diff --git a/e2e/src/advanced/pluginApi.e2e-spec.js b/e2e/src/advanced/pluginApi.e2e-spec.js new file mode 100644 index 0000000000000000000000000000000000000000..1a4d5f2ba89f7c83208d5db5a9a4df92a7ab91d0 --- /dev/null +++ b/e2e/src/advanced/pluginApi.e2e-spec.js @@ -0,0 +1,68 @@ +const { AtlasPage } = require('../util') +const template = 'ICBM 2009c Nonlinear Asymmetric' + +const pluginName = `fzj.xg.testWidget` +const pluginDisplayName = `Test Widget Title` + +const prepareWidget = ({ template = 'hello world', script = `console.log('hello world')` } = {}) => { + + return ` +const jsSrc = \`${script.replace(/\`/, '\\`')}\` +const blob = new Blob([jsSrc], { type: 'text/javascript' }) +window.interactiveViewer.uiHandle.launchNewWidget({ + name: '${pluginName}', + displayName: '${pluginDisplayName}', + template: \`${template.replace(/\`/, '\\`')}\`, + scriptURL: URL.createObjectURL(blob) +})` +} + +describe('> plugin api', () => { + let iavPage + + beforeEach(async () => { + iavPage = new AtlasPage() + await iavPage.init() + await iavPage.goto() + await iavPage.selectTitleCard(template) + await iavPage.wait(500) + await iavPage.waitUntilAllChunksLoaded() + }) + + describe('> interactiveViewer', () => { + describe('> uiHandle', () => { + describe('> launchNewWidget', () => { + it('should launch new widget', async () => { + + const prevTitle = await iavPage.execScript(() => window.document.title) + await iavPage.execScript(prepareWidget({ script: `window.document.title = 'hello world ' + window.document.title` })) + + await iavPage.wait(500) + + const isDisplayed = await iavPage.widgetPanelIsDispalyed(`Test Widget Title`) + expect(isDisplayed).toEqual(true) + + const newTitle = await iavPage.execScript(() => window.document.title) + expect(newTitle).toEqual(`hello world ${prevTitle}`) + }) + }) + }) + }) + + describe('> pluginControl', () => { + describe('> onShutdown', () => { + it('> works', async () => { + const newTitle = `testing pluginControl onShutdown` + const script = `window.interactiveViewer.pluginControl['${pluginName}'].onShutdown(() => window.document.title = '${newTitle}')` + await iavPage.execScript(prepareWidget({ script })) + await iavPage.wait(500) + const oldTitle = await iavPage.execScript(() => window.document.title) + await iavPage.closeWidgetByname(pluginDisplayName) + await iavPage.wait(500) + const actualNewTitle = await iavPage.execScript(() => window.document.title) + expect(oldTitle).not.toEqual(actualNewTitle) + expect(actualNewTitle).toEqual(newTitle) + }) + }) + }) +}) diff --git a/e2e/src/util.js b/e2e/src/util.js index e8f4ece07b8c205a2d1ef967e8d2ab22aa076d40..00f524b8efafe85c114276d31988a51e6dad168c 100644 --- a/e2e/src/util.js +++ b/e2e/src/util.js @@ -210,6 +210,11 @@ class WdBase{ ) .perform() } + + async execScript(fn, ...arg){ + const result = await this._driver.executeScript(fn) + return result + } } class WdLayoutPage extends WdBase{ @@ -423,6 +428,59 @@ class WdLayoutPage extends WdBase{ await this._getFavDatasetIcon().click() await this.wait(500) } + + _getPinnedDatasetPanel(){ + return this._driver + .findElement( + By.css('[aria-label="Pinned datasets panel"]') + ) + } + + async getPinnedDatasetsFromOpenedPanel(){ + const list = await this._getPinnedDatasetPanel() + .findElements( + By.tagName('mat-list-item') + ) + + const returnArr = [] + for (const el of list) { + const text = await _getTextFromWebElement(el) + returnArr.push(text) + } + return returnArr + } + + async unpinNthDatasetFromOpenedPanel(index){ + const list = await this._getPinnedDatasetPanel() + .findElements( + By.tagName('mat-list-item') + ) + + if (!list[index]) throw new Error(`index out of bound: ${index} in list with size ${list.length}`) + await list[index] + .findElement( By.css('[aria-label="Toggle pinning this dataset"]') ) + .click() + } + + _getWidgetPanel(title){ + return this._driver.findElement( By.css(`[aria-label="Widget for ${title}"]`) ) + } + + async widgetPanelIsDispalyed(title){ + try { + const isDisplayed = await this._getWidgetPanel(title).isDisplayed() + return isDisplayed + } catch (e) { + console.warn(`widgetPanelIsDisplayed error`, e) + return false + } + } + + async closeWidgetByname(title){ + await this._getWidgetPanel(title) + .findElement( By.css(`[aria-label="close"]`) ) + .click() + } } class WdIavPage extends WdLayoutPage{ diff --git a/src/atlasViewer/atlasViewer.apiService.service.ts b/src/atlasViewer/atlasViewer.apiService.service.ts index 6ee20f5ab6abdde3c7304fcda9486523992b6ce2..9be4720ba7d95a1f743d5fdc913c8bd33dffcc0c 100644 --- a/src/atlasViewer/atlasViewer.apiService.service.ts +++ b/src/atlasViewer/atlasViewer.apiService.service.ts @@ -1,9 +1,8 @@ import {Injectable, NgZone} from "@angular/core"; import { select, Store } from "@ngrx/store"; -import { Observable, Subscribable } from "rxjs"; +import { Observable } from "rxjs"; import { distinctUntilChanged, map, filter, startWith } from "rxjs/operators"; import { DialogService } from "src/services/dialogService.service"; -import { LoggingService } from "src/services/logging.service"; import { DISABLE_PLUGIN_REGION_SELECTION, getLabelIndexMap, @@ -13,8 +12,8 @@ import { } from "src/services/stateStore.service"; import { ModalHandler } from "../util/pluginHandlerClasses/modalHandler"; import { ToastHandler } from "../util/pluginHandlerClasses/toastHandler"; -import { IPluginManifest } from "./atlasViewer.pluginService.service"; -import {ENABLE_PLUGIN_REGION_SELECTION} from "src/services/state/uiState.store"; +import { IPluginManifest, PluginServices } from "./atlasViewer.pluginService.service"; +import { ENABLE_PLUGIN_REGION_SELECTION } from "src/services/state/uiState.store"; declare let window @@ -36,8 +35,8 @@ export class AtlasViewerAPIServices { constructor( private store: Store<IavRootStoreInterface>, private dialogService: DialogService, - private log: LoggingService, private zone: NgZone, + private pluginService: PluginServices, ) { this.loadedTemplates$ = this.store.pipe( @@ -125,9 +124,14 @@ export class AtlasViewerAPIServices { /** * to be overwritten by atlas */ - launchNewWidget: (_manifest) => { - return Promise.reject('Needs to be overwritted') - }, + launchNewWidget: (manifest) => this.pluginService.launchNewWidget(manifest) + .then(() => { + // trigger change detection in Angular + // otherwise, model won't be updated until user input + + /* eslint-disable-next-line @typescript-eslint/no-empty-function */ + this.zone.run(() => { }) + }), getUserInput: config => this.dialogService.getUserInput(config) , getUserConfirmation: config => this.dialogService.getUserConfirm(config), @@ -156,13 +160,14 @@ export class AtlasViewerAPIServices { } }, - pluginControl : { - loadExternalLibraries : () => Promise.reject('load External Library method not over written') - , - unloadExternalLibraries : () => { - this.log.warn('unloadExternalLibrary method not overwritten by atlasviewer') - }, - }, + pluginControl: new Proxy({}, { + get: (_, prop) => { + if (prop === 'loadExternalLibraries') return this.pluginService.loadExternalLibraries + if (prop === 'unloadExternalLibraries') return this.pluginService.unloadExternalLibraries + if (typeof prop === 'string') return this.pluginService.pluginHandlersMap.get(prop) + return undefined + } + }) as any, } window.interactiveViewer = this.interactiveViewer this.init() diff --git a/src/atlasViewer/atlasViewer.pluginService.service.ts b/src/atlasViewer/atlasViewer.pluginService.service.ts index 3c02c791f16e49ca05e918594a2f77249d20ed87..b8d5903db5bdd5c4ad353090560bc8c4101e446b 100644 --- a/src/atlasViewer/atlasViewer.pluginService.service.ts +++ b/src/atlasViewer/atlasViewer.pluginService.service.ts @@ -1,8 +1,7 @@ import { HttpClient } from '@angular/common/http' -import { ComponentFactory, ComponentFactoryResolver, Injectable, NgZone, ViewContainerRef } from "@angular/core"; +import { ComponentFactory, ComponentFactoryResolver, Injectable, ViewContainerRef } from "@angular/core"; import { PLUGINSTORE_ACTION_TYPES } from "src/services/state/pluginState.store"; import { IavRootStoreInterface, isDefined } from 'src/services/stateStore.service' -import { AtlasViewerAPIServices } from "./atlasViewer.apiService.service"; import { PluginUnit } from "./pluginUnit/pluginUnit.component"; import { WidgetServices } from "./widgetUnit/widgetService.service"; @@ -21,6 +20,11 @@ import { WidgetUnit } from "./widgetUnit/widgetUnit.component"; export class PluginServices { + public pluginHandlersMap: Map<string, PluginHandler> = new Map() + + public loadExternalLibraries: (libraries: string[]) => Promise<any> = () => Promise.reject(`fail to overwritten`) + public unloadExternalLibraries: (libraries: string[]) => void = () => { throw new Error(`failed to be overwritten`) } + public fetchedPluginManifests: IPluginManifest[] = [] public pluginViewContainerRef: ViewContainerRef public appendSrc: (script: HTMLElement) => void @@ -34,13 +38,11 @@ export class PluginServices { public fetch: (url: string, httpOption?: any) => Promise<any> = (url, httpOption = {}) => this.http.get(url, httpOption).toPromise() constructor( - private apiService: AtlasViewerAPIServices, private constantService: AtlasViewerConstantsServices, private widgetService: WidgetServices, private cfr: ComponentFactoryResolver, private store: Store<IavRootStoreInterface>, private http: HttpClient, - zone: NgZone, private log: LoggingService, ) { @@ -52,18 +54,6 @@ export class PluginServices { ) this.pluginUnitFactory = this.cfr.resolveComponentFactory( PluginUnit ) - this.apiService.interactiveViewer.uiHandle.launchNewWidget = (arg) => { - - return this.launchNewWidget(arg) - .then(arg2 => { - // trigger change detection in Angular - // otherwise, model won't be updated until user input - - /* eslint-disable-next-line @typescript-eslint/no-empty-function */ - zone.run(() => { }) - return arg2 - }) - } /** * TODO convert to rxjs streams, instead of Promise.all @@ -216,7 +206,7 @@ export class PluginServices { */ const handler = new PluginHandler() - this.apiService.interactiveViewer.pluginControl[plugin.name] = handler + this.pluginHandlersMap.set(plugin.name, handler) /** * define the handler properties prior to appending plugin script @@ -289,7 +279,7 @@ export class PluginServices { handler.onShutdown(() => { unsubscribeOnPluginDestroy.forEach(s => s.unsubscribe()) - delete this.apiService.interactiveViewer.pluginControl[plugin.name] + this.pluginHandlersMap.delete(plugin.name) this.mapPluginNameToWidgetUnit.delete(plugin.name) }) diff --git a/src/atlasViewer/widgetUnit/widgetService.service.ts b/src/atlasViewer/widgetUnit/widgetService.service.ts index b784a431b7753af0ce54ddfc0bc30bf9a1844136..6a9ff82d54ebc76424bed0534190e62137d39d6c 100644 --- a/src/atlasViewer/widgetUnit/widgetService.service.ts +++ b/src/atlasViewer/widgetUnit/widgetService.service.ts @@ -92,8 +92,13 @@ export class WidgetServices implements OnDestroy { if (component.constructor === Error) { throw component } else { - const _component = (component as ComponentRef<WidgetUnit>); + const _component = (component as ComponentRef<WidgetUnit>) + + // guestComponentRef + // insert view _component.instance.container.insert( guestComponentRef.hostView ) + // on host destroy, destroy guest + _component.onDestroy(() => guestComponentRef.destroy()) /* programmatic DI */ _component.instance.widgetServices = this diff --git a/src/atlasViewer/widgetUnit/widgetUnit.template.html b/src/atlasViewer/widgetUnit/widgetUnit.template.html index de35f7f2a2ee8c0c57dcb50db74ce0521dfdce62..73a764fbc3d39199064a127e2b5b29c3ad73f906 100644 --- a/src/atlasViewer/widgetUnit/widgetUnit.template.html +++ b/src/atlasViewer/widgetUnit/widgetUnit.template.html @@ -1,4 +1,5 @@ <panel-component + [attr.aria-label]="'Widget for ' + title" widgetUnitPanel [ngClass]="{'blinkOn': blinkOn}" [bodyCollapsable] = "state === 'docked'" @@ -37,6 +38,7 @@ </ng-container> <i *ngIf="exitable" + aria-label="close" (click)="exit($event)" class="fas fa-times" [hoverable] ="hoverableConfig"></i> diff --git a/src/ui/databrowserModule/databrowser.useEffect.ts b/src/ui/databrowserModule/databrowser.useEffect.ts index b30394864e479998842c00434ed2157320ec9f44..00e9e3a347a1ce9398659e3f7bda214f9c873586 100644 --- a/src/ui/databrowserModule/databrowser.useEffect.ts +++ b/src/ui/databrowserModule/databrowser.useEffect.ts @@ -75,7 +75,7 @@ export class DataBrowserUseEffect implements OnDestroy { ).subscribe(({ datasetId, filename }) => { // TODO replace with common/util/getIdFromFullId - + // TODO replace with widgetService.open const re = getKgSchemaIdFromFullId(datasetId) this.dialog.open( PreviewComponentWrapper, diff --git a/src/util/directives/pluginFactory.directive.ts b/src/util/directives/pluginFactory.directive.ts index eb40319fb85ad6028ddfa681540ff07b4813efef..5ca5401d638cd8f9a46f8eb40fab485c9cc3ef4b 100644 --- a/src/util/directives/pluginFactory.directive.ts +++ b/src/util/directives/pluginFactory.directive.ts @@ -1,5 +1,4 @@ import { Directive, Renderer2, ViewContainerRef } from "@angular/core"; -import { AtlasViewerAPIServices } from "src/atlasViewer/atlasViewer.apiService.service"; import { SUPPORT_LIBRARY_MAP } from "src/atlasViewer/atlasViewer.constantService.service"; import { PluginServices } from "src/atlasViewer/atlasViewer.pluginService.service"; import { LoggingService } from "src/services/logging.service"; @@ -12,68 +11,70 @@ export class PluginFactoryDirective { constructor( pluginService: PluginServices, viewContainerRef: ViewContainerRef, - rd2: Renderer2, - apiService: AtlasViewerAPIServices, + private rd2: Renderer2, private log: LoggingService, ) { + pluginService.loadExternalLibraries = this.loadExternalLibraries.bind(this) + pluginService.unloadExternalLibraries = this.unloadExternalLibraries.bind(this) pluginService.pluginViewContainerRef = viewContainerRef pluginService.appendSrc = (src: HTMLElement) => rd2.appendChild(document.head, src) pluginService.removeSrc = (src: HTMLElement) => rd2.removeChild(document.head, src) + } + + private loadedLibraries: Map<string, {counter: number, src: HTMLElement|null}> = new Map() + + loadExternalLibraries(libraries: string[]) { + const srcHTMLElement = libraries.map(libraryName => ({ + name: libraryName, + srcEl: SUPPORT_LIBRARY_MAP.get(libraryName), + })) - apiService.interactiveViewer.pluginControl.loadExternalLibraries = (libraries: string[]) => new Promise((resolve, reject) => { - const srcHTMLElement = libraries.map(libraryName => ({ - name: libraryName, - srcEl: SUPPORT_LIBRARY_MAP.get(libraryName), - })) + const rejected = srcHTMLElement.filter(scriptObj => scriptObj.srcEl === null) + if (rejected.length > 0) { + return Promise.reject(`Some library names cannot be recognised. No libraries were loaded: ${rejected.map(srcObj => srcObj.name).join(', ')}`) + } - const rejected = srcHTMLElement.filter(scriptObj => scriptObj.srcEl === null) - if (rejected.length > 0) { - return reject(`Some library names cannot be recognised. No libraries were loaded: ${rejected.map(srcObj => srcObj.name).join(', ')}`) + return Promise.all(srcHTMLElement.map(scriptObj => new Promise((rs, rj) => { + /** + * if browser already support customElements, do not append polyfill + */ + if ('customElements' in window && scriptObj.name === 'webcomponentsLite') { + return rs() } + const existingEntry = this.loadedLibraries.get(scriptObj.name) + if (existingEntry) { + this.loadedLibraries.set(scriptObj.name, { counter: existingEntry.counter + 1, src: existingEntry.src }) + rs() + } else { + const srcEl = scriptObj.srcEl + srcEl.onload = () => rs() + srcEl.onerror = (e: any) => rj(e) + this.rd2.appendChild(document.head, srcEl) + this.loadedLibraries.set(scriptObj.name, { counter: 1, src: srcEl }) + } + }))) + } - Promise.all(srcHTMLElement.map(scriptObj => new Promise((rs, rj) => { - /** - * if browser already support customElements, do not append polyfill - */ - if ('customElements' in window && scriptObj.name === 'webcomponentsLite') { - return rs() + unloadExternalLibraries(libraries: string[]) { + libraries + .filter((stringname) => SUPPORT_LIBRARY_MAP.get(stringname) !== null) + .forEach(libname => { + const ledger = this.loadedLibraries.get(libname) + if (!ledger) { + this.log.warn('unload external libraries error. cannot find ledger entry...', libname, this.loadedLibraries) + return } - const existingEntry = apiService.loadedLibraries.get(scriptObj.name) - if (existingEntry) { - apiService.loadedLibraries.set(scriptObj.name, { counter: existingEntry.counter + 1, src: existingEntry.src }) - rs() - } else { - const srcEl = scriptObj.srcEl - srcEl.onload = () => rs() - srcEl.onerror = (e: any) => rj(e) - rd2.appendChild(document.head, srcEl) - apiService.loadedLibraries.set(scriptObj.name, { counter: 1, src: srcEl }) + if (ledger.src === null) { + this.log.log('webcomponents is native supported. no library needs to be unloaded') + return } - }))) - .then(() => resolve()) - .catch(e => (this.log.warn(e), reject(e))) - }) - - apiService.interactiveViewer.pluginControl.unloadExternalLibraries = (libraries: string[]) => - libraries - .filter((stringname) => SUPPORT_LIBRARY_MAP.get(stringname) !== null) - .forEach(libname => { - const ledger = apiService.loadedLibraries.get(libname) - if (!ledger) { - this.log.warn('unload external libraries error. cannot find ledger entry...', libname, apiService.loadedLibraries) - return - } - if (ledger.src === null) { - this.log.log('webcomponents is native supported. no library needs to be unloaded') - return - } - if (ledger.counter - 1 == 0) { - rd2.removeChild(document.head, ledger.src) - apiService.loadedLibraries.delete(libname) - } else { - apiService.loadedLibraries.set(libname, { counter: ledger.counter - 1, src: ledger.src }) - } - }) + if (ledger.counter - 1 == 0) { + this.rd2.removeChild(document.head, ledger.src) + this.loadedLibraries.delete(libname) + } else { + this.loadedLibraries.set(libname, { counter: ledger.counter - 1, src: ledger.src }) + } + }) } }