Explorar el Código

feat(admin-ui): Experimental system for extending the UI

Relates to #55
Michael Bromley hace 6 años
padre
commit
1dcb2e600d

+ 1 - 0
packages/admin-ui-plugin/package.json

@@ -25,6 +25,7 @@
     "typescript": "^3.3.4000"
   },
   "dependencies": {
+    "@vendure/admin-ui": "0.2.0",
     "fs-extra": "^8.0.1"
   }
 }

+ 41 - 8
packages/admin-ui-plugin/src/plugin.ts

@@ -1,10 +1,14 @@
+import { compileUiExtensions } from '@vendure/admin-ui/src/compile-ui-extensions';
 import { DEFAULT_AUTH_TOKEN_HEADER_KEY } from '@vendure/common/lib/shared-constants';
-import { AdminUiConfig, Type } from '@vendure/common/lib/shared-types';
+import { AdminUiConfig, AdminUiExtension, Type } from '@vendure/common/lib/shared-types';
 import {
     AuthOptions,
+    ConfigService,
     createProxyHandler,
+    Logger,
     OnVendureBootstrap,
     OnVendureClose,
+    PluginCommonModule,
     RuntimeVendureConfig,
     VendurePlugin,
 } from '@vendure/core';
@@ -50,6 +54,12 @@ export interface AdminUiOptions {
      * @default 'auto'
      */
     apiPort?: number | 'auto';
+    /**
+     * @description
+     * An optional array of objects which configure extension Angular modules
+     * to be compiled into and made available by the AdminUi application.
+     */
+    extensions?: AdminUiExtension[];
 }
 
 /**
@@ -82,12 +92,15 @@ export interface AdminUiOptions {
  * @docsCategory AdminUiPlugin
  */
 @VendurePlugin({
+    imports: [PluginCommonModule],
     configuration: config => AdminUiPlugin.configure(config),
 })
 export class AdminUiPlugin implements OnVendureBootstrap, OnVendureClose {
     private static options: AdminUiOptions;
     private server: Server;
 
+    constructor(private configService: ConfigService) {}
+
     /**
      * @description
      * Set the plugin options
@@ -104,15 +117,17 @@ export class AdminUiPlugin implements OnVendureBootstrap, OnVendureClose {
             handler: createProxyHandler({ ...this.options, route, label: 'Admin UI' }),
             route,
         });
-        const { adminApiPath, authOptions } = config;
-        const { apiHost, apiPort } = this.options;
-        await this.overwriteAdminUiConfig(apiHost || 'auto', apiPort || 'auto', adminApiPath, authOptions);
         return config;
     }
 
     /** @internal */
-    onVendureBootstrap() {
-        const adminUiPath = AdminUiPlugin.getAdminUiPath();
+    async onVendureBootstrap() {
+        const { adminApiPath, authOptions } = this.configService;
+        const { apiHost, apiPort } = AdminUiPlugin.options;
+        await this.compileAdminUiApp();
+        await this.overwriteAdminUiConfig(apiHost || 'auto', apiPort || 'auto', adminApiPath, authOptions);
+
+        const adminUiPath = this.getAdminUiPath();
         const assetServer = express();
         assetServer.use(express.static(adminUiPath));
         assetServer.use((req, res) => {
@@ -126,11 +141,29 @@ export class AdminUiPlugin implements OnVendureBootstrap, OnVendureClose {
         return new Promise(resolve => this.server.close(() => resolve()));
     }
 
+    private async compileAdminUiApp() {
+        const extensions = this.getExtensions();
+        Logger.info('Compiling Admin UI extensions...', 'AdminUiPlugin');
+        await compileUiExtensions(path.join(__dirname, '../admin-ui'), extensions);
+        Logger.info('Completed compilation!', 'AdminUiPlugin');
+    }
+
+    private getExtensions(): Array<Required<AdminUiExtension>> {
+        return (AdminUiPlugin.options.extensions || []).map(e => {
+            const id =
+                e.id ||
+                Math.random()
+                    .toString(36)
+                    .substr(4);
+            return { ...e, id };
+        });
+    }
+
     /**
      * Overwrites the parts of the admin-ui app's `vendure-ui-config.json` file relating to connecting to
      * the server admin API.
      */
-    private static async overwriteAdminUiConfig(
+    private async overwriteAdminUiConfig(
         host: string | 'auto',
         port: number | 'auto',
         adminApiPath: string,
@@ -152,7 +185,7 @@ export class AdminUiPlugin implements OnVendureBootstrap, OnVendureClose {
         await fs.writeFile(adminUiConfigPath, JSON.stringify(config, null, 2));
     }
 
-    private static getAdminUiPath(): string {
+    private getAdminUiPath(): string {
         // attempt to read from the path location on a production npm install
         const prodPath = path.join(__dirname, '../admin-ui');
         if (fs.existsSync(path.join(prodPath, 'index.html'))) {

+ 4 - 0
packages/admin-ui/.gitignore

@@ -8,6 +8,10 @@
 # dependencies
 /node_modules
 
+# generated extension files
+/src/app/extensions/modules
+/src/app/extensions/extensions.module.ts.generated
+
 # IDEs and editors
 /.idea
 .project

+ 5 - 4
packages/admin-ui/package.json

@@ -12,12 +12,16 @@
   },
   "private": false,
   "dependencies": {
+    "@angular-devkit/build-angular": "^0.802.0",
     "@angular/animations": "^8.2.0",
     "@angular/cdk": "^8.1.2",
+    "@angular/cli": "^8.2.0",
     "@angular/common": "^8.2.0",
     "@angular/compiler": "^8.2.0",
+    "@angular/compiler-cli": "^8.2.0",
     "@angular/core": "^8.2.0",
     "@angular/forms": "^8.2.0",
+    "@angular/language-service": "^8.2.0",
     "@angular/platform-browser": "^8.2.0",
     "@angular/platform-browser-dynamic": "^8.2.0",
     "@angular/router": "^8.2.0",
@@ -35,6 +39,7 @@
     "apollo-link-context": "^1.0.18",
     "apollo-upload-client": "^11.0.0",
     "core-js": "^3.1.3",
+    "fs-extra": "^8.1.0",
     "graphql": "^14.3.1",
     "graphql-tag": "^2.10.1",
     "messageformat": "2.2.0",
@@ -46,10 +51,6 @@
     "zone.js": "^0.10.0"
   },
   "devDependencies": {
-    "@angular-devkit/build-angular": "^0.802.0",
-    "@angular/cli": "^8.2.0",
-    "@angular/compiler-cli": "^8.2.0",
-    "@angular/language-service": "^8.2.0",
     "@biesbjerg/ngx-translate-extract": "^2.3.4",
     "@types/jasmine": "~3.3.16",
     "@types/jasminewd2": "~2.0.6",

+ 4 - 0
packages/admin-ui/src/app/app.routes.ts

@@ -39,6 +39,10 @@ export const routes: Route[] = [
                 path: 'settings',
                 loadChildren: './settings/settings.module#SettingsModule',
             },
+            {
+                path: 'extensions',
+                loadChildren: `./extensions/extensions.module#ExtensionsModule`,
+            },
         ],
     },
 ];

+ 12 - 0
packages/admin-ui/src/app/extensions/extensions.module.ts

@@ -0,0 +1,12 @@
+import { CommonModule } from '@angular/common';
+import { NgModule } from '@angular/core';
+
+/**
+ * This is a placeholder module for UI extensions provided by the AdminUiPlugin `extensions` option.
+ * When the {@link compileUiExtensions} function is executed, this module gets temporarily replaced
+ * by a generated module which includes all of the configured extension modules.
+ */
+@NgModule({
+    imports: [CommonModule],
+})
+export class ExtensionsModule {}

+ 95 - 0
packages/admin-ui/src/compile-ui-extensions.ts

@@ -0,0 +1,95 @@
+import { AdminUiExtension } from '@vendure/common/lib/shared-types';
+import { exec, spawn } from 'child_process';
+import * as fs from 'fs-extra';
+import * as path from 'path';
+
+const EXTENSIONS_DIR = path.join(__dirname, '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');
+
+/**
+ * Builds the admin-ui app using the Angular CLI `ng build --prod` command.
+ */
+export function compileUiExtensions(outputPath: string, extensions: Array<Required<AdminUiExtension>>) {
+    const cwd = path.join(__dirname, '..');
+    const relativeOutputPath = path.relative(cwd, outputPath);
+    return new Promise((resolve, reject) => {
+        deleteExistingExtensionModules();
+        copyExtensionModules(extensions);
+        createExtensionsModule(extensions);
+
+        const buildProcess = spawn(
+            'yarn',
+            [
+                'build',
+                /*'--prod=true', */
+                `--outputPath=${relativeOutputPath}`,
+            ],
+            {
+                cwd,
+                shell: true,
+                stdio: 'inherit',
+            },
+        );
+        buildProcess.on('close', code => {
+            if (code === 0) {
+                resolve();
+            } else {
+                reject(code);
+            }
+        });
+        buildProcess.on('error', err => {
+            reject(err);
+        });
+    }).finally(() => {
+        restoreOriginalExtensionsModule();
+    });
+}
+
+function deleteExistingExtensionModules() {
+    fs.removeSync(path.join(EXTENSIONS_DIR, EXTENSIONS_MODULES_DIR));
+}
+
+function copyExtensionModules(extensions: Array<Required<AdminUiExtension>>) {
+    for (const extension of extensions) {
+        const dirName = path.basename(path.dirname(extension.ngModulePath));
+        const dest = path.join(EXTENSIONS_DIR, EXTENSIONS_MODULES_DIR, extension.id);
+        fs.copySync(extension.ngModulePath, dest);
+    }
+}
+
+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');
+}
+
+function restoreOriginalExtensionsModule() {
+    fs.renameSync(originalExtensionsModuleFile, path.join(EXTENSIONS_DIR, 'extensions.module.ts.generated'));
+    fs.renameSync(tempExtensionsModuleFile, originalExtensionsModuleFile);
+}
+
+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 {}
+`;
+}

+ 1 - 0
packages/admin-ui/src/index.ts

@@ -0,0 +1 @@
+export * from './compile-ui-extensions';

+ 7 - 0
packages/common/src/shared-types.ts

@@ -89,3 +89,10 @@ export interface AdminUiConfig {
     tokenMethod: 'cookie' | 'bearer';
     authTokenHeaderKey: string;
 }
+
+export interface AdminUiExtension {
+    id?: string;
+    ngModulePath: string;
+    ngModuleFileName: string;
+    ngModuleName: string;
+}

+ 3 - 3
packages/core/src/plugin/plugin.module.ts

@@ -45,13 +45,13 @@ export class PluginModule implements OnModuleInit, OnModuleDestroy {
 
     async onModuleInit() {
         if (this.processContext === PluginProcessContext.Main) {
-            this.runPluginLifecycleMethods('onVendureBootstrap', instance => {
+            await this.runPluginLifecycleMethods('onVendureBootstrap', instance => {
                 const pluginName = instance.constructor.name || '(anonymous plugin)';
                 Logger.verbose(`Bootstrapped plugin ${pluginName}`);
             });
         }
         if (this.processContext === PluginProcessContext.Worker) {
-            this.runPluginLifecycleMethods('onVendureWorkerBootstrap');
+            await this.runPluginLifecycleMethods('onVendureWorkerBootstrap');
         }
     }
 
@@ -60,7 +60,7 @@ export class PluginModule implements OnModuleInit, OnModuleDestroy {
             await this.runPluginLifecycleMethods('onVendureClose');
         }
         if (this.processContext === PluginProcessContext.Worker) {
-            this.runPluginLifecycleMethods('onVendureWorkerClose');
+            await this.runPluginLifecycleMethods('onVendureWorkerClose');
         }
     }
 

+ 4 - 1
packages/dev-server/dev-config.ts

@@ -15,6 +15,7 @@ import path from 'path';
 import { ConnectionOptions } from 'typeorm';
 
 import { RestPlugin } from './rest-plugin';
+import { UiPlugin } from './ui-plugin/ui-plugin';
 
 /**
  * Config settings used during development
@@ -42,7 +43,7 @@ export const devConfig: VendureConfig = {
             { type: 'datetime', name: 'expires' },
         ],*/
     },
-    logger: new DefaultLogger({ level: LogLevel.Verbose }),
+    logger: new DefaultLogger({ level: LogLevel.Info }),
     importExportOptions: {
         importAssetsDir: path.join(__dirname, 'import-assets'),
     },
@@ -69,8 +70,10 @@ export const devConfig: VendureConfig = {
                 changeEmailAddressUrl: 'http://localhost:4201/change-email-address',
             },
         }),
+        UiPlugin,
         AdminUiPlugin.init({
             port: 5001,
+            extensions: UiPlugin.uiExtensions,
         }),
     ],
 };

+ 23 - 0
packages/dev-server/ui-plugin/module/ui-plugin.module.ts

@@ -0,0 +1,23 @@
+import { Component, NgModule } from '@angular/core';
+import { RouterModule } from '@angular/router';
+
+@Component({
+    selector: 'plugin-test-component',
+    template: `
+        <p>Test component works!!</p>
+    `,
+})
+export class TestComponent {}
+
+@NgModule({
+    imports: [
+        RouterModule.forChild([
+            {
+                path: 'test',
+                component: TestComponent,
+            },
+        ]),
+    ],
+    declarations: [TestComponent],
+})
+export class TestModule {}

+ 14 - 0
packages/dev-server/ui-plugin/ui-plugin.ts

@@ -0,0 +1,14 @@
+import { AdminUiExtension } from '@vendure/common/lib/shared-types';
+import { VendurePlugin } from '@vendure/core';
+import path from 'path';
+
+@VendurePlugin({})
+export class UiPlugin {
+    static uiExtensions: AdminUiExtension[] = [
+        {
+            ngModulePath: path.join(__dirname, 'module'),
+            ngModuleFileName: 'ui-plugin.module.ts',
+            ngModuleName: 'TestModule',
+        },
+    ];
+}