Explorar o código

feat(admin-ui): Initial implementation of extension host architecture

Relates to #225
Michael Bromley %!s(int64=6) %!d(string=hai) anos
pai
achega
85815c1472

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

@@ -9,6 +9,7 @@
 /node_modules
 
 # generated extension files
+/src/app/extensions/__static-assets__
 /src/app/extensions/modules
 /src/app/extensions/extensions.module.ts.generated
 /src/app/extensions/extensions.module.ts.temp

+ 6 - 1
packages/admin-ui/angular.json

@@ -27,7 +27,12 @@
               "src/favicon.ico",
               "src/vendure-ui-config.json",
               "src/assets",
-              "src/i18n-messages"
+              "src/i18n-messages",
+              {
+                "glob": "**/*.*",
+                "input": "src/app/extensions/__static-assets__",
+                "output": "assets"
+              }
             ],
             "styles": [
               "../../node_modules/@clr/icons/clr-icons.min.css",

+ 3 - 0
packages/admin-ui/src/app/core/components/app-shell/app-shell.component.scss

@@ -11,3 +11,6 @@ vdr-breadcrumb {
         margin-left: 9rem;
     }
 }
+.content-area {
+    position: relative;
+}

+ 10 - 0
packages/admin-ui/src/app/shared/components/extension-host/extension-host-config.ts

@@ -0,0 +1,10 @@
+export interface ExtensionHostOptions {
+    extensionUrl: string;
+}
+
+export class ExtensionHostConfig {
+    public extensionUrl: string;
+    constructor(options: ExtensionHostOptions) {
+        this.extensionUrl = options.extensionUrl;
+    }
+}

+ 1 - 0
packages/admin-ui/src/app/shared/components/extension-host/extension-host.component.html

@@ -0,0 +1 @@
+<iframe [src]="extensionUrl" #extensionFrame></iframe>

+ 12 - 0
packages/admin-ui/src/app/shared/components/extension-host/extension-host.component.scss

@@ -0,0 +1,12 @@
+@import "variables";
+
+iframe {
+    position: absolute;
+    left: 0;
+    top: 0;
+    bottom: 0;
+    right: 0;
+    width: 100%;
+    height: 100%;
+    border: none;
+}

+ 44 - 0
packages/admin-ui/src/app/shared/components/extension-host/extension-host.component.ts

@@ -0,0 +1,44 @@
+import { ChangeDetectionStrategy, Component, ElementRef, OnInit, ViewChild } from '@angular/core';
+import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
+import { ActivatedRoute } from '@angular/router';
+
+import { ExtensionHostConfig } from './extension-host-config';
+import { ExtensionHostService } from './extension-host.service';
+
+@Component({
+    selector: 'vdr-extension-host',
+    templateUrl: './extension-host.component.html',
+    styleUrls: ['./extension-host.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+    providers: [ExtensionHostService],
+})
+export class ExtensionHostComponent implements OnInit {
+    extensionUrl: SafeResourceUrl;
+    @ViewChild('extensionFrame', { static: true }) private extensionFrame: ElementRef<HTMLIFrameElement>;
+
+    constructor(
+        private route: ActivatedRoute,
+        private sanitizer: DomSanitizer,
+        private extensionHostService: ExtensionHostService,
+    ) {}
+
+    ngOnInit() {
+        const { data } = this.route.snapshot;
+        if (!this.isExtensionHostConfig(data.extensionHostConfig)) {
+            throw new Error(
+                `Expected an ExtensionHostConfig object, got ${JSON.stringify(data.extensionHostConfig)}`,
+            );
+        }
+        this.extensionUrl = this.sanitizer.bypassSecurityTrustResourceUrl(
+            data.extensionHostConfig.extensionUrl || 'about:blank',
+        );
+        const { contentWindow } = this.extensionFrame.nativeElement;
+        if (contentWindow) {
+            this.extensionHostService.init(contentWindow);
+        }
+    }
+
+    private isExtensionHostConfig(input: any): input is ExtensionHostConfig {
+        return input.hasOwnProperty('extensionUrl');
+    }
+}

+ 51 - 0
packages/admin-ui/src/app/shared/components/extension-host/extension-host.service.ts

@@ -0,0 +1,51 @@
+import { Injectable, OnDestroy } from '@angular/core';
+import { DataService } from '@vendure/admin-ui/src/app/data/providers/data.service';
+import { parse } from 'graphql';
+
+import { ExtensionMesssage } from './extension-message-types';
+
+@Injectable()
+export class ExtensionHostService implements OnDestroy {
+    private extensionWindow: Window;
+
+    constructor(private dataService: DataService) {}
+
+    init(extensionWindow: Window) {
+        this.extensionWindow = extensionWindow;
+
+        window.addEventListener('message', this.handleMessage);
+    }
+
+    ngOnDestroy(): void {
+        window.removeEventListener('message', this.handleMessage);
+    }
+
+    private handleMessage = (message: MessageEvent) => {
+        const { data, origin } = message;
+        if (this.isExtensionMessage(data)) {
+            switch (data.type) {
+                case 'query': {
+                    const { document, variables, fetchPolicy } = data.data;
+                    this.dataService
+                        .query(parse(document), variables, fetchPolicy)
+                        .single$.subscribe(result => this.sendMessage(result, origin, data.requestId));
+                    break;
+                }
+                case 'mutation': {
+                    const { document, variables } = data.data;
+                    this.dataService
+                        .mutate(parse(document), variables)
+                        .subscribe(result => this.sendMessage(result, origin, data.requestId));
+                }
+            }
+        }
+    };
+
+    private sendMessage(message: any, origin, requestId: string) {
+        this.extensionWindow.postMessage({ requestId, data: message }, origin);
+    }
+
+    private isExtensionMessage(input: any): input is ExtensionMesssage {
+        return input.hasOwnProperty('type') && input.hasOwnProperty('data');
+    }
+}

+ 26 - 0
packages/admin-ui/src/app/shared/components/extension-host/extension-message-types.ts

@@ -0,0 +1,26 @@
+import { WatchQueryFetchPolicy } from 'apollo-client';
+
+export interface BaseExtensionMessage {
+    requestId: string;
+    type: string;
+    data: any;
+}
+
+export interface QueryMessage extends BaseExtensionMessage {
+    type: 'query';
+    data: {
+        document: string;
+        variables?: { [key: string]: any };
+        fetchPolicy?: WatchQueryFetchPolicy;
+    };
+}
+
+export interface MutationMessage extends BaseExtensionMessage {
+    type: 'mutation';
+    data: {
+        document: string;
+        variables?: { [key: string]: any };
+    };
+}
+
+export type ExtensionMesssage = QueryMessage | MutationMessage;

+ 2 - 0
packages/admin-ui/src/app/shared/shared-declarations.ts

@@ -45,6 +45,8 @@ export { LanguageSelectorComponent } from './components/language-selector/langua
 export { DialogButtonsDirective } from './components/modal-dialog/dialog-buttons.directive';
 export { DialogComponentOutletComponent } from './components/modal-dialog/dialog-component-outlet.component';
 export { DialogTitleDirective } from './components/modal-dialog/dialog-title.directive';
+export { ExtensionHostComponent } from './components/extension-host/extension-host.component';
+export * from './components/extension-host/extension-host-config';
 export { ModalDialogComponent } from './components/modal-dialog/modal-dialog.component';
 export { ObjectTreeComponent } from './components/object-tree/object-tree.component';
 export { OrderStateLabelComponent } from './components/order-state-label/order-state-label.component';

+ 2 - 0
packages/admin-ui/src/app/shared/shared.module.ts

@@ -43,6 +43,7 @@ import {
     DropdownMenuComponent,
     DropdownTriggerDirective,
     EntityInfoComponent,
+    ExtensionHostComponent,
     FacetValueChipComponent,
     FacetValueSelectorComponent,
     FileSizePipe,
@@ -140,6 +141,7 @@ const DECLARATIONS = [
     ChannelAssignmentControlComponent,
     ChannelLabelPipe,
     IfDefaultChannelActiveDirective,
+    ExtensionHostComponent,
 ];
 
 @NgModule({

+ 17 - 0
packages/admin-ui/src/devkit/common.ts

@@ -4,6 +4,7 @@ import * as path from 'path';
 
 const EXTENSIONS_DIR = path.join(__dirname, '../src/app/extensions');
 const EXTENSIONS_MODULES_DIR = 'modules';
+const STATIC_ASSETS_OUTPUT_DIR = path.join(EXTENSIONS_DIR, '__static-assets__');
 const lazyExtensionsModuleFile = path.join(EXTENSIONS_DIR, 'lazy-extensions.module.ts');
 const sharedExtensionsModuleFile = path.join(EXTENSIONS_DIR, 'shared-extensions.module.ts');
 
@@ -20,6 +21,7 @@ export function isInVendureMonorepo(): boolean {
  */
 export function deleteExistingExtensionModules() {
     fs.removeSync(path.join(EXTENSIONS_DIR, EXTENSIONS_MODULES_DIR));
+    fs.removeSync(STATIC_ASSETS_OUTPUT_DIR);
 }
 
 /**
@@ -31,6 +33,21 @@ export function copyExtensionModules(extensions: Array<Required<AdminUiExtension
         const dirName = path.basename(path.dirname(extension.extensionPath));
         const dest = getModuleOutputDir(extension);
         fs.copySync(extension.extensionPath, dest);
+        if (Array.isArray(extension.staticAssets)) {
+            for (const asset of extension.staticAssets) {
+                copyStaticAsset(asset);
+            }
+        }
+    }
+}
+
+export function copyStaticAsset(staticAssetPath: string) {
+    const stats = fs.statSync(staticAssetPath);
+    if (stats.isDirectory()) {
+        const assetDirname = path.basename(staticAssetPath);
+        fs.copySync(staticAssetPath, path.join(STATIC_ASSETS_OUTPUT_DIR, assetDirname));
+    } else {
+        fs.copySync(staticAssetPath, STATIC_ASSETS_OUTPUT_DIR);
     }
 }
 

+ 9 - 0
packages/admin-ui/src/devkit/watch.ts

@@ -6,6 +6,7 @@ import * as path from 'path';
 
 import {
     copyExtensionModules,
+    copyStaticAsset,
     createExtensionsModules,
     deleteExistingExtensionModules,
     getModuleOutputDir,
@@ -53,6 +54,14 @@ export function watchAdminUiApp(extensions: Array<Required<AdminUiExtension>>, p
         watcher.on('change', filePath => {
             const extension = extensions.find(e => filePath.includes(e.extensionPath));
             if (extension) {
+                if (extension.staticAssets) {
+                    for (const assetPath of extension.staticAssets) {
+                        if (filePath.includes(assetPath)) {
+                            copyStaticAsset(assetPath);
+                            return;
+                        }
+                    }
+                }
                 const outputDir = getModuleOutputDir(extension);
                 const filePart = path.relative(extension.extensionPath, filePath);
                 const dest = path.join(outputDir, filePart);

+ 8 - 1
packages/common/src/shared-types.ts

@@ -22,7 +22,7 @@ export type DeepRequired<T, U extends object | undefined = undefined> = T extend
     ? {
           [P in keyof T]-?: NonNullable<T[P]> extends NonNullable<U | Function | Type<any>>
               ? NonNullable<T[P]>
-              : DeepRequired<NonNullable<T[P]>, U>
+              : DeepRequired<NonNullable<T[P]>, U>;
       }
     : T;
 // tslint:enable:ban-types
@@ -121,6 +121,13 @@ export interface AdminUiExtension {
      * One or more Angular modules which extend the default Admin UI.
      */
     ngModules: AdminUiExtensionModule[];
+
+    /**
+     * @description
+     * Optional array of paths to static assets which will be copied over to the Admin UI app's `/static`
+     * directory.
+     */
+    staticAssets?: string[];
 }
 
 /**