UI: External angular modules for widget development

This commit is contained in:
Igor Kulikov 2020-07-15 12:44:16 +03:00
parent 2ca577e9c5
commit 21d7350efc
8 changed files with 176 additions and 33 deletions

View File

@ -25,6 +25,10 @@ const PROXY_CONFIG = {
"target": `http://${ruleNodeUiforwardHost}:${ruleNodeUiforwardPort}`, "target": `http://${ruleNodeUiforwardHost}:${ruleNodeUiforwardPort}`,
"secure": false, "secure": false,
}, },
"/static": {
"target": "http://localhost:8080",
"secure": false,
},
"/api/ws": { "/api/ws": {
"target": "ws://localhost:8080", "target": "ws://localhost:8080",
"ws": true, "ws": true,

View File

@ -239,7 +239,7 @@ export class RuleChainService {
}); });
} }
if (moduleResource) { if (moduleResource) {
tasks.push(this.resourcesService.loadModule(moduleResource, ruleNodeConfigResourcesModulesMap).pipe( tasks.push(this.resourcesService.loadFactories(moduleResource, ruleNodeConfigResourcesModulesMap).pipe(
map((res) => { map((res) => {
if (nodeDefinition.configDirective && nodeDefinition.configDirective.length) { if (nodeDefinition.configDirective && nodeDefinition.configDirective.length) {
const selector = snakeCase(nodeDefinition.configDirective, '-'); const selector = snakeCase(nodeDefinition.configDirective, '-');

View File

@ -25,6 +25,8 @@ import {
} from '@angular/core'; } from '@angular/core';
import { DOCUMENT } from '@angular/common'; import { DOCUMENT } from '@angular/common';
import { forkJoin, Observable, ReplaySubject, throwError } from 'rxjs'; import { forkJoin, Observable, ReplaySubject, throwError } from 'rxjs';
import { HttpClient } from '@angular/common/http';
import { objToBase64 } from '@core/utils';
declare const SystemJS; declare const SystemJS;
@ -34,12 +36,14 @@ declare const SystemJS;
export class ResourcesService { export class ResourcesService {
private loadedResources: { [url: string]: ReplaySubject<any> } = {}; private loadedResources: { [url: string]: ReplaySubject<any> } = {};
private loadedModules: { [url: string]: ReplaySubject<ComponentFactory<any>[]> } = {}; private loadedModules: { [url: string]: ReplaySubject<Type<any>[]> } = {};
private loadedFactories: { [url: string]: ReplaySubject<ComponentFactory<any>[]> } = {};
private anchor = this.document.getElementsByTagName('head')[0] || this.document.getElementsByTagName('body')[0]; private anchor = this.document.getElementsByTagName('head')[0] || this.document.getElementsByTagName('body')[0];
constructor(@Inject(DOCUMENT) private readonly document: any, constructor(@Inject(DOCUMENT) private readonly document: any,
private compiler: Compiler, private compiler: Compiler,
private http: HttpClient,
private injector: Injector) {} private injector: Injector) {}
public loadResource(url: string): Observable<any> { public loadResource(url: string): Observable<any> {
@ -60,12 +64,12 @@ export class ResourcesService {
return this.loadResourceByType(fileType, url); return this.loadResourceByType(fileType, url);
} }
public loadModule(url: string, modulesMap: {[key: string]: any}): Observable<ComponentFactory<any>[]> { public loadFactories(url: string, modulesMap: {[key: string]: any}): Observable<ComponentFactory<any>[]> {
if (this.loadedModules[url]) { if (this.loadedFactories[url]) {
return this.loadedModules[url].asObservable(); return this.loadedFactories[url].asObservable();
} }
const subject = new ReplaySubject<ComponentFactory<any>[]>(); const subject = new ReplaySubject<ComponentFactory<any>[]>();
this.loadedModules[url] = subject; this.loadedFactories[url] = subject;
if (modulesMap) { if (modulesMap) {
for (const moduleId of Object.keys(modulesMap)) { for (const moduleId of Object.keys(modulesMap)) {
SystemJS.set(moduleId, modulesMap[moduleId]); SystemJS.set(moduleId, modulesMap[moduleId]);
@ -86,19 +90,70 @@ export class ResourcesService {
c.ngModuleFactory.create(this.injector); c.ngModuleFactory.create(this.injector);
componentFactories.push(...c.componentFactories); componentFactories.push(...c.componentFactories);
} }
this.loadedModules[url].next(componentFactories); this.loadedFactories[url].next(componentFactories);
this.loadedModules[url].complete(); this.loadedFactories[url].complete();
} catch (e) { } catch (e) {
this.loadedModules[url].error(new Error(`Unable to init module from url: ${url}`)); this.loadedFactories[url].error(new Error(`Unable to init module from url: ${url}`));
delete this.loadedModules[url]; delete this.loadedFactories[url];
} }
}, },
(e) => { (e) => {
this.loadedModules[url].error(new Error(`Unable to compile module from url: ${url}`)); this.loadedFactories[url].error(new Error(`Unable to compile module from url: ${url}`));
delete this.loadedModules[url]; delete this.loadedFactories[url];
}); });
} else { } else {
this.loadedModules[url].error(new Error(`Module '${url}' doesn't have default export!`)); this.loadedFactories[url].error(new Error(`Module '${url}' doesn't have default export!`));
delete this.loadedFactories[url];
}
},
(e) => {
this.loadedFactories[url].error(new Error(`Unable to load module from url: ${url}`));
delete this.loadedFactories[url];
}
);
return subject.asObservable();
}
public loadModules(url: string, modulesMap: {[key: string]: any}): Observable<Type<any>[]> {
if (this.loadedModules[url]) {
return this.loadedModules[url].asObservable();
}
const subject = new ReplaySubject<Type<any>[]>();
this.loadedModules[url] = subject;
if (modulesMap) {
for (const moduleId of Object.keys(modulesMap)) {
SystemJS.set(moduleId, modulesMap[moduleId]);
}
}
SystemJS.import(url).then(
(module) => {
let modules;
try {
modules = this.extractNgModules(module);
} catch (e) {}
if (modules && modules.length) {
const tasks: Promise<ModuleWithComponentFactories<any>>[] = [];
for (const m of modules) {
tasks.push(this.compiler.compileModuleAndAllComponentsAsync(m));
}
forkJoin(tasks).subscribe((compiled) => {
try {
for (const c of compiled) {
c.ngModuleFactory.create(this.injector);
}
this.loadedModules[url].next(modules);
this.loadedModules[url].complete();
} catch (e) {
this.loadedModules[url].error(new Error(`Unable to init module from url: ${url}`));
delete this.loadedModules[url];
}
},
(e) => {
this.loadedModules[url].error(new Error(`Unable to compile module from url: ${url}`));
delete this.loadedModules[url];
});
} else {
this.loadedModules[url].error(new Error(`Module '${url}' doesn't have default export or not NgModule!`));
delete this.loadedModules[url]; delete this.loadedModules[url];
} }
}, },

View File

@ -41,6 +41,43 @@ import { NULL_UUID } from '@shared/models/id/has-uuid';
import { WidgetTypeId } from '@app/shared/models/id/widget-type-id'; import { WidgetTypeId } from '@app/shared/models/id/widget-type-id';
import { TenantId } from '@app/shared/models/id/tenant-id'; import { TenantId } from '@app/shared/models/id/tenant-id';
import { SharedModule } from '@shared/shared.module'; import { SharedModule } from '@shared/shared.module';
import * as AngularCore from '@angular/core';
import * as AngularCommon from '@angular/common';
import * as AngularForms from '@angular/forms';
import * as AngularRouter from '@angular/router';
import * as AngularCdkKeycodes from '@angular/cdk/keycodes';
import * as AngularCdkCoercion from '@angular/cdk/coercion';
import * as AngularMaterialChips from '@angular/material/chips';
import * as AngularMaterialAutocomplete from '@angular/material/autocomplete';
import * as AngularMaterialDialog from '@angular/material/dialog';
import * as NgrxStore from '@ngrx/store';
import * as RxJs from 'rxjs';
import * as RxJsOperators from 'rxjs/operators';
import * as TranslateCore from '@ngx-translate/core';
import * as TbCore from '@core/public-api';
import * as TbShared from '@shared/public-api';
import * as _moment from 'moment';
declare const SystemJS;
const widgetResourcesModulesMap = {
'@angular/core': SystemJS.newModule(AngularCore),
'@angular/common': SystemJS.newModule(AngularCommon),
'@angular/forms': SystemJS.newModule(AngularForms),
'@angular/router': SystemJS.newModule(AngularRouter),
'@angular/cdk/keycodes': SystemJS.newModule(AngularCdkKeycodes),
'@angular/cdk/coercion': SystemJS.newModule(AngularCdkCoercion),
'@angular/material/chips': SystemJS.newModule(AngularMaterialChips),
'@angular/material/autocomplete': SystemJS.newModule(AngularMaterialAutocomplete),
'@angular/material/dialog': SystemJS.newModule(AngularMaterialDialog),
'@ngrx/store': SystemJS.newModule(NgrxStore),
rxjs: SystemJS.newModule(RxJs),
'rxjs/operators': SystemJS.newModule(RxJsOperators),
'@ngx-translate/core': SystemJS.newModule(TranslateCore),
'@core/public-api': SystemJS.newModule(TbCore),
'@shared/public-api': SystemJS.newModule(TbShared),
moment: SystemJS.newModule(_moment)
};
// @dynamic // @dynamic
@Injectable() @Injectable()
@ -105,8 +142,8 @@ export class WidgetComponentService {
const initSubject = new ReplaySubject(); const initSubject = new ReplaySubject();
this.init$ = initSubject.asObservable(); this.init$ = initSubject.asObservable();
const loadDefaultWidgetInfoTasks = [ const loadDefaultWidgetInfoTasks = [
this.loadWidgetResources(this.missingWidgetType, 'global-widget-missing-type', [SharedModule]), this.loadWidgetResources(this.missingWidgetType, 'global-widget-missing-type', [SharedModule, WidgetComponentsModule]),
this.loadWidgetResources(this.errorWidgetType, 'global-widget-error-type', [SharedModule]), this.loadWidgetResources(this.errorWidgetType, 'global-widget-error-type', [SharedModule, WidgetComponentsModule]),
]; ];
forkJoin(loadDefaultWidgetInfoTasks).subscribe( forkJoin(loadDefaultWidgetInfoTasks).subscribe(
() => { () => {
@ -218,31 +255,71 @@ export class WidgetComponentService {
this.cssParser.cssPreviewNamespace = widgetNamespace; this.cssParser.cssPreviewNamespace = widgetNamespace;
this.cssParser.createStyleElement(widgetNamespace, widgetInfo.templateCss); this.cssParser.createStyleElement(widgetNamespace, widgetInfo.templateCss);
const resourceTasks: Observable<string>[] = []; const resourceTasks: Observable<string>[] = [];
const modulesTasks: Observable<Type<any>[] | string>[] = [];
if (widgetInfo.resources.length > 0) { if (widgetInfo.resources.length > 0) {
widgetInfo.resources.forEach((resource) => { widgetInfo.resources.filter(r => r.isModule).forEach(
(resource) => {
modulesTasks.push(
this.resources.loadModules(resource.url, widgetResourcesModulesMap).pipe(
catchError((e: Error) => of(e?.message ? e.message : `Failed to load widget resource module: '${resource.url}'`))
)
);
}
);
}
widgetInfo.resources.filter(r => !r.isModule).forEach(
(resource) => {
resourceTasks.push( resourceTasks.push(
this.resources.loadResource(resource.url).pipe( this.resources.loadResource(resource.url).pipe(
catchError(e => of(`Failed to load widget resource: '${resource.url}'`)) catchError(e => of(`Failed to load widget resource: '${resource.url}'`))
) )
); );
}); }
} );
resourceTasks.push(
this.dynamicComponentFactoryService.createDynamicComponentFactory( let modulesObservable: Observable<string | Type<any>[]>;
class DynamicWidgetComponentInstance extends DynamicWidgetComponent {}, if (modulesTasks.length) {
widgetInfo.templateHtml, modulesObservable = forkJoin(modulesTasks).pipe(
modules map(res => {
).pipe( const msg = res.find(r => typeof r === 'string');
map((factory) => { if (msg) {
widgetInfo.componentFactory = factory; return msg as string;
return null; } else {
}), let resModules = (res as Type<any>[][]).flat();
catchError(e => { if (modules && modules.length) {
const details = this.utils.parseException(e); resModules = resModules.concat(modules);
const errorMessage = `Failed to compile widget html. \n Error: ${details.message}`; }
return of(errorMessage); return resModules;
}
}) })
) );
} else {
modulesObservable = modules && modules.length ? of(modules) : of([]);
}
resourceTasks.push(
modulesObservable.pipe(
mergeMap((resolvedModules) => {
if (typeof resolvedModules === 'string') {
return of(resolvedModules);
} else {
return this.dynamicComponentFactoryService.createDynamicComponentFactory(
class DynamicWidgetComponentInstance extends DynamicWidgetComponent {},
widgetInfo.templateHtml,
resolvedModules
).pipe(
map((factory) => {
widgetInfo.componentFactory = factory;
return null;
}),
catchError(e => {
const details = this.utils.parseException(e);
const errorMessage = `Failed to compile widget html. \n Error: ${details.message}`;
return of(errorMessage);
})
)
}
}))
); );
return forkJoin(resourceTasks).pipe( return forkJoin(resourceTasks).pipe(
switchMap(msgs => { switchMap(msgs => {

View File

@ -129,6 +129,10 @@
(ngModelChange)="isDirty = true" (ngModelChange)="isDirty = true"
placeholder="{{ 'widget.resource-url' | translate }}"/> placeholder="{{ 'widget.resource-url' | translate }}"/>
</mat-form-field> </mat-form-field>
<mat-checkbox [(ngModel)]="resource.isModule"
(ngModelChange)="isDirty = true">
{{ 'widget.resource-is-module' | translate }}
</mat-checkbox>
<button mat-icon-button color="primary" <button mat-icon-button color="primary"
[disabled]="isLoading$ | async" [disabled]="isLoading$ | async"
(click)="removeResource(i)" (click)="removeResource(i)"

View File

@ -53,6 +53,7 @@ export interface NodeScriptTestDialogData {
msgType?: string; msgType?: string;
} }
// @dynamic
@Component({ @Component({
selector: 'tb-node-script-test-dialog', selector: 'tb-node-script-test-dialog',
templateUrl: './node-script-test-dialog.component.html', templateUrl: './node-script-test-dialog.component.html',

View File

@ -114,6 +114,7 @@ export const widgetTypesData = new Map<widgetType, WidgetTypeData>(
export interface WidgetResource { export interface WidgetResource {
url: string; url: string;
isModule?: boolean;
} }
export interface WidgetActionSource { export interface WidgetActionSource {

View File

@ -1787,6 +1787,7 @@
"type": "Widget type", "type": "Widget type",
"resources": "Resources", "resources": "Resources",
"resource-url": "JavaScript/CSS URL", "resource-url": "JavaScript/CSS URL",
"resource-is-module": "Is module",
"remove-resource": "Remove resource", "remove-resource": "Remove resource",
"add-resource": "Add resource", "add-resource": "Add resource",
"html": "HTML", "html": "HTML",