Skip to content

Commit

Permalink
feat(admin-ui-plugin): Add watch mode for UI extension development
Browse files Browse the repository at this point in the history
Relates to #55
  • Loading branch information
michaelbromley committed Sep 9, 2019
1 parent bc321af commit c0b4d3f
Show file tree
Hide file tree
Showing 13 changed files with 336 additions and 93 deletions.
4 changes: 4 additions & 0 deletions packages/admin-ui-plugin/src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import path from 'path';

export const UI_PATH = path.join(__dirname, '../admin-ui');
export const loggerCtx = 'AdminUiPlugin';
72 changes: 56 additions & 16 deletions packages/admin-ui-plugin/src/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Watcher } from '@vendure/admin-ui/devkit/watch';
import { DEFAULT_AUTH_TOKEN_HEADER_KEY } from '@vendure/common/lib/shared-constants';
import { AdminUiConfig, AdminUiExtension, Type } from '@vendure/common/lib/shared-types';
import {
Expand All @@ -16,6 +17,7 @@ import fs from 'fs-extra';
import { Server } from 'http';
import path from 'path';

import { UI_PATH } from './constants';
import { UiAppCompiler } from './ui-app-compiler.service';

/**
Expand Down Expand Up @@ -61,6 +63,16 @@ export interface AdminUiOptions {
* to be compiled into and made available by the AdminUi application.
*/
extensions?: AdminUiExtension[];
/**
* @description
* Set to `true` in order to run the Admin UI in development mode (using the Angular CLI
* [ng serve](https://angular.io/cli/serve) command). When in watch mode, any changes to
* UI extension files will be watched and trigger a rebuild of the Admin UI with live
* reloading.
*
* @default false
*/
watch?: boolean;
}

/**
Expand Down Expand Up @@ -100,6 +112,7 @@ export interface AdminUiOptions {
export class AdminUiPlugin implements OnVendureBootstrap, OnVendureClose {
private static options: AdminUiOptions;
private server: Server;
private watcher: Watcher | undefined;

constructor(private configService: ConfigService, private appCompiler: UiAppCompiler) {}

Expand All @@ -116,36 +129,64 @@ export class AdminUiPlugin implements OnVendureBootstrap, OnVendureClose {
static async configure(config: RuntimeVendureConfig): Promise<RuntimeVendureConfig> {
const route = 'admin';
config.middleware.push({
handler: createProxyHandler({ ...this.options, route, label: 'Admin UI' }),
handler: createProxyHandler({
...this.options,
route: 'admin',
label: 'Admin UI',
basePath: this.options.watch ? 'admin' : undefined,
}),
route,
});
if (this.options.watch) {
config.middleware.push({
handler: createProxyHandler({
...this.options,
route: 'sockjs-node',
label: 'Admin UI live reload',
basePath: 'sockjs-node',
}),
route: 'sockjs-node',
});
}
return config;
}

/** @internal */
async onVendureBootstrap() {
const { adminApiPath, authOptions } = this.configService;
const { apiHost, apiPort, extensions } = AdminUiPlugin.options;
const adminUiPath = await this.appCompiler.compileAdminUiApp(extensions);
const { apiHost, apiPort, extensions, watch, port } = AdminUiPlugin.options;
let adminUiConfigPath: string;

if (watch) {
this.watcher = this.appCompiler.watchAdminUiApp(extensions, port);
adminUiConfigPath = path.join(__dirname, '../../../admin-ui/src', 'vendure-ui-config.json');
} else {
const adminUiPath = await this.appCompiler.compileAdminUiApp(extensions);
const adminUiServer = express();
adminUiServer.use(express.static(UI_PATH));
adminUiServer.use((req, res) => {
res.sendFile(path.join(UI_PATH, 'index.html'));
});
this.server = adminUiServer.listen(AdminUiPlugin.options.port);
adminUiConfigPath = path.join(UI_PATH, 'vendure-ui-config.json');
}
await this.overwriteAdminUiConfig({
host: apiHost || 'auto',
port: apiPort || 'auto',
adminApiPath,
adminUiPath,
authOptions,
adminUiConfigPath,
});

const adminUiServer = express();
adminUiServer.use(express.static(adminUiPath));
adminUiServer.use((req, res) => {
res.sendFile(path.join(adminUiPath, 'index.html'));
});
this.server = adminUiServer.listen(AdminUiPlugin.options.port);
}

/** @internal */
onVendureClose(): Promise<void> {
return new Promise(resolve => this.server.close(() => resolve()));
async onVendureClose(): Promise<void> {
if (this.watcher) {
this.watcher.close();
}
if (this.server) {
await new Promise(resolve => this.server.close(() => resolve()));
}
}

/**
Expand All @@ -155,12 +196,11 @@ export class AdminUiPlugin implements OnVendureBootstrap, OnVendureClose {
private async overwriteAdminUiConfig(options: {
host: string | 'auto';
port: number | 'auto';
adminUiPath: string;
adminApiPath: string;
authOptions: AuthOptions;
adminUiConfigPath: string;
}) {
const { host, port, adminApiPath, adminUiPath, authOptions } = options;
const adminUiConfigPath = path.join(adminUiPath, 'vendure-ui-config.json');
const { host, port, adminApiPath, authOptions, adminUiConfigPath } = options;
const adminUiConfig = await fs.readFile(adminUiConfigPath, 'utf-8');
let config: AdminUiConfig;
try {
Expand Down
21 changes: 14 additions & 7 deletions packages/admin-ui-plugin/src/ui-app-compiler.service.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,35 @@
import { Injectable } from '@nestjs/common';
import { compileAdminUiApp } from '@vendure/admin-ui/devkit/compile';
import { watchAdminUiApp, Watcher } from '@vendure/admin-ui/devkit/watch';
import { AdminUiExtension } from '@vendure/common/lib/shared-types';
import { Logger } from '@vendure/core';
import crypto from 'crypto';
import fs from 'fs-extra';
import path from 'path';

import { loggerCtx, UI_PATH } from './constants';

@Injectable()
export class UiAppCompiler {
private readonly outputPath = path.join(__dirname, '../admin-ui');
private readonly hashfile = path.join(__dirname, 'modules-hash.txt');

async compileAdminUiApp(extensions: AdminUiExtension[] | undefined): Promise<string> {
const compiledAppExists = fs.existsSync(path.join(this.outputPath, 'index.html'));
watchAdminUiApp(extensions: AdminUiExtension[] | undefined, port: number): Watcher {
const extensionsWithId = this.normalizeExtensions(extensions);
Logger.info(`Starting Admin UI in Angular dev server on port ${port}`, loggerCtx);
return watchAdminUiApp(extensionsWithId, port);
}

async compileAdminUiApp(extensions: AdminUiExtension[] | undefined): Promise<void> {
const compiledAppExists = fs.existsSync(path.join(UI_PATH, 'index.html'));
const extensionsWithId = this.normalizeExtensions(extensions);

if (!compiledAppExists || this.extensionModulesHaveChanged(extensionsWithId)) {
Logger.info('Compiling Admin UI with extensions...', 'AdminUiPlugin');
Logger.info('Compiling Admin UI with extensions...', loggerCtx);
await compileAdminUiApp(path.join(__dirname, '../admin-ui'), extensionsWithId);
Logger.info('Completed compilation!', 'AdminUiPlugin');
Logger.info('Completed compilation!', loggerCtx);
} else {
Logger.info('Extensions not changed since last run', 'AdminUiPlugin');
Logger.verbose('Extensions not changed since last run', loggerCtx);
}
return this.outputPath;
}

/**
Expand Down
7 changes: 4 additions & 3 deletions packages/admin-ui/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@
# generated extension files
/src/app/extensions/modules
/src/app/extensions/extensions.module.ts.generated
/src/app/extensions/extensions.module.ts.temp

# compiled devkit files
/devkit/compile.js
/devkit/compile.d.ts
/devkit/compile.js.map
/devkit/*.js
/devkit/*.d.ts
/devkit/*.js.map

# IDEs and editors
/.idea
Expand Down
43 changes: 42 additions & 1 deletion packages/admin-ui/angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,22 @@
"vendorChunk": false,
"buildOptimizer": true
},
"compile-in-plugin": {
"plugin": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
],
"optimization": true,
"outputHashing": "all",
"sourceMap": true,
"extractCss": true,
"namedChunks": false,
"aot": true,
"extractLicenses": true,
"vendorChunk": false,
"buildOptimizer": true,
"styles": [
"../../@clr/icons/clr-icons.min.css",
"src/styles/styles.scss",
Expand All @@ -69,6 +84,26 @@
"scripts": [
"../../trix/dist/trix-core.js"
]
},
"plugin-watch": {
"styles": [
"../../@clr/icons/clr-icons.min.css",
"src/styles/styles.scss",
"../../trix/dist/trix.css"
],
"scripts": [
"../../trix/dist/trix-core.js"
]
},
"plugin-dev": {
"styles": [
"../../node_modules/@clr/icons/clr-icons.min.css",
"src/styles/styles.scss",
"../../node_modules/trix/dist/trix.css"
],
"scripts": [
"../../node_modules/trix/dist/trix-core.js"
]
}
}
},
Expand All @@ -80,6 +115,12 @@
"configurations": {
"production": {
"browserTarget": "vendure-admin:build:production"
},
"plugin": {
"browserTarget": "vendure-admin:build:plugin-watch"
},
"plugin-dev": {
"browserTarget": "vendure-admin:build:plugin-dev"
}
}
},
Expand Down
80 changes: 80 additions & 0 deletions packages/admin-ui/devkit/common.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { AdminUiExtension } from '@vendure/common/lib/shared-types';
import * as fs from 'fs-extra';
import * as path from 'path';

const EXTENSIONS_DIR = path.join(__dirname, '../src/app/extensions');
const EXTENSIONS_MODULES_DIR = 'modules';
const originalExtensionsModuleFile = path.join(EXTENSIONS_DIR, 'extensions.module.ts');
const tempExtensionsModuleFile = path.join(EXTENSIONS_DIR, 'extensions.module.ts.temp');

/**
* Returns true if currently being executed from inside the Vendure monorepo.
*/
export function isInVendureMonorepo(): boolean {
return fs.existsSync(path.join(__dirname, '../../dev-server'));
}

/**
* Restores the placeholder ExtensionsModule file from a template.
*/
export function restoreExtensionsModule() {
fs.copyFileSync(path.join(__dirname, 'extensions.module.ts.template'), originalExtensionsModuleFile);
}

/**
* Deletes the contents of the /modules directory, which contains the plugin
* extension modules copied over during the last compilation.
*/
export function deleteExistingExtensionModules() {
fs.removeSync(path.join(EXTENSIONS_DIR, EXTENSIONS_MODULES_DIR));
}

/**
* Copies all files from the ngModulePaths of the configured extensions into the
* admin-ui source tree.
*/
export function copyExtensionModules(extensions: Array<Required<AdminUiExtension>>) {
for (const extension of extensions) {
const dirName = path.basename(path.dirname(extension.ngModulePath));
const dest = getModuleOutputDir(extension);
fs.copySync(extension.ngModulePath, dest);
}
}

export function getModuleOutputDir(extension: Required<AdminUiExtension>): string {
return path.join(EXTENSIONS_DIR, EXTENSIONS_MODULES_DIR, extension.id);
}

export function createExtensionsModule(extensions: Array<Required<AdminUiExtension>>) {
const removeTsExtension = (filename: string): string => filename.replace(/\.ts$/, '');
const importPath = (e: Required<AdminUiExtension>): string =>
`./${EXTENSIONS_MODULES_DIR}/${e.id}/${removeTsExtension(e.ngModuleFileName)}`;
fs.renameSync(originalExtensionsModuleFile, tempExtensionsModuleFile);

const source = generateExtensionModuleTsSource(
extensions.map(e => ({ className: e.ngModuleName, path: importPath(e) })),
);
fs.writeFileSync(path.join(EXTENSIONS_DIR, 'extensions.module.ts'), source, 'utf-8');
}

export function restoreOriginalExtensionsModule() {
fs.renameSync(originalExtensionsModuleFile, path.join(EXTENSIONS_DIR, 'extensions.module.ts.generated'));
restoreExtensionsModule();
}

function generateExtensionModuleTsSource(modules: Array<{ className: string; path: string }>): string {
return `/** This file is generated by the build() function. Do not edit. */
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
${modules.map(e => `import { ${e.className} } from '${e.path}';`).join('\n')}
@NgModule({
imports: [
CommonModule,
${modules.map(e => e.className + ',').join('\n')}
],
})
export class ExtensionsModule {}
`;
}
Loading

0 comments on commit c0b4d3f

Please sign in to comment.