diff --git a/deploy/plugins/index.js b/deploy/plugins/index.js index 9f15b4687a85be1f07b76284694132fd0fb799eb..f0216dea9b1aa14f7a85dfc955a11b530e9f740e 100644 --- a/deploy/plugins/index.js +++ b/deploy/plugins/index.js @@ -5,15 +5,11 @@ const express = require('express') const router = express.Router() -const PLUGIN_URLS = process.env.PLUGIN_URLS && JSON.stringify(process.env.PLUGIN_URLS.split(';')) +const PLUGIN_URLS = (process.env.PLUGIN_URLS && process.env.PLUGIN_URLS.split(';')) + || [] router.get('', (_req, res) => { - - if (PLUGIN_URLS) { - return res.status(200).send(PLUGIN_URLS) - } else { - return res.status(200).send('[]') - } + return res.status(200).json(PLUGIN_URLS) }) module.exports = router \ No newline at end of file diff --git a/src/atlasViewer/atlasViewer.pluginService.service.spec.ts b/src/atlasViewer/atlasViewer.pluginService.service.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..51e3fb8f8f6cd33c05c06867a4c2507ec3c829bf --- /dev/null +++ b/src/atlasViewer/atlasViewer.pluginService.service.spec.ts @@ -0,0 +1,108 @@ +import { PluginServices } from "./atlasViewer.pluginService.service"; +import { TestBed, inject } from "@angular/core/testing"; +import { MainModule } from "src/main.module"; +import { HttpTestingController, HttpClientTestingModule } from '@angular/common/http/testing' + +const MOCK_PLUGIN_MANIFEST = { + name: 'fzj.xg.MOCK_PLUGIN_MANIFEST', + templateURL: 'http://localhost:10001/template.html', + scriptURL: 'http://localhost:10001/script.js' +} + +describe('PluginServices', () => { + let pluginService: PluginServices + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + HttpClientTestingModule, + MainModule + ] + }).compileComponents() + + pluginService = TestBed.get(PluginServices) + }) + + it( + 'is instantiated in test suite OK', + () => expect(TestBed.get(PluginServices)).toBeTruthy() + ) + + it( + 'expectOne is working as expected', + inject([HttpTestingController], (httpMock: HttpTestingController) => { + expect(httpMock.match('test').length).toBe(0) + pluginService.fetch('test') + expect(httpMock.match('test').length).toBe(1) + pluginService.fetch('test') + pluginService.fetch('test') + expect(httpMock.match('test').length).toBe(2) + }) + ) + + describe('#launchPlugin', () => { + + describe('basic fetching functionality', () => { + it( + 'fetches templateURL and scriptURL properly', + inject([HttpTestingController], (httpMock: HttpTestingController) => { + + pluginService.launchPlugin(MOCK_PLUGIN_MANIFEST) + + const mockTemplate = httpMock.expectOne(MOCK_PLUGIN_MANIFEST.templateURL) + const mockScript = httpMock.expectOne(MOCK_PLUGIN_MANIFEST.scriptURL) + + expect(mockTemplate).toBeTruthy() + expect(mockScript).toBeTruthy() + }) + ) + + it( + 'template overrides templateURL', + inject([HttpTestingController], (httpMock: HttpTestingController) => { + pluginService.launchPlugin({ + ...MOCK_PLUGIN_MANIFEST, + template: '' + }) + + httpMock.expectNone(MOCK_PLUGIN_MANIFEST.templateURL) + const mockScript = httpMock.expectOne(MOCK_PLUGIN_MANIFEST.scriptURL) + + expect(mockScript).toBeTruthy() + }) + ) + + it( + 'script overrides scriptURL', + + inject([HttpTestingController], (httpMock: HttpTestingController) => { + pluginService.launchPlugin({ + ...MOCK_PLUGIN_MANIFEST, + script: '' + }) + + const mockTemplate = httpMock.expectOne(MOCK_PLUGIN_MANIFEST.templateURL) + httpMock.expectNone(MOCK_PLUGIN_MANIFEST.scriptURL) + + expect(mockTemplate).toBeTruthy() + }) + ) + }) + + describe('racing slow cconnection when launching plugin', () => { + it( + 'when template/script has yet been fetched, repeated launchPlugin should not result in repeated fetching', + inject([HttpTestingController], (httpMock:HttpTestingController) => { + + expect(pluginService.pluginLaunching.has(MOCK_PLUGIN_MANIFEST.name)).toBeFalsy() + pluginService.launchPlugin(MOCK_PLUGIN_MANIFEST) + pluginService.launchPlugin(MOCK_PLUGIN_MANIFEST) + expect(httpMock.match(MOCK_PLUGIN_MANIFEST.scriptURL).length).toBe(1) + expect(httpMock.match(MOCK_PLUGIN_MANIFEST.templateURL).length).toBe(1) + + expect(pluginService.pluginLaunching.has(MOCK_PLUGIN_MANIFEST.name)).toBeTruthy() + }) + ) + }) + }) +}) \ No newline at end of file diff --git a/src/atlasViewer/atlasViewer.pluginService.service.ts b/src/atlasViewer/atlasViewer.pluginService.service.ts index 9d9ac412464dae44465963da913932e71cc608db..723adfea2da1145acbf817b0aa2593414cf772a0 100644 --- a/src/atlasViewer/atlasViewer.pluginService.service.ts +++ b/src/atlasViewer/atlasViewer.pluginService.service.ts @@ -1,4 +1,5 @@ import { Injectable, ViewContainerRef, ComponentFactoryResolver, ComponentFactory } from "@angular/core"; +import { HttpClient } from '@angular/common/http' import { PluginInitManifestInterface, ACTION_TYPES } from "src/services/state/pluginState.store"; import { isDefined } from 'src/services/stateStore.service' import { AtlasViewerAPIServices } from "./atlasViewer.apiService.service"; @@ -23,13 +24,20 @@ export class PluginServices{ public appendSrc : (script:HTMLElement)=>void public removeSrc: (script:HTMLElement) => void private pluginUnitFactory : ComponentFactory<PluginUnit> + public minimisedPlugins$ : Observable<Set<string>> + + /** + * TODO remove polyfil and convert all calls to this.fetch to http client + */ + public fetch: (url:string) => Promise<any> = (url) => this.http.get(url).toPromise() constructor( private apiService : AtlasViewerAPIServices, private constantService : AtlasViewerConstantsServices, private widgetService : WidgetServices, private cfr : ComponentFactoryResolver, - private store : Store<PluginInitManifestInterface> + private store : Store<PluginInitManifestInterface>, + private http: HttpClient ){ this.pluginUnitFactory = this.cfr.resolveComponentFactory( PluginUnit ) @@ -44,17 +52,17 @@ export class PluginServices{ * PLUGINDEV should return an array of */ PLUGINDEV - ? fetch(PLUGINDEV).then(res => res.json()) + ? this.fetch(PLUGINDEV).then(res => res.json()) : Promise.resolve([]), new Promise(resolve => { - fetch(`${this.constantService.backendUrl}plugins`) + this.fetch(`${this.constantService.backendUrl}plugins`) .then(res => res.json()) .then(arr => Promise.all( arr.map(url => new Promise(rs => /** * instead of failing all promises when fetching manifests, only fail those that fails to fetch */ - fetch(url).then(res => res.json()).then(rs).catch(e => (console.log('fetching manifest error', e), rs(null)))) + this.fetch(url).then(res => res.json()).then(rs).catch(e => (console.log('fetching manifest error', e), rs(null)))) ) )) .then(manifests => resolve( @@ -67,7 +75,7 @@ export class PluginServices{ Promise.all( BUNDLEDPLUGINS .filter(v => typeof v === 'string') - .map(v => fetch(`res/plugin_examples/${v}/manifest.json`).then(res => res.json())) + .map(v => this.fetch(`res/plugin_examples/${v}/manifest.json`).then(res => res.json())) ) .then(arr => arr.reduce((acc,curr) => acc.concat(curr) ,[])) ]) @@ -112,14 +120,14 @@ export class PluginServices{ isDefined(plugin.template) ? Promise.resolve('template already provided') : isDefined(plugin.templateURL) ? - fetch(plugin.templateURL) + this.fetch(plugin.templateURL) .then(res=>res.text()) .then(template=>plugin.template = template) : Promise.reject('both template and templateURL are not defined') , isDefined(plugin.script) ? Promise.resolve('script already provided') : isDefined(plugin.scriptURL) ? - fetch(plugin.scriptURL) + this.fetch(plugin.scriptURL) .then(res=>res.text()) .then(script=>plugin.script = script) : Promise.reject('both script and scriptURL are not defined') @@ -135,32 +143,53 @@ export class PluginServices{ this.launchedPlugins.add(pluginName) this.launchedPlugins$.next(this.launchedPlugins) } - removePluginToLaunchedSet(pluginName:string){ + removePluginFromLaunchedSet(pluginName:string){ this.launchedPlugins.delete(pluginName) this.launchedPlugins$.next(this.launchedPlugins) } + + pluginIsLaunching(pluginName:string){ + return this.launchingPlugins.has(pluginName) + } + addPluginToIsLaunchingSet(pluginName:string) { + this.launchingPlugins.add(pluginName) + } + removePluginFromIsLaunchingSet(pluginName:string){ + this.launchedPlugins.delete(pluginName) + } + private mapPluginNameToWidgetUnit: Map<string, WidgetUnit> = new Map() pluginIsMinimised(pluginName:string) { return this.widgetService.isMinimised( this.mapPluginNameToWidgetUnit.get(pluginName) ) } - public minimisedPlugins$: Observable<Set<string>> - + private launchingPlugins: Set<string> = new Set() public orphanPlugins: Set<PluginManifest> = new Set() launchPlugin(plugin:PluginManifest){ - if(this.apiService.interactiveViewer.pluginControl[plugin.name]){ - // if already launched, toggle minimise/restore + if (this.pluginIsLaunching(plugin.name)) { + // plugin launching please be patient + // TODO add visual feedback + return + } + if ( this.pluginHasLaunched(plugin.name)) { + // plugin launched + // TODO add visual feedback + // if widget window is minimized, maximize it + const wu = this.mapPluginNameToWidgetUnit.get(plugin.name) if (this.widgetService.isMinimised(wu)) { this.widgetService.unminimise(wu) } else { this.widgetService.minimise(wu) } - return Promise.reject('plugin already launched') + return } + + this.addPluginToIsLaunchingSet(plugin.name) + return this.readyPlugin(plugin) .then(()=>{ const pluginUnit = this.pluginViewContainerRef.createComponent( this.pluginUnitFactory ) @@ -199,7 +228,7 @@ export class PluginServices{ const shutdownCB = [ () => { - this.removePluginToLaunchedSet(plugin.name) + this.removePluginFromLaunchedSet(plugin.name) } ] @@ -228,6 +257,8 @@ export class PluginServices{ }) this.addPluginToLaunchedSet(plugin.name) + this.removePluginFromIsLaunchingSet(plugin.name) + this.mapPluginNameToWidgetUnit.set(plugin.name, widgetCompRef.instance) const unsubscribeOnPluginDestroy = [] diff --git a/src/ui/ui.module.ts b/src/ui/ui.module.ts index 694000b8e0295bf7d27ba038cb189821b2cabd17..7e69c597b1dfb9e7828b9fcb9dd82d711a090757 100644 --- a/src/ui/ui.module.ts +++ b/src/ui/ui.module.ts @@ -51,9 +51,12 @@ import { AppendtooltipTextPipe } from "src/util/pipes/appendTooltipText.pipe"; import { MenuIconPluginBtnClsPipe } from "src/util/pipes/menuIconPluginBtnCls.pipe"; import { MenuIconKgSearchBtnClsPipe } from "src/util/pipes/menuIconKgSearchBtnCls.pipe"; import { ScrollingModule } from "@angular/cdk/scrolling" +import { HttpClientModule } from "@angular/common/http"; + @NgModule({ imports : [ + HttpClientModule, FormsModule, LayoutModule, ComponentsModule,