From bc23e65e835ba48bd71a762823b2cab0621a588f Mon Sep 17 00:00:00 2001 From: danielwiehl Date: Mon, 18 Jan 2021 17:50:59 +0100 Subject: [PATCH] feat(workbench-client/popup): allow providing a microfrontend for display in a workbench popup A micro application can provide a microfrontend for display in a workbench popup. Popup microfrontends are contributed in the form of popup capabilities. A popup is a visual workbench component for displaying content above other content. Unlike views, popups are not part of the persistent workbench navigation, meaning that popups do not survive a page reload. --- .github/workflows/workflow.yml | 8 + .../src/app/activator/activator.module.ts | 35 ++ .../src/app/app-routing.module.ts | 8 + .../popup-opener-page.component.html | 76 +++ .../popup-opener-page.component.scss | 38 ++ .../popup-opener-page.component.ts | 133 ++++ .../popup-opener-page.module.ts | 38 ++ .../app/popup-page/popup-page.component.html | 113 ++++ .../app/popup-page/popup-page.component.scss | 86 +++ .../app/popup-page/popup-page.component.ts | 73 +++ .../src/app/popup-page/popup-page.module.ts | 45 ++ ...r-workbench-capability-page.component.html | 37 ++ ...ter-workbench-capability-page.component.ts | 56 +- ...er-workbench-intention-page.component.html | 1 + .../src/app/util/util.ts | 22 + .../workbench-microfrontend-support.ts | 4 +- projects/scion/e2e-testing/protractor.conf.js | 12 + projects/scion/e2e-testing/src/app.po.ts | 14 +- .../page-object/popup-opener-page.po.ts | 218 +++++++ .../page-object/popup-page.po.ts | 189 ++++++ .../register-workbench-capability-page.po.ts | 31 +- .../register-workbench-intention-page.po.ts | 4 +- .../page-object/router-outlet.po.ts | 15 +- .../workbench-client/popup-params.e2e-spec.ts | 132 ++++ .../workbench-client/popup-router.e2e-spec.ts | 372 +++++++++++ .../workbench-client/popup-size.e2e-spec.ts | 442 +++++++++++++ .../src/workbench-client/popup.e2e-spec.ts | 589 ++++++++++++++++++ .../page-object/popup-opener-page.po.ts | 1 - .../lib/popup/workbench-popup-capability.ts | 109 ++++ .../src/lib/popup/workbench-popup-context.ts | 38 ++ .../lib/popup/workbench-popup-initializer.ts | 29 + .../lib/popup/workbench-popup-open-command.ts | 27 + .../src/lib/popup/workbench-popup-service.ts | 147 +++++ .../src/lib/popup/workbench-popup.config.ts | 76 +++ .../src/lib/popup/workbench-popup.ts | 152 +++++ .../src/lib/workbench-capabilities.enum.ts | 6 +- .../src/lib/workbench-client.ts | 4 + .../src/lib/\311\265workbench-commands.ts" | 19 + .../scion/workbench-client/src/public_api.ts | 9 +- .../tsconfig.lib.prod.typedoc.json | 6 +- ...rofrontend-platform-initializer.service.ts | 11 +- ...ofrontend-popup-command-handler.service.ts | 149 +++++ .../microfrontend-popup.component.html | 6 + .../microfrontend-popup.component.scss | 3 + .../microfrontend-popup.component.ts | 128 ++++ .../workbench-microfrontend-support.ts | 10 +- .../workbench/src/lib/workbench.module.ts | 2 + .../workbench/src/theme/_popup-theme.scss | 7 +- 48 files changed, 3706 insertions(+), 24 deletions(-) create mode 100644 apps/workbench-client-testing-app/src/app/popup-opener-page/popup-opener-page.component.html create mode 100644 apps/workbench-client-testing-app/src/app/popup-opener-page/popup-opener-page.component.scss create mode 100644 apps/workbench-client-testing-app/src/app/popup-opener-page/popup-opener-page.component.ts create mode 100644 apps/workbench-client-testing-app/src/app/popup-opener-page/popup-opener-page.module.ts create mode 100644 apps/workbench-client-testing-app/src/app/popup-page/popup-page.component.html create mode 100644 apps/workbench-client-testing-app/src/app/popup-page/popup-page.component.scss create mode 100644 apps/workbench-client-testing-app/src/app/popup-page/popup-page.component.ts create mode 100644 apps/workbench-client-testing-app/src/app/popup-page/popup-page.module.ts create mode 100644 apps/workbench-client-testing-app/src/app/util/util.ts create mode 100644 projects/scion/e2e-testing/src/workbench-client/page-object/popup-opener-page.po.ts create mode 100644 projects/scion/e2e-testing/src/workbench-client/page-object/popup-page.po.ts create mode 100644 projects/scion/e2e-testing/src/workbench-client/popup-params.e2e-spec.ts create mode 100644 projects/scion/e2e-testing/src/workbench-client/popup-router.e2e-spec.ts create mode 100644 projects/scion/e2e-testing/src/workbench-client/popup-size.e2e-spec.ts create mode 100644 projects/scion/e2e-testing/src/workbench-client/popup.e2e-spec.ts create mode 100644 projects/scion/workbench-client/src/lib/popup/workbench-popup-capability.ts create mode 100644 projects/scion/workbench-client/src/lib/popup/workbench-popup-context.ts create mode 100644 projects/scion/workbench-client/src/lib/popup/workbench-popup-initializer.ts create mode 100644 projects/scion/workbench-client/src/lib/popup/workbench-popup-open-command.ts create mode 100644 projects/scion/workbench-client/src/lib/popup/workbench-popup-service.ts create mode 100644 projects/scion/workbench-client/src/lib/popup/workbench-popup.config.ts create mode 100644 projects/scion/workbench-client/src/lib/popup/workbench-popup.ts create mode 100644 projects/scion/workbench/src/lib/microfrontend-platform/microfrontend-popup/microfrontend-popup-command-handler.service.ts create mode 100644 projects/scion/workbench/src/lib/microfrontend-platform/microfrontend-popup/microfrontend-popup.component.html create mode 100644 projects/scion/workbench/src/lib/microfrontend-platform/microfrontend-popup/microfrontend-popup.component.scss create mode 100644 projects/scion/workbench/src/lib/microfrontend-platform/microfrontend-popup/microfrontend-popup.component.ts diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index ea6d8a8c8..8dcc9acaa 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -141,6 +141,14 @@ jobs: project: workbench-client - name: view project: workbench-client + - name: popup + project: workbench-client + - name: popup-params + project: workbench-client + - name: popup-router + project: workbench-client + - name: popup-size + project: workbench-client steps: - uses: actions/checkout@v2 - name: 'Downloading dist/workbench and dist/workbench-client' diff --git a/apps/workbench-client-testing-app/src/app/activator/activator.module.ts b/apps/workbench-client-testing-app/src/app/activator/activator.module.ts index 1e52af8fb..23039f091 100644 --- a/apps/workbench-client-testing-app/src/app/activator/activator.module.ts +++ b/apps/workbench-client-testing-app/src/app/activator/activator.module.ts @@ -128,5 +128,40 @@ export class ActivatorModule { }, }, ); + + // Register view to open a workbench popup. + await this._manifestService.registerCapability( + { + type: WorkbenchCapabilities.View, + qualifier: { + component: 'popup', + app, + }, + description: '[e2e] Allows opening a microfrontend in a workbench popup', + private: false, + properties: { + path: 'test-popup', + pinToStartPage: true, + title: 'Workbench Popup', + heading, + cssClass: 'e2e-test-popup', + }, + }); + + // Register the popup microfrontend. + await this._manifestService.registerCapability( + { + type: WorkbenchCapabilities.Popup, + qualifier: { + component: 'popup', + app, + }, + description: '[e2e] Provides access to the workbench popup object', + private: false, + properties: { + path: 'popup', + cssClass: 'e2e-test-popup', + }, + }); } } diff --git a/apps/workbench-client-testing-app/src/app/app-routing.module.ts b/apps/workbench-client-testing-app/src/app/app-routing.module.ts index c3fded52d..e7cd436f6 100644 --- a/apps/workbench-client-testing-app/src/app/app-routing.module.ts +++ b/apps/workbench-client-testing-app/src/app/app-routing.module.ts @@ -24,6 +24,14 @@ const routes: Routes = [ path: 'test-view', loadChildren: (): any => import('./view-page/view-page.module').then(m => m.ViewPageModule), }, + { + path: 'test-popup', + loadChildren: (): any => import('./popup-opener-page/popup-opener-page.module').then(m => m.PopupOpenerPageModule), + }, + { + path: 'popup', + loadChildren: (): any => import('./popup-page/popup-page.module').then(m => m.PopupPageModule), + }, { path: 'register-workbench-capability', loadChildren: (): any => import('./register-workbench-capability-page/register-workbench-capability-page.module').then(m => m.RegisterWorkbenchCapabilityPageModule), diff --git a/apps/workbench-client-testing-app/src/app/popup-opener-page/popup-opener-page.component.html b/apps/workbench-client-testing-app/src/app/popup-opener-page/popup-opener-page.component.html new file mode 100644 index 000000000..48d23b474 --- /dev/null +++ b/apps/workbench-client-testing-app/src/app/popup-opener-page/popup-opener-page.component.html @@ -0,0 +1,76 @@ + +
+ + + + + + + + + + + +
+ + + +
Anchor
+
+ + + + + + + + + + + + + + + + + + + +
+ + + +
Close Strategy
+
+ + + + + + + + + +
+
+ + + + + {{returnValue}} + + + + {{popupError}} + diff --git a/apps/workbench-client-testing-app/src/app/popup-opener-page/popup-opener-page.component.scss b/apps/workbench-client-testing-app/src/app/popup-opener-page/popup-opener-page.component.scss new file mode 100644 index 000000000..9190ff880 --- /dev/null +++ b/apps/workbench-client-testing-app/src/app/popup-opener-page/popup-opener-page.component.scss @@ -0,0 +1,38 @@ +@use 'sci-toolkit-styles' as sci-toolkit-styles; + +:host { + display: grid; + grid-auto-rows: max-content; + row-gap: 1em; + padding: 1em; + + > section { + display: grid; + grid-row-gap: .5em; + border: 1px solid var(--sci-color-P400); + border-radius: 5px; + padding: 1em; + } + + > sci-accordion header { + font-weight: bold; + } + + > button.open { + justify-self: center; + } + + > output.return-value { + border: 1px solid var(--sci-color-accent); + background-color: var(--sci-color-A100); + border-radius: 3px; + padding: 1em; + } + + > output.popup-error { + border: 1px solid var(--sci-color-warn); + background-color: var(--sci-color-W100); + border-radius: 3px; + padding: 1em; + } +} diff --git a/apps/workbench-client-testing-app/src/app/popup-opener-page/popup-opener-page.component.ts b/apps/workbench-client-testing-app/src/app/popup-opener-page/popup-opener-page.component.ts new file mode 100644 index 000000000..fe3300782 --- /dev/null +++ b/apps/workbench-client-testing-app/src/app/popup-opener-page/popup-opener-page.component.ts @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2018-2019 Swiss Federal Railways + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import { AfterViewInit, Component, ElementRef, ViewChild } from '@angular/core'; +import { FormArray, FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms'; +import { CloseStrategy, WorkbenchPopupService } from '@scion/workbench-client'; +import { SciParamsEnterComponent } from '@scion/toolkit.internal/widgets'; +import { undefinedIfEmpty } from '../util/util'; +import { defer, Observable } from 'rxjs'; +import { PopupOrigin } from '@scion/workbench'; +import { map, startWith } from 'rxjs/operators'; + +const QUALIFIER = 'qualifier'; +const PARAMS = 'params'; +const ANCHOR = 'anchor'; +const BINDING = 'binding'; +const ALIGN = 'align'; +const CLOSE_STRATEGY = 'closeStrategy'; +const ON_FOCUS_LOST = 'onFocusLost'; +const ON_ESCAPE = 'onEscape'; +const X = 'x'; +const Y = 'y'; +const HEIGHT = 'height'; +const WIDTH = 'width'; + +@Component({ + selector: 'app-popup-opener-page', + templateUrl: './popup-opener-page.component.html', + styleUrls: ['./popup-opener-page.component.scss'], +}) +export class PopupOpenerPageComponent implements AfterViewInit { + + public readonly QUALIFIER = QUALIFIER; + public readonly PARAMS = PARAMS; + public readonly ANCHOR = ANCHOR; + public readonly BINDING = BINDING; + public readonly ALIGN = ALIGN; + public readonly CLOSE_STRATEGY = CLOSE_STRATEGY; + public readonly ON_FOCUS_LOST = ON_FOCUS_LOST; + public readonly ON_ESCAPE = ON_ESCAPE; + public readonly HEIGHT = HEIGHT; + public readonly WIDTH = WIDTH; + public readonly X = X; + public readonly Y = Y; + + private _coordinateAnchor$: Observable; + + public form: FormGroup; + + public popupError: string; + public returnValue: string; + + @ViewChild('open_button', {static: true}) + private _openButton: ElementRef; + + constructor(private _host: ElementRef, + private _popupService: WorkbenchPopupService, + formBuilder: FormBuilder) { + this.form = formBuilder.group({ + [QUALIFIER]: formBuilder.array([ + new FormGroup({ + paramName: new FormControl('component'), + paramValue: new FormControl('popup'), + }), + new FormGroup({ + paramName: new FormControl('app'), + paramValue: new FormControl('app1'), + }, + )], Validators.required), + [PARAMS]: formBuilder.array([]), + [ANCHOR]: formBuilder.group({ + [BINDING]: formBuilder.control('element', Validators.required), + [X]: formBuilder.control('0'), + [Y]: formBuilder.control('0'), + [WIDTH]: formBuilder.control('0'), + [HEIGHT]: formBuilder.control('0'), + }), + [ALIGN]: formBuilder.control(''), + [CLOSE_STRATEGY]: formBuilder.group({ + [ON_FOCUS_LOST]: formBuilder.control(true), + [ON_ESCAPE]: formBuilder.control(true), + }), + }); + + this._coordinateAnchor$ = defer(() => this.form.get(ANCHOR) + .valueChanges + .pipe( + startWith(this.form.get(ANCHOR).value as object), + map(formValue => ({ + x: Number(formValue[X]), + y: Number(formValue[Y]), + width: Number(formValue[WIDTH]), + height: Number(formValue[HEIGHT]), + }), + ), + )); + } + + public ngAfterViewInit(): void { + const {left, top, width, height} = this._host.nativeElement.getBoundingClientRect(); + this.form.get(ANCHOR).patchValue({ + [X]: left + width / 2, + [Y]: top + height / 2, + }); + } + + public async onPopupOpen(): Promise { + const qualifier = SciParamsEnterComponent.toParamsDictionary(this.form.get(QUALIFIER) as FormArray); + const params = SciParamsEnterComponent.toParamsDictionary(this.form.get(PARAMS) as FormArray); + + this.popupError = null; + this.returnValue = null; + + await this._popupService.open(qualifier, { + params, + anchor: this.form.get([ANCHOR, BINDING]).value === 'element' ? this._openButton.nativeElement : this._coordinateAnchor$, + align: this.form.get(ALIGN).value || undefined, + closeStrategy: undefinedIfEmpty({ + onFocusLost: this.form.get([CLOSE_STRATEGY, ON_FOCUS_LOST]).value ?? undefined, + onEscape: this.form.get([CLOSE_STRATEGY, ON_ESCAPE]).value ?? undefined, + }), + }) + .then(result => this.returnValue = result) + .catch(error => this.popupError = error ?? 'Popup was closed with an error'); + } +} diff --git a/apps/workbench-client-testing-app/src/app/popup-opener-page/popup-opener-page.module.ts b/apps/workbench-client-testing-app/src/app/popup-opener-page/popup-opener-page.module.ts new file mode 100644 index 000000000..939a76142 --- /dev/null +++ b/apps/workbench-client-testing-app/src/app/popup-opener-page/popup-opener-page.module.ts @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2018-2019 Swiss Federal Railways + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SciAccordionModule, SciCheckboxModule, SciFormFieldModule, SciParamsEnterModule } from '@scion/toolkit.internal/widgets'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { RouterModule, Routes } from '@angular/router'; +import { PopupOpenerPageComponent } from './popup-opener-page.component'; + +const routes: Routes = [ + {path: '', component: PopupOpenerPageComponent}, +]; + +@NgModule({ + imports: [ + CommonModule, + FormsModule, + ReactiveFormsModule, + RouterModule.forChild(routes), + SciFormFieldModule, + SciCheckboxModule, + SciAccordionModule, + SciParamsEnterModule, + ], + declarations: [ + PopupOpenerPageComponent, + ], +}) +export class PopupOpenerPageModule { +} diff --git a/apps/workbench-client-testing-app/src/app/popup-page/popup-page.component.html b/apps/workbench-client-testing-app/src/app/popup-page/popup-page.component.html new file mode 100644 index 000000000..c189bbe46 --- /dev/null +++ b/apps/workbench-client-testing-app/src/app/popup-page/popup-page.component.html @@ -0,0 +1,113 @@ + +
+ + {{uuid}} + +
+ +
+
Component Size
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
Popup Capability
+
+ + +
{{popup.capability | json}}
+
+
+
+ + + +
Popup Params
+
+ + + + + +
+ + + +
Route Params
+
+ + + +
+ + + +
Route Query Params
+
+ + + +
+ + + +
Route Fragment
+
+ + {{fragment}} + +
+ + + +
Preferred Overlay Size
+
+ + +
{{preferredSize | json}}
+
+
+
+ + + +
Return Value
+
+ + + +
+ +
+ +
+ + +
diff --git a/apps/workbench-client-testing-app/src/app/popup-page/popup-page.component.scss b/apps/workbench-client-testing-app/src/app/popup-page/popup-page.component.scss new file mode 100644 index 000000000..0321fadf7 --- /dev/null +++ b/apps/workbench-client-testing-app/src/app/popup-page/popup-page.component.scss @@ -0,0 +1,86 @@ +@use 'sci-toolkit-styles' as sci-toolkit-styles; + +:host { + display: flex; + flex-direction: column; + padding: 1em; + // Because this component contains expandable panels that can grow and shrink, + // we position it out of the document flow to give it infinite space so that it + // can always be rendered at its preferred size. + position: absolute; + + > sci-viewport { + flex: auto; + --sci-viewport-content-grid-gap: 1em; + --sci-viewport-content-grid-template-columns: 1fr 1fr; + --sci-viewport-content-grid-auto-rows: min-content; + + section, form { + display: grid; + grid-row-gap: .5em; + grid-auto-rows: min-content; + border: 1px solid var(--sci-color-P400); + border-radius: 5px; + padding: 1em; + + > header { + margin-top: 0; + margin-bottom: 2em; + font-weight: bold; + } + } + + section.general { + grid-column: 1/-1; + } + + section.component-size { + grid-row: 2/8; + } + + sci-accordion.return-value { + grid-column: 1/-1; + } + + sci-accordion { + header { + font-weight: bold; + } + + &.popup-capability sci-viewport { + max-height: 200px; + max-width: 260px; + + div.capability { + white-space: pre; + font-family: monospace; + } + } + + &.e2e-popup-params sci-viewport { + max-height: 200px; + } + + &.preferred-overlay-size sci-viewport { + max-height: 200px; + + div.preferred-overlay-size { + white-space: pre; + font-family: monospace; + } + } + + &.return-value input { + @include sci-toolkit-styles.input-field(); + } + } + } + + > div.buttons { + flex: none; + display: grid; + grid-template-columns: 1fr 1fr; + column-gap: .25em; + margin-top: 1em; + } +} diff --git a/apps/workbench-client-testing-app/src/app/popup-page/popup-page.component.ts b/apps/workbench-client-testing-app/src/app/popup-page/popup-page.component.ts new file mode 100644 index 000000000..7e79b1cb8 --- /dev/null +++ b/apps/workbench-client-testing-app/src/app/popup-page/popup-page.component.ts @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2018-2019 Swiss Federal Railways + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import { Component, ElementRef, HostBinding } from '@angular/core'; +import { WorkbenchPopup } from '@scion/workbench-client'; +import { Beans } from '@scion/toolkit/bean-manager'; +import { PreferredSizeService } from '@scion/microfrontend-platform'; +import { UUID } from '@scion/toolkit/uuid'; +import { ActivatedRoute } from '@angular/router'; + +/** + * Popup test component which can grow and shrink. + */ +@Component({ + selector: 'app-popup-page', + templateUrl: './popup-page.component.html', + styleUrls: ['./popup-page.component.scss'], +}) +export class PopupPageComponent { + + public uuid = UUID.randomUUID(); + + @HostBinding('style.width') + public width: string; + + @HostBinding('style.height') + public height: string; + + @HostBinding('style.min-height') + public minHeight: string; + + @HostBinding('style.max-height') + public maxHeight: string; + + /** + * Since the component is positioned absolutely, we set its 'minWidth' to '100VW' + * so that it can fill the available space horizontally if the popup overlay defines + * a fixed width. + */ + @HostBinding('style.min-width') + public minWidth = '100vw'; + + @HostBinding('style.max-width') + public maxWidth: string; + + public result: string; + + constructor(host: ElementRef, + public route: ActivatedRoute, + public popup: WorkbenchPopup) { + // Use the size of this component as the popup size. + Beans.get(PreferredSizeService).fromDimension(host.nativeElement); + + const configuredPopupSize = popup.capability.properties.size; + this.width = configuredPopupSize?.width ?? 'max-content'; + this.height = configuredPopupSize?.height ?? 'max-content'; + } + + public onClose(): void { + this.popup.close(this.result); + } + + public onCloseWithError(): void { + this.popup.closeWithError(this.result); + } +} diff --git a/apps/workbench-client-testing-app/src/app/popup-page/popup-page.module.ts b/apps/workbench-client-testing-app/src/app/popup-page/popup-page.module.ts new file mode 100644 index 000000000..870a304ba --- /dev/null +++ b/apps/workbench-client-testing-app/src/app/popup-page/popup-page.module.ts @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2018-2019 Swiss Federal Railways + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterModule, Routes } from '@angular/router'; +import { SciAccordionModule, SciFormFieldModule, SciPropertyModule } from '@scion/toolkit.internal/widgets'; +import { FormsModule } from '@angular/forms'; +import { PopupPageComponent } from './popup-page.component'; +import { UtilModule } from '../util/util.module'; +import { SciViewportModule } from '@scion/toolkit/viewport'; +import { A11yModule } from '@angular/cdk/a11y'; + +const routes: Routes = [ + {path: '', component: PopupPageComponent}, + {path: ':segment1/segment2/:segment3', component: PopupPageComponent}, + {path: 'popup1', component: PopupPageComponent}, + {path: 'popup2', component: PopupPageComponent}, +]; + +@NgModule({ + imports: [ + CommonModule, + FormsModule, + RouterModule.forChild(routes), + A11yModule, + SciFormFieldModule, + SciPropertyModule, + SciAccordionModule, + SciViewportModule, + UtilModule, + ], + declarations: [ + PopupPageComponent, + ], +}) +export class PopupPageModule { +} diff --git a/apps/workbench-client-testing-app/src/app/register-workbench-capability-page/register-workbench-capability-page.component.html b/apps/workbench-client-testing-app/src/app/register-workbench-capability-page/register-workbench-capability-page.component.html index 9303f550c..04a082fd0 100644 --- a/apps/workbench-client-testing-app/src/app/register-workbench-capability-page/register-workbench-capability-page.component.html +++ b/apps/workbench-client-testing-app/src/app/register-workbench-capability-page/register-workbench-capability-page.component.html @@ -3,6 +3,7 @@ @@ -50,6 +51,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/workbench-client-testing-app/src/app/register-workbench-capability-page/register-workbench-capability-page.component.ts b/apps/workbench-client-testing-app/src/app/register-workbench-capability-page/register-workbench-capability-page.component.ts index 0d65bbf9a..0f1cf2156 100644 --- a/apps/workbench-client-testing-app/src/app/register-workbench-capability-page/register-workbench-capability-page.component.ts +++ b/apps/workbench-client-testing-app/src/app/register-workbench-capability-page/register-workbench-capability-page.component.ts @@ -12,13 +12,15 @@ import { Component } from '@angular/core'; import { AbstractControl, FormArray, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { SciParamsEnterComponent } from '@scion/toolkit.internal/widgets'; import { Capability, ManifestService } from '@scion/microfrontend-platform'; -import { WorkbenchCapabilities, WorkbenchViewCapability } from '@scion/workbench-client'; +import { PopupSize, WorkbenchCapabilities, WorkbenchPopupCapability, WorkbenchViewCapability } from '@scion/workbench-client'; +import { undefinedIfEmpty } from '../util/util'; const TYPE = 'type'; const QUALIFIER = 'qualifier'; const REQUIRED_PARAMS = 'requiredParams'; const OPTIONAL_PARAMS = 'optionalParams'; const VIEW_PROPERTIES = 'viewProperties'; +const POPUP_PROPERTIES = 'popupProperties'; const PRIVATE = 'private'; const PATH = 'path'; const TITLE = 'title'; @@ -26,6 +28,13 @@ const HEADING = 'heading'; const CLOSABLE = 'closable'; const CSS_CLASS = 'cssClass'; const PIN_TO_START_PAGE = 'pinToStartPage'; +const SIZE = 'size'; +const MIN_HEIGHT = 'minHeight'; +const HEIGHT = 'height'; +const MAX_HEIGHT = 'maxHeight'; +const MIN_WIDTH = 'minWidth'; +const WIDTH = 'width'; +const MAX_WIDTH = 'maxWidth'; /** * Allows registering workbench capabilities. @@ -43,12 +52,20 @@ export class RegisterWorkbenchCapabilityPageComponent { public readonly OPTIONAL_PARAMS = OPTIONAL_PARAMS; public readonly PRIVATE = PRIVATE; public readonly VIEW_PROPERTIES = VIEW_PROPERTIES; + public readonly POPUP_PROPERTIES = POPUP_PROPERTIES; public readonly PATH = PATH; public readonly TITLE = TITLE; public readonly HEADING = HEADING; public readonly CLOSABLE = CLOSABLE; public readonly CSS_CLASS = CSS_CLASS; public readonly PIN_TO_START_PAGE = PIN_TO_START_PAGE; + public readonly SIZE = SIZE; + public readonly MIN_HEIGHT = MIN_HEIGHT; + public readonly HEIGHT = HEIGHT; + public readonly MAX_HEIGHT = MAX_HEIGHT; + public readonly MIN_WIDTH = MIN_WIDTH; + public readonly WIDTH = WIDTH; + public readonly MAX_WIDTH = MAX_WIDTH; public form: FormGroup; @@ -74,6 +91,18 @@ export class RegisterWorkbenchCapabilityPageComponent { [CSS_CLASS]: formBuilder.control(''), [PIN_TO_START_PAGE]: formBuilder.control(false), }), + [POPUP_PROPERTIES]: formBuilder.group({ + [PATH]: formBuilder.control(''), + [SIZE]: formBuilder.group({ + [MIN_HEIGHT]: formBuilder.control(''), + [HEIGHT]: formBuilder.control(''), + [MAX_HEIGHT]: formBuilder.control(''), + [MIN_WIDTH]: formBuilder.control(''), + [WIDTH]: formBuilder.control(''), + [MAX_WIDTH]: formBuilder.control(''), + }), + [CSS_CLASS]: formBuilder.control(''), + }), }); this._formInitialValue = this.form.value; } @@ -83,6 +112,8 @@ export class RegisterWorkbenchCapabilityPageComponent { switch (this.form.get(TYPE).value) { case WorkbenchCapabilities.View: return this.readViewCapabilityFromUI(); + case WorkbenchCapabilities.Popup: + return this.readPopupCapabilityFromUI(); default: throw Error('[IllegalArgumentError] Capability expected to be a workbench capability, but was not.'); } @@ -119,6 +150,29 @@ export class RegisterWorkbenchCapabilityPageComponent { }; } + private readPopupCapabilityFromUI(): WorkbenchPopupCapability { + const propertiesGroup = this.form.get(POPUP_PROPERTIES); + return { + type: WorkbenchCapabilities.Popup, + qualifier: SciParamsEnterComponent.toParamsDictionary(this.form.get(QUALIFIER) as FormArray), + requiredParams: this.form.get(REQUIRED_PARAMS).value?.split(/,\s*/).filter(Boolean), + optionalParams: this.form.get(OPTIONAL_PARAMS).value?.split(/,\s*/).filter(Boolean), + private: this.form.get(PRIVATE).value, + properties: { + path: this.readPathFromUI(propertiesGroup), + size: undefinedIfEmpty({ + width: propertiesGroup.get([SIZE, WIDTH]).value || undefined, + height: propertiesGroup.get([SIZE, HEIGHT]).value || undefined, + minWidth: propertiesGroup.get([SIZE, MIN_WIDTH]).value || undefined, + maxWidth: propertiesGroup.get([SIZE, MAX_WIDTH]).value || undefined, + minHeight: propertiesGroup.get([SIZE, MIN_HEIGHT]).value || undefined, + maxHeight: propertiesGroup.get([SIZE, MAX_HEIGHT]).value || undefined, + }), + cssClass: propertiesGroup.get(CSS_CLASS).value?.split(/\s+/).filter(Boolean) || undefined, + }, + }; + } + private readPathFromUI(formGroup: AbstractControl): string { const path = formGroup.get(PATH).value; switch (path) { diff --git a/apps/workbench-client-testing-app/src/app/register-workbench-intention-page/register-workbench-intention-page.component.html b/apps/workbench-client-testing-app/src/app/register-workbench-intention-page/register-workbench-intention-page.component.html index cdf2883e7..9a2f81a3b 100644 --- a/apps/workbench-client-testing-app/src/app/register-workbench-intention-page/register-workbench-intention-page.component.html +++ b/apps/workbench-client-testing-app/src/app/register-workbench-intention-page/register-workbench-intention-page.component.html @@ -2,6 +2,7 @@ diff --git a/apps/workbench-client-testing-app/src/app/util/util.ts b/apps/workbench-client-testing-app/src/app/util/util.ts new file mode 100644 index 000000000..068ead449 --- /dev/null +++ b/apps/workbench-client-testing-app/src/app/util/util.ts @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2018-2019 Swiss Federal Railways + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +/** + * Returns a new instance with `undefined` entries removed, + * or returns `undefined` if all entries are `undefined`. + */ +export function undefinedIfEmpty(object: T): T { + return Object.entries(object).reduce((acc, [key, value]) => { + if (value === undefined) { + return acc; + } + return {...acc, [key]: value}; + }, undefined as T); +} diff --git a/apps/workbench-client-testing-app/src/app/workbench-client/workbench-microfrontend-support.ts b/apps/workbench-client-testing-app/src/app/workbench-client/workbench-microfrontend-support.ts index 28461ff26..2b7784540 100644 --- a/apps/workbench-client-testing-app/src/app/workbench-client/workbench-microfrontend-support.ts +++ b/apps/workbench-client-testing-app/src/app/workbench-client/workbench-microfrontend-support.ts @@ -10,7 +10,7 @@ import { APP_INITIALIZER, Provider } from '@angular/core'; import { ContextService, FocusMonitor, IntentClient, ManifestService, MessageClient, MicroApplicationConfig, OutletRouter, PlatformPropertyService, PreferredSizeService } from '@scion/microfrontend-platform'; -import { WorkbenchClient, WorkbenchRouter, WorkbenchView } from '@scion/workbench-client'; +import { WorkbenchClient, WorkbenchPopup, WorkbenchPopupService, WorkbenchRouter, WorkbenchView } from '@scion/workbench-client'; import { NgZoneIntentClientDecorator, NgZoneMessageClientDecorator } from './ng-zone-decorators'; import { Beans } from '@scion/toolkit/bean-manager'; import { environment } from '../../environments/environment'; @@ -43,6 +43,8 @@ export function provideWorkbenchClientInitializer(): Provider[] { {provide: PreferredSizeService, useFactory: () => Beans.get(PreferredSizeService)}, {provide: WorkbenchRouter, useFactory: () => Beans.get(WorkbenchRouter)}, {provide: WorkbenchView, useFactory: () => Beans.opt(WorkbenchView)}, + {provide: WorkbenchPopupService, useFactory: () => Beans.get(WorkbenchPopupService)}, + {provide: WorkbenchPopup, useFactory: () => Beans.opt(WorkbenchPopup)}, ]; } diff --git a/projects/scion/e2e-testing/protractor.conf.js b/projects/scion/e2e-testing/protractor.conf.js index dcb274ddc..59f3fb657 100644 --- a/projects/scion/e2e-testing/protractor.conf.js +++ b/projects/scion/e2e-testing/protractor.conf.js @@ -66,6 +66,18 @@ exports.config = { 'workbench-client::view': [ './src/workbench-client/**/view.e2e-spec.ts', ], + 'workbench-client::popup': [ + './src/workbench-client/**/popup.e2e-spec.ts', + ], + 'workbench-client::popup-params': [ + './src/workbench-client/**/popup-params.e2e-spec.ts', + ], + 'workbench-client::popup-router': [ + './src/workbench-client/**/popup-router.e2e-spec.ts', + ], + 'workbench-client::popup-size': [ + './src/workbench-client/**/popup-size.e2e-spec.ts', + ], }, capabilities: { browserName: 'chrome', diff --git a/projects/scion/e2e-testing/src/app.po.ts b/projects/scion/e2e-testing/src/app.po.ts index 6c09247ca..f1fd39983 100644 --- a/projects/scion/e2e-testing/src/app.po.ts +++ b/projects/scion/e2e-testing/src/app.po.ts @@ -8,7 +8,7 @@ * SPDX-License-Identifier: EPL-2.0 */ -import { $, browser, ElementFinder, Key, protractor } from 'protractor'; +import { $, $$, browser, ElementFinder, Key, protractor } from 'protractor'; import { getCssClasses, isCssClassPresent, runOutsideAngularSynchronization } from './helper/testing.util'; import { StartPagePO } from './start-page.po'; import { coerceArray, coerceBooleanProperty } from '@angular/cdk/coercion'; @@ -297,7 +297,7 @@ export class AppPO { public async isPresent(): Promise { await WebdriverExecutionContexts.switchToDefault(); - return popupOverlayFinder.isPresent() && popupComponentFinder.isPresent(); + return await popupOverlayFinder.isPresent() && await popupComponentFinder.isPresent(); } public async isDisplayed(): Promise { @@ -305,7 +305,7 @@ export class AppPO { if (!await this.isPresent()) { return false; } - return popupOverlayFinder.isDisplayed() && popupComponentFinder.isDisplayed(); + return await popupOverlayFinder.isDisplayed() && await popupComponentFinder.isDisplayed(); } public async getClientRect(selector: 'cdk-overlay' | 'wb-popup' = 'wb-popup'): Promise { @@ -357,6 +357,14 @@ export class AppPO { }; } + /** + * Returns the number of opened popups. + */ + public async getPopupCount(): Promise { + await WebdriverExecutionContexts.switchToDefault(); + return $$('.wb-popup').count(); + } + /** * Returns a handle representing the notification having given CSS class(es) set. * This call does not send a command to the browser. Use 'isPresent()' to test its presence. diff --git a/projects/scion/e2e-testing/src/workbench-client/page-object/popup-opener-page.po.ts b/projects/scion/e2e-testing/src/workbench-client/page-object/popup-opener-page.po.ts new file mode 100644 index 000000000..7936f16ac --- /dev/null +++ b/projects/scion/e2e-testing/src/workbench-client/page-object/popup-opener-page.po.ts @@ -0,0 +1,218 @@ +/* + * Copyright (c) 2018-2019 Swiss Federal Railways + * + * This program and the accompanying materials are made + * available under the terms from the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import { assertPageToDisplay, enterText, selectOption } from '../../helper/testing.util'; +import { AppPO, ViewPO, ViewTabPO } from '../../app.po'; +import { SciAccordionPO, SciCheckboxPO, SciParamsEnterPO } from '@scion/toolkit.internal/widgets.po'; +import { $, browser, ElementFinder } from 'protractor'; +import { WebdriverExecutionContexts } from '../../helper/webdriver-execution-context'; +import { Qualifier } from '@scion/microfrontend-platform'; +import { PopupOrigin } from '@scion/workbench'; +import { Dictionary } from '@scion/toolkit/util'; + +/** + * Page object to interact {@link PopupOpenerPageComponent}. + */ +export class PopupOpenerPagePO { + + private _appPO = new AppPO(); + private _pageFinder: ElementFinder; + + public readonly viewPO: ViewPO; + public readonly viewTabPO: ViewTabPO; + + constructor(public viewId: string) { + this.viewPO = this._appPO.findView({viewId: viewId}); + this.viewTabPO = this._appPO.findViewTab({viewId: viewId}); + this._pageFinder = $('app-popup-opener-page'); + } + + public async enterQualifier(qualifier: Qualifier): Promise { + await WebdriverExecutionContexts.switchToIframe(this.viewId); + await assertPageToDisplay(this._pageFinder); + const paramsEnterPO = new SciParamsEnterPO(this._pageFinder.$('sci-params-enter.e2e-qualifier')); + await paramsEnterPO.clear(); + await paramsEnterPO.enterParams(qualifier); + } + + public async enterParams(params: Dictionary): Promise { + await WebdriverExecutionContexts.switchToIframe(this.viewId); + await assertPageToDisplay(this._pageFinder); + const paramsEnterPO = new SciParamsEnterPO(this._pageFinder.$('sci-params-enter.e2e-params')); + await paramsEnterPO.clear(); + await paramsEnterPO.enterParams(params); + } + + public async selectAnchor(anchor: 'element' | 'coordinate'): Promise { + await WebdriverExecutionContexts.switchToIframe(this.viewId); + await assertPageToDisplay(this._pageFinder); + + const accordionPO = new SciAccordionPO(this._pageFinder.$('sci-accordion.e2e-anchor')); + await accordionPO.expand(); + try { + await selectOption(anchor, this._pageFinder.$('select.e2e-anchor')); + } + finally { + await accordionPO.collapse(); + } + } + + public async enterAnchorCoordinate(coordinate: PopupOrigin): Promise { + await WebdriverExecutionContexts.switchToIframe(this.viewId); + await assertPageToDisplay(this._pageFinder); + + const accordionPO = new SciAccordionPO(this._pageFinder.$('sci-accordion.e2e-anchor')); + await accordionPO.expand(); + try { + if (coordinate.x !== undefined) { + await enterText(`${coordinate.x}`, this._pageFinder.$('input.e2e-anchor-x')); + } + if (coordinate.y !== undefined) { + await enterText(`${coordinate.y}`, this._pageFinder.$('input.e2e-anchor-y')); + } + if (coordinate.width !== undefined) { + await enterText(`${coordinate.width}`, this._pageFinder.$('input.e2e-anchor-width')); + } + if (coordinate.height !== undefined) { + await enterText(`${coordinate.height}`, this._pageFinder.$('input.e2e-anchor-height')); + } + } + finally { + await accordionPO.collapse(); + } + } + + public async selectAlign(align: 'east' | 'west' | 'north' | 'south'): Promise { + await WebdriverExecutionContexts.switchToIframe(this.viewId); + await assertPageToDisplay(this._pageFinder); + await selectOption(align, this._pageFinder.$('select.e2e-align')); + } + + public async enterCloseStrategy(options: { closeOnFocusLost?: boolean, closeOnEscape?: boolean }): Promise { + await WebdriverExecutionContexts.switchToIframe(this.viewId); + await assertPageToDisplay(this._pageFinder); + + const accordionPO = new SciAccordionPO(this._pageFinder.$('sci-accordion.e2e-close-strategy')); + await accordionPO.expand(); + try { + if (options.closeOnFocusLost !== undefined) { + await new SciCheckboxPO(this._pageFinder.$('sci-checkbox.e2e-close-on-focus-lost')).toggle(options.closeOnFocusLost); + } + if (options.closeOnEscape !== undefined) { + await new SciCheckboxPO(this._pageFinder.$('sci-checkbox.e2e-close-on-escape')).toggle(options.closeOnEscape); + } + } + finally { + await accordionPO.collapse(); + } + } + + public async expandAnchorPanel(): Promise { + await WebdriverExecutionContexts.switchToIframe(this.viewId); + await assertPageToDisplay(this._pageFinder); + const accordionPO = new SciAccordionPO(this._pageFinder.$('sci-accordion.e2e-anchor')); + await accordionPO.expand(); + } + + public async collapseAnchorPanel(): Promise { + await WebdriverExecutionContexts.switchToIframe(this.viewId); + await assertPageToDisplay(this._pageFinder); + const accordionPO = new SciAccordionPO(this._pageFinder.$('sci-accordion.e2e-anchor')); + await accordionPO.collapse(); + } + + public async clickOpen(): Promise { + await WebdriverExecutionContexts.switchToIframe(this.viewId); + await assertPageToDisplay(this._pageFinder); + + const expectedPopupCount = await this._appPO.getPopupCount() + 1; + + await WebdriverExecutionContexts.switchToIframe(this.viewId); + await this._pageFinder.$('button.e2e-open').click(); + + // Evaluate the response: resolves the promise on success, or rejects it on error. + const errorFinder = this._pageFinder.$('output.e2e-popup-error'); + await browser.wait(async () => { + // Test if the popup has opened + await WebdriverExecutionContexts.switchToDefault(); + const actualPopupCount = await this._appPO.getPopupCount(); + if (actualPopupCount === expectedPopupCount) { + return true; + } + + // Test if an error is present + await WebdriverExecutionContexts.switchToIframe(this.viewId); + if (await errorFinder.isPresent()) { + return true; + } + + return false; + }, 5000); + + await WebdriverExecutionContexts.switchToIframe(this.viewId); + if (await errorFinder.isPresent()) { + return Promise.reject(await errorFinder.getText()); + } + } + + public async getPopupCloseAction(): Promise { + await WebdriverExecutionContexts.switchToIframe(this.viewId); + await assertPageToDisplay(this._pageFinder); + + if (await this._pageFinder.$('output.e2e-return-value').isPresent()) { + return { + type: 'closed-with-value', + value: await this._pageFinder.$('output.e2e-return-value').getText(), + }; + } + if (await this._pageFinder.$('output.e2e-popup-error').isPresent()) { + return { + type: 'closed-with-error', + value: await this._pageFinder.$('output.e2e-popup-error').getText(), + }; + } + return { + type: 'closed', + }; + } + + public async getAnchorElementClientRect(): Promise { + await WebdriverExecutionContexts.switchToIframe(this.viewId); + await assertPageToDisplay(this._pageFinder); + + const buttonFinder = this._pageFinder.$('button.e2e-open'); + const {width, height} = await buttonFinder.getSize(); + const {x, y} = await buttonFinder.getLocation(); + return { + top: y, + left: x, + right: x + width, + bottom: y + height, + width, + height, + }; + } + + /** + * Opens the page to test the popup in a new view tab. + */ + public static async openInNewTab(app: 'app1' | 'app2'): Promise { + const appPO = new AppPO(); + const startPO = await appPO.openNewViewTab(); + await startPO.openMicrofrontendView('e2e-test-popup', `workbench-client-testing-${app}`); + const viewId = await appPO.findActiveView().getViewId(); + return new PopupOpenerPagePO(viewId); + } +} + +export interface PopupCloseAction { + type: 'closed' | 'closed-with-value' | 'closed-with-error'; + value?: string; +} diff --git a/projects/scion/e2e-testing/src/workbench-client/page-object/popup-page.po.ts b/projects/scion/e2e-testing/src/workbench-client/page-object/popup-page.po.ts new file mode 100644 index 000000000..4b56bb061 --- /dev/null +++ b/projects/scion/e2e-testing/src/workbench-client/page-object/popup-page.po.ts @@ -0,0 +1,189 @@ +/* + * Copyright (c) 2018-2019 Swiss Federal Railways + * + * This program and the accompanying materials are made + * available under the terms from the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import { assertPageToDisplay, enterText } from '../../helper/testing.util'; +import { AppPO, PopupPO } from '../../app.po'; +import { $, ElementFinder } from 'protractor'; +import { WebdriverExecutionContexts } from '../../helper/webdriver-execution-context'; +import { PopupSize } from '@scion/workbench'; +import { SciAccordionPO, SciPropertyPO } from '@scion/toolkit.internal/widgets.po'; +import { ISize } from 'selenium-webdriver'; +import { Params } from '@angular/router'; +import { WorkbenchPopupCapability } from '@scion/workbench-client'; +import { RouterOutletPO } from './router-outlet.po'; + +/** + * Page object to interact {@link PopupPageComponent}. + */ +export class PopupPagePO { + + private _appPO = new AppPO(); + private _pageFinder: ElementFinder; + private _popupId: Promise; + + public readonly popupPO: PopupPO; + + constructor(public cssClass: string) { + this.popupPO = this._appPO.findPopup({cssClass: cssClass}); + this._pageFinder = $('app-popup-page'); + this._popupId = new RouterOutletPO().resolveRouterOutletName('e2e-popup', cssClass); + } + + public async isPresent(): Promise { + if (!await this.popupPO.isPresent()) { + return false; + } + + if (!await new RouterOutletPO().isPresent(await this._popupId)) { + return false; + } + + await WebdriverExecutionContexts.switchToIframe(await this._popupId); + return this._pageFinder.isPresent(); + } + + public async isDisplayed(): Promise { + if (!await this.popupPO.isDisplayed()) { + return false; + } + + if (!await new RouterOutletPO().isDisplayed(await this._popupId)) { + return false; + } + + await WebdriverExecutionContexts.switchToIframe(await this._popupId); + return await this._pageFinder.isPresent() && await this._pageFinder.isDisplayed(); + } + + public async getComponentInstanceId(): Promise { + await WebdriverExecutionContexts.switchToIframe(await this._popupId); + await assertPageToDisplay(this._pageFinder); + return this._pageFinder.$('span.e2e-component-instance-id').getText(); + } + + public async getPopupCapability(): Promise { + await WebdriverExecutionContexts.switchToIframe(await this._popupId); + await assertPageToDisplay(this._pageFinder); + + const accordionPO = new SciAccordionPO(this._pageFinder.$('sci-accordion.e2e-popup-capability')); + await accordionPO.expand(); + try { + return JSON.parse(await this._pageFinder.$('div.e2e-popup-capability').getText()); + } + finally { + await accordionPO.collapse(); + } + } + + public async getPopupParams(): Promise { + await WebdriverExecutionContexts.switchToIframe(await this._popupId); + await assertPageToDisplay(this._pageFinder); + + const accordionPO = new SciAccordionPO(this._pageFinder.$('sci-accordion.e2e-popup-params')); + await accordionPO.expand(); + try { + return await new SciPropertyPO(this._pageFinder.$('sci-property.e2e-popup-params')).readAsDictionary(); + } + finally { + await accordionPO.collapse(); + } + } + + public async getRouteParams(): Promise { + await WebdriverExecutionContexts.switchToIframe(await this._popupId); + await assertPageToDisplay(this._pageFinder); + + const accordionPO = new SciAccordionPO(this._pageFinder.$('sci-accordion.e2e-route-params')); + await accordionPO.expand(); + try { + return await new SciPropertyPO(this._pageFinder.$('sci-property.e2e-route-params')).readAsDictionary(); + } + finally { + await accordionPO.collapse(); + } + } + + public async getRouteQueryParams(): Promise { + await WebdriverExecutionContexts.switchToIframe(await this._popupId); + await assertPageToDisplay(this._pageFinder); + + const accordionPO = new SciAccordionPO(this._pageFinder.$('sci-accordion.e2e-route-query-params')); + await accordionPO.expand(); + try { + return await new SciPropertyPO(this._pageFinder.$('sci-property.e2e-route-query-params')).readAsDictionary(); + } + finally { + await accordionPO.collapse(); + } + } + + public async getRouteFragment(): Promise { + await WebdriverExecutionContexts.switchToIframe(await this._popupId); + await assertPageToDisplay(this._pageFinder); + + const accordionPO = new SciAccordionPO(this._pageFinder.$('sci-accordion.e2e-route-fragment')); + await accordionPO.expand(); + try { + return await this._pageFinder.$('span.e2e-route-fragment').getText(); + } + finally { + await accordionPO.collapse(); + } + } + + public async enterComponentSize(size: PopupSize): Promise { + await WebdriverExecutionContexts.switchToIframe(await this._popupId); + await assertPageToDisplay(this._pageFinder); + + await enterText(size.width, this._pageFinder.$('input.e2e-width')); + await enterText(size.height, this._pageFinder.$('input.e2e-height')); + await enterText(size.minWidth, this._pageFinder.$('input.e2e-min-width')); + await enterText(size.maxWidth, this._pageFinder.$('input.e2e-max-width')); + await enterText(size.minHeight, this._pageFinder.$('input.e2e-min-height')); + await enterText(size.maxHeight, this._pageFinder.$('input.e2e-max-height')); + } + + public async enterReturnValue(returnValue: string): Promise { + await WebdriverExecutionContexts.switchToIframe(await this._popupId); + await assertPageToDisplay(this._pageFinder); + + const accordionPO = new SciAccordionPO(this._pageFinder.$('sci-accordion.e2e-return-value')); + await accordionPO.expand(); + try { + await enterText(returnValue, this._pageFinder.$('input.e2e-return-value')); + } + finally { + await accordionPO.collapse(); + } + } + + public async clickClose(options?: { returnValue?: string, closeWithError?: boolean }): Promise { + await WebdriverExecutionContexts.switchToIframe(await this._popupId); + await assertPageToDisplay(this._pageFinder); + + if (options?.returnValue !== undefined) { + await this.enterReturnValue(options.returnValue); + } + + if (options?.closeWithError === true) { + await this._pageFinder.$('button.e2e-close-with-error').click(); + } + else { + await this._pageFinder.$('button.e2e-close').click(); + } + } + + public async getSize(): Promise { + await WebdriverExecutionContexts.switchToIframe(await this._popupId); + await assertPageToDisplay(this._pageFinder); + const {width, height} = await this._pageFinder.getSize(); + return {width, height}; + } +} diff --git a/projects/scion/e2e-testing/src/workbench-client/page-object/register-workbench-capability-page.po.ts b/projects/scion/e2e-testing/src/workbench-client/page-object/register-workbench-capability-page.po.ts index 35fd6a1b6..0efbceb4d 100644 --- a/projects/scion/e2e-testing/src/workbench-client/page-object/register-workbench-capability-page.po.ts +++ b/projects/scion/e2e-testing/src/workbench-client/page-object/register-workbench-capability-page.po.ts @@ -15,7 +15,7 @@ import { $, browser, ElementFinder, protractor } from 'protractor'; import { WebdriverExecutionContexts } from '../../helper/webdriver-execution-context'; import { coerceArray } from '@angular/cdk/coercion'; import { RouterOutletPO } from './router-outlet.po'; -import { WorkbenchViewCapability as _WorkbenchViewCapability } from '@scion/workbench-client'; +import { WorkbenchPopupCapability as _WorkbenchPopupCapability, WorkbenchViewCapability as _WorkbenchViewCapability } from '@scion/workbench-client'; const EC = protractor.ExpectedConditions; @@ -29,6 +29,7 @@ const EC = protractor.ExpectedConditions; * For that reason, we re-declare workbench capability interfaces and replace their `type` property with a string literal. */ export type WorkbenchViewCapability = Omit<_WorkbenchViewCapability, 'type'> & { type: 'view', properties: { pinToStartPage?: boolean } }; +export type WorkbenchPopupCapability = Omit<_WorkbenchPopupCapability, 'type'> & { type: 'popup' }; /** * Page object to interact {@link RegisterWorkbenchCapabilityPageComponent}. @@ -67,7 +68,7 @@ export class RegisterWorkbenchCapabilityPagePO { * * Returns a Promise that resolves to the capability ID upon successful registration, or that rejects on registration error. */ - public async registerCapability(capability: T): Promise { + public async registerCapability(capability: T): Promise { await WebdriverExecutionContexts.switchToIframe(this.viewId); await assertPageToDisplay(this._pageFinder); @@ -98,6 +99,9 @@ export class RegisterWorkbenchCapabilityPagePO { if (capability.type === 'view') { await this.enterViewCapabilityProperties(capability as WorkbenchViewCapability); } + else if (capability.type === 'popup') { + await this.enterPopupCapabilityProperties(capability as WorkbenchPopupCapability); + } await this.clickRegister(); @@ -128,6 +132,29 @@ export class RegisterWorkbenchCapabilityPagePO { } } + private async enterPopupCapabilityProperties(capability: WorkbenchPopupCapability): Promise { + const size = capability.properties.size; + + if (size?.width !== undefined) { + await enterText(size.width, this._pageFinder.$('input.e2e-width')); + } + if (size?.height) { + await enterText(size.height, this._pageFinder.$('input.e2e-height')); + } + if (size?.minWidth) { + await enterText(size.minWidth, this._pageFinder.$('input.e2e-min-width')); + } + if (size?.maxWidth) { + await enterText(size.maxWidth, this._pageFinder.$('input.e2e-max-width')); + } + if (size?.minHeight) { + await enterText(size.minHeight, this._pageFinder.$('input.e2e-min-height')); + } + if (size?.maxHeight) { + await enterText(size.maxHeight, this._pageFinder.$('input.e2e-max-height')); + } + } + public async clickRegister(): Promise { await WebdriverExecutionContexts.switchToIframe(this.viewId); await assertPageToDisplay(this._pageFinder); diff --git a/projects/scion/e2e-testing/src/workbench-client/page-object/register-workbench-intention-page.po.ts b/projects/scion/e2e-testing/src/workbench-client/page-object/register-workbench-intention-page.po.ts index 6722d28b8..05da06e9a 100644 --- a/projects/scion/e2e-testing/src/workbench-client/page-object/register-workbench-intention-page.po.ts +++ b/projects/scion/e2e-testing/src/workbench-client/page-object/register-workbench-intention-page.po.ts @@ -41,7 +41,7 @@ export class RegisterWorkbenchIntentionPagePO { * * Returns a Promise that resolves to the intention ID upon successful registration, or that rejects on registration error. */ - public async registerIntention(intention: Intention & { type: 'view' }): Promise { + public async registerIntention(intention: Intention & { type: 'view' | 'popup' }): Promise { await WebdriverExecutionContexts.switchToIframe(this.viewId); await assertPageToDisplay(this._pageFinder); @@ -61,7 +61,7 @@ export class RegisterWorkbenchIntentionPagePO { } } - public async selectType(type: 'view'): Promise { + public async selectType(type: 'view' | 'popup'): Promise { await WebdriverExecutionContexts.switchToIframe(this.viewId); await assertPageToDisplay(this._pageFinder); await selectOption(type, this._pageFinder.$('select.e2e-type')); diff --git a/projects/scion/e2e-testing/src/workbench-client/page-object/router-outlet.po.ts b/projects/scion/e2e-testing/src/workbench-client/page-object/router-outlet.po.ts index 3b4f7338a..972f5823b 100644 --- a/projects/scion/e2e-testing/src/workbench-client/page-object/router-outlet.po.ts +++ b/projects/scion/e2e-testing/src/workbench-client/page-object/router-outlet.po.ts @@ -8,11 +8,24 @@ * SPDX-License-Identifier: EPL-2.0 */ -import { $ } from 'protractor'; +import { $, browser, protractor } from 'protractor'; import { WebdriverExecutionContexts } from '../../helper/webdriver-execution-context'; +const EC = protractor.ExpectedConditions; + export class RouterOutletPO { + /** + * Resolves to the name of the that has given CSS class(es) set. + */ + public async resolveRouterOutletName(...cssClass: string[]): Promise { + await WebdriverExecutionContexts.switchToDefault(); + + const routerOutletFinder = $(`sci-router-outlet.${cssClass.join('.')}`); + await browser.wait(EC.presenceOf(routerOutletFinder), 5000); + return routerOutletFinder.getAttribute('name'); + } + /** * Tests if the given router outlet is present in the DOM. */ diff --git a/projects/scion/e2e-testing/src/workbench-client/popup-params.e2e-spec.ts b/projects/scion/e2e-testing/src/workbench-client/popup-params.e2e-spec.ts new file mode 100644 index 000000000..aad76a574 --- /dev/null +++ b/projects/scion/e2e-testing/src/workbench-client/popup-params.e2e-spec.ts @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2018-2019 Swiss Federal Railways + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import { AppPO } from '../app.po'; +import { installSeleniumWebDriverClickFix } from '../helper/selenium-webdriver-click-fix'; +import { RegisterWorkbenchCapabilityPagePO } from './page-object/register-workbench-capability-page.po'; +import { consumeBrowserLog } from '../helper/testing.util'; +import { PopupOpenerPagePO } from './page-object/popup-opener-page.po'; +import { PopupPagePO } from './page-object/popup-page.po'; + +export declare type HTMLElement = any; + +describe('Popup', () => { + + const appPO = new AppPO(); + + installSeleniumWebDriverClickFix(); + + beforeEach(async () => consumeBrowserLog()); + + it('should allow passing a value to the popup component', async () => { + await appPO.navigateTo({microfrontendSupport: true}); + + // register testee popup + const registerCapabilityPagePO = await RegisterWorkbenchCapabilityPagePO.openInNewTab('app1'); + await registerCapabilityPagePO.registerCapability({ + type: 'popup', + qualifier: {entity: 'product', id: '*'}, + requiredParams: ['readonly'], + properties: { + path: 'popup', + cssClass: 'product', + }, + }); + + // open the popup + const popupOpenerPagePO = await PopupOpenerPagePO.openInNewTab('app1'); + await popupOpenerPagePO.enterQualifier({entity: 'product', id: '123'}); + await popupOpenerPagePO.enterParams({readonly: 'true'}); + await popupOpenerPagePO.clickOpen(); + + // expect qualifier to be contained in popup params + const popupPagePO = new PopupPagePO('product'); + await expect(await popupPagePO.getPopupParams()).toEqual(jasmine.objectContaining({entity: 'product', id: '123', readonly: 'true'})); + }); + + it('should contain the qualifier in popup params', async () => { + await appPO.navigateTo({microfrontendSupport: true}); + + // register testee popup + const registerCapabilityPagePO = await RegisterWorkbenchCapabilityPagePO.openInNewTab('app1'); + await registerCapabilityPagePO.registerCapability({ + type: 'popup', + qualifier: {entity: 'product', id: '*'}, + properties: { + path: 'popup', + cssClass: 'product', + }, + }); + + // open the popup + const popupOpenerPagePO = await PopupOpenerPagePO.openInNewTab('app1'); + await popupOpenerPagePO.enterQualifier({entity: 'product', id: '123'}); + await popupOpenerPagePO.clickOpen(); + + // expect qualifier to be contained in popup params + const popupPagePO = new PopupPagePO('product'); + await expect(await popupPagePO.getPopupParams()).toEqual(jasmine.objectContaining({entity: 'product', id: '123'})); + }); + + it('should not overwrite qualifier values with param values', async () => { + await appPO.navigateTo({microfrontendSupport: true}); + + // register testee popup + const registerCapabilityPagePO = await RegisterWorkbenchCapabilityPagePO.openInNewTab('app1'); + await registerCapabilityPagePO.registerCapability({ + type: 'popup', + qualifier: {entity: 'product', id: '*'}, + requiredParams: ['id'], + properties: { + path: 'popup', + cssClass: 'product', + }, + }); + + // open the popup + const popupOpenerPagePO = await PopupOpenerPagePO.openInNewTab('app1'); + await popupOpenerPagePO.enterQualifier({entity: 'product', id: '123'}); + await popupOpenerPagePO.enterParams({id: '456'}); // should be ignored + await popupOpenerPagePO.clickOpen(); + + // expect qualifier values not to be overwritten by params + const popupPagePO = new PopupPagePO('product'); + await expect(await popupPagePO.getPopupParams()).toEqual(jasmine.objectContaining({entity: 'product', id: '123'})); + }); + + it('should substitute named URL params with values of the qualifier and params', async () => { + await appPO.navigateTo({microfrontendSupport: true}); + + // register testee popup + const registerCapabilityPagePO = await RegisterWorkbenchCapabilityPagePO.openInNewTab('app1'); + await registerCapabilityPagePO.registerCapability({ + type: 'popup', + qualifier: {component: 'testee', seg1: '*', mp1: '*', qp1: '*'}, + requiredParams: ['seg3', 'mp2', 'qp2', 'fragment'], + properties: { + path: 'popup/:seg1/segment2/:seg3;mp1=:mp1;mp2=:mp2?qp1=:qp1&qp2=:qp2#:fragment', + cssClass: 'testee', + }, + }); + + // open the popup + const popupOpenerPagePO = await PopupOpenerPagePO.openInNewTab('app1'); + await popupOpenerPagePO.enterQualifier({component: 'testee', seg1: 'SEG1', mp1: 'MP1', qp1: 'QP1'}); + await popupOpenerPagePO.enterParams({seg3: 'SEG3', mp2: 'MP2', qp2: 'QP2', fragment: 'FRAGMENT'}); + await popupOpenerPagePO.clickOpen(); + + // expect named params to be substituted + const popupPagePO = new PopupPagePO('testee'); + await expect(await popupPagePO.getPopupParams()).toEqual(jasmine.objectContaining({component: 'testee', seg1: 'SEG1', seg3: 'SEG3', mp1: 'MP1', mp2: 'MP2', qp1: 'QP1', qp2: 'QP2', fragment: 'FRAGMENT'})); + await expect(await popupPagePO.getRouteParams()).toEqual({segment1: 'SEG1', segment3: 'SEG3', mp1: 'MP1', mp2: 'MP2'}); + await expect(await popupPagePO.getRouteQueryParams()).toEqual({qp1: 'QP1', qp2: 'QP2'}); + await expect(await popupPagePO.getRouteFragment()).toEqual('FRAGMENT'); + }); +}); diff --git a/projects/scion/e2e-testing/src/workbench-client/popup-router.e2e-spec.ts b/projects/scion/e2e-testing/src/workbench-client/popup-router.e2e-spec.ts new file mode 100644 index 000000000..e9cabda83 --- /dev/null +++ b/projects/scion/e2e-testing/src/workbench-client/popup-router.e2e-spec.ts @@ -0,0 +1,372 @@ +/* + * Copyright (c) 2018-2019 Swiss Federal Railways + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import { AppPO } from '../app.po'; +import { installSeleniumWebDriverClickFix } from '../helper/selenium-webdriver-click-fix'; +import { RegisterWorkbenchCapabilityPagePO } from './page-object/register-workbench-capability-page.po'; +import { RegisterWorkbenchIntentionPagePO } from './page-object/register-workbench-intention-page.po'; +import { expectPromise } from '../helper/expect-promise-matcher'; +import { assertPageToDisplay, consumeBrowserLog } from '../helper/testing.util'; +import { PopupOpenerPagePO } from './page-object/popup-opener-page.po'; +import { PopupPagePO } from './page-object/popup-page.po'; +import { WebdriverExecutionContexts } from '../helper/webdriver-execution-context'; +import { $ } from 'protractor'; +import { RouterOutletPO } from './page-object/router-outlet.po'; + +export declare type HTMLElement = any; + +describe('Popup Router', () => { + + const appPO = new AppPO(); + + installSeleniumWebDriverClickFix(); + + beforeEach(async () => consumeBrowserLog()); + + it('should navigate to own public popups', async () => { + await appPO.navigateTo({microfrontendSupport: true}); + + // register testee popup + const registerCapabilityPagePO = await RegisterWorkbenchCapabilityPagePO.openInNewTab('app1'); + await registerCapabilityPagePO.registerCapability({ + type: 'popup', + qualifier: {component: 'testee'}, + private: false, // PUBLIC + properties: { + path: 'popup', + cssClass: 'testee', + }, + }); + + // open the popup + const popupOpenerPagePO = await PopupOpenerPagePO.openInNewTab('app1'); + await popupOpenerPagePO.enterQualifier({component: 'testee'}); + await popupOpenerPagePO.clickOpen(); + + // expect popup to display + const popupPagePO = new PopupPagePO('testee'); + await expect(await popupPagePO.isDisplayed()).toBe(true); + }); + + it('should navigate to own private popups', async () => { + await appPO.navigateTo({microfrontendSupport: true}); + + // register testee popup + const registerCapabilityPagePO = await RegisterWorkbenchCapabilityPagePO.openInNewTab('app1'); + await registerCapabilityPagePO.registerCapability({ + type: 'popup', + qualifier: {component: 'testee'}, + private: true, // PRIVATE + properties: { + path: 'popup', + cssClass: 'testee', + }, + }); + + // open the popup + const popupOpenerPagePO = await PopupOpenerPagePO.openInNewTab('app1'); + await popupOpenerPagePO.enterQualifier({component: 'testee'}); + await popupOpenerPagePO.clickOpen(); + + // expect popup to display + const popupPagePO = new PopupPagePO('testee'); + await expect(await popupPagePO.isDisplayed()).toBe(true); + }); + + it('should not navigate to private popups of other apps', async () => { + await appPO.navigateTo({microfrontendSupport: true}); + + // register testee popup as private popup in app 2 + const registerCapabilityPagePO = await RegisterWorkbenchCapabilityPagePO.openInNewTab('app2'); + await registerCapabilityPagePO.registerCapability({ + type: 'popup', + qualifier: {component: 'testee'}, + private: true, // PRIVATE + properties: { + path: 'popup', + cssClass: 'testee', + }, + }); + + // register popup intention in app 1 + const registerIntentionPagePO = await RegisterWorkbenchIntentionPagePO.openInNewTab('app1'); + await registerIntentionPagePO.registerIntention({type: 'popup', qualifier: {component: 'testee'}}); + + // open the popup + const popupOpenerPagePO = await PopupOpenerPagePO.openInNewTab('app1'); + await popupOpenerPagePO.enterQualifier({component: 'testee'}); + await expectPromise(popupOpenerPagePO.clickOpen()).toReject(/NullProviderError/); + + // expect popup not to display + const popupPagePO = new PopupPagePO('testee'); + await expect(await popupPagePO.isPresent()).toBe(false); + }); + + it('should navigate to public popups of other apps', async () => { + await appPO.navigateTo({microfrontendSupport: true}); + + // register testee popup as public popup in app 2 + const registerCapabilityPagePO = await RegisterWorkbenchCapabilityPagePO.openInNewTab('app2'); + await registerCapabilityPagePO.registerCapability({ + type: 'popup', + qualifier: {component: 'testee'}, + private: false, // PUBLIC + properties: { + path: 'popup', + cssClass: 'testee', + }, + }); + + // register popup intention in app 1 + const registerIntentionPagePO = await RegisterWorkbenchIntentionPagePO.openInNewTab('app1'); + await registerIntentionPagePO.registerIntention({type: 'popup', qualifier: {component: 'testee'}}); + + // open the popup + const popupOpenerPagePO = await PopupOpenerPagePO.openInNewTab('app1'); + await popupOpenerPagePO.enterQualifier({component: 'testee'}); + await popupOpenerPagePO.clickOpen(); + + // expect popup to display + const popupPagePO = new PopupPagePO('testee'); + await expect(await popupPagePO.isDisplayed()).toBe(true); + }); + + it('should not navigate to public popups of other apps if missing the intention', async () => { + await appPO.navigateTo({microfrontendSupport: true}); + + // register testee popup as public popup in app 2 + const registerCapabilityPagePO = await RegisterWorkbenchCapabilityPagePO.openInNewTab('app2'); + await registerCapabilityPagePO.registerCapability({ + type: 'popup', + qualifier: {component: 'testee'}, + private: false, // PUBLIC + properties: { + path: 'popup', + cssClass: 'testee', + }, + }); + + // open the popup + const popupOpenerPagePO = await PopupOpenerPagePO.openInNewTab('app1'); + await popupOpenerPagePO.enterQualifier({component: 'testee'}); + await expectPromise(popupOpenerPagePO.clickOpen()).toReject(/NotQualifiedError/); + + // expect popup not to display + const popupPagePO = new PopupPagePO('testee'); + await expect(await popupPagePO.isPresent()).toBe(false); + }); + + it('should throw when the requested popup has no microfrontend path declared', async () => { + await appPO.navigateTo({microfrontendSupport: true}); + + // register testee popups + const registerCapabilityPagePO = await RegisterWorkbenchCapabilityPagePO.openInNewTab('app1'); + await registerCapabilityPagePO.registerCapability({ + type: 'popup', + qualifier: {component: 'testee', path: 'undefined'}, + properties: { + path: '', + cssClass: 'testee', + }, + }); + await registerCapabilityPagePO.registerCapability({ + type: 'popup', + qualifier: {component: 'testee', path: 'null'}, + properties: { + path: '', + cssClass: 'testee', + }, + }); + await registerCapabilityPagePO.registerCapability({ + type: 'popup', + qualifier: {component: 'testee', path: 'empty'}, + properties: { + path: '', + cssClass: 'testee', + }, + }); + + // open the popup with `undefined` as path + const popupOpenerPagePO = await PopupOpenerPagePO.openInNewTab('app1'); + await popupOpenerPagePO.enterQualifier({component: 'testee', path: 'undefined'}); + await expectPromise(popupOpenerPagePO.clickOpen()).toReject(/PopupProviderError/); + + // expect popup not to display + await expect(await new PopupPagePO('testee').isPresent()).toBe(false); + + // open the popup with `null` as path + await popupOpenerPagePO.enterQualifier({component: 'testee', path: 'null'}); + await expectPromise(popupOpenerPagePO.clickOpen()).toReject(/PopupProviderError/); + + // expect popup not to display + await expect(await new PopupPagePO('testee').isPresent()).toBe(false); + + // open the popup with `empty` as path + await popupOpenerPagePO.enterQualifier({component: 'testee', path: 'empty'}); + await popupOpenerPagePO.clickOpen(); + + // expect popup to display + await expect(await new PopupPagePO('testee').isPresent()).toBe(false); + + const popupId = await new RouterOutletPO().resolveRouterOutletName('e2e-popup', 'testee'); + await WebdriverExecutionContexts.switchToIframe(popupId); + await assertPageToDisplay($('app-root')); + }); + + it('should not throw if another app provides an equivalent but private popup capability', async () => { + await appPO.navigateTo({microfrontendSupport: true}); + + // register testee popups + const registerCapabilityPage1PO = await RegisterWorkbenchCapabilityPagePO.openInNewTab('app1'); + await registerCapabilityPage1PO.registerCapability({ + type: 'popup', + qualifier: {component: 'testee'}, + properties: { + path: 'popup', + cssClass: 'testee', + }, + }); + const registerCapabilityPage2PO = await RegisterWorkbenchCapabilityPagePO.openInNewTab('app2'); + await registerCapabilityPage2PO.registerCapability({ + type: 'popup', + qualifier: {component: 'testee'}, + private: true, // PRIVATE + properties: { + path: 'popup', + cssClass: 'testee', + }, + }); + const registerIntentionPage2PO = await RegisterWorkbenchIntentionPagePO.openInNewTab('app1'); + await registerIntentionPage2PO.registerIntention({ + type: 'popup', + qualifier: {component: 'testee'}, + }); + + // open the popup + const popupOpenerPagePO = await PopupOpenerPagePO.openInNewTab('app1'); + await popupOpenerPagePO.enterQualifier({component: 'testee'}); + await popupOpenerPagePO.clickOpen(); + + // expect popup to display + const popupPagePO = new PopupPagePO('testee'); + await expect(await popupPagePO.isDisplayed()).toBe(true); + + // expect the popup of this app to display + await expect((await popupPagePO.getPopupCapability()).metadata.appSymbolicName).toEqual('workbench-client-testing-app1'); + }); + + it('should not throw if another app provides an equivalent public popup capability if not declared an intention', async () => { + await appPO.navigateTo({microfrontendSupport: true}); + + // register testee popups + const registerCapabilityPage1PO = await RegisterWorkbenchCapabilityPagePO.openInNewTab('app1'); + await registerCapabilityPage1PO.registerCapability({ + type: 'popup', + qualifier: {component: 'testee'}, + properties: { + path: 'popup', + cssClass: 'testee', + }, + }); + const registerCapabilityPage2PO = await RegisterWorkbenchCapabilityPagePO.openInNewTab('app2'); + await registerCapabilityPage2PO.registerCapability({ + type: 'popup', + qualifier: {component: 'testee'}, + private: false, // PUBLIC + properties: { + path: 'popup', + cssClass: 'testee', + }, + }); + + // open the popup + const popupOpenerPagePO = await PopupOpenerPagePO.openInNewTab('app1'); + await popupOpenerPagePO.enterQualifier({component: 'testee'}); + await popupOpenerPagePO.clickOpen(); + + // expect popup to display + const popupPagePO = new PopupPagePO('testee'); + await expect(await popupPagePO.isDisplayed()).toBe(true); + + // expect the popup of this app to display + await expect((await popupPagePO.getPopupCapability()).metadata.appSymbolicName).toEqual('workbench-client-testing-app1'); + }); + + it('should throw if another app provides an equivalent public popup capability', async () => { + await appPO.navigateTo({microfrontendSupport: true}); + + // register testee popups + const registerCapabilityPage1PO = await RegisterWorkbenchCapabilityPagePO.openInNewTab('app1'); + await registerCapabilityPage1PO.registerCapability({ + type: 'popup', + qualifier: {component: 'testee'}, + properties: { + path: 'popup', + cssClass: 'testee', + }, + }); + const registerCapabilityPage2PO = await RegisterWorkbenchCapabilityPagePO.openInNewTab('app2'); + await registerCapabilityPage2PO.registerCapability({ + type: 'popup', + qualifier: {component: 'testee'}, + private: false, // PUBLIC + properties: { + path: 'popup', + cssClass: 'testee', + }, + }); + const registerIntentionPage2PO = await RegisterWorkbenchIntentionPagePO.openInNewTab('app1'); + await registerIntentionPage2PO.registerIntention({ + type: 'popup', + qualifier: {component: 'testee'}, + }); + + // open the popup + const popupOpenerPagePO = await PopupOpenerPagePO.openInNewTab('app1'); + await popupOpenerPagePO.enterQualifier({component: 'testee'}); + await expectPromise(popupOpenerPagePO.clickOpen()).toReject(/MultiProviderError/); + + // expect popup not to display + const popupPagePO = new PopupPagePO('testee'); + await expect(await popupPagePO.isPresent()).toBe(false); + }); + + it('should throw if multiple popup providers match the qualifier', async () => { + await appPO.navigateTo({microfrontendSupport: true}); + + // register testee popups + const registerCapabilityPagePO = await RegisterWorkbenchCapabilityPagePO.openInNewTab('app1'); + await registerCapabilityPagePO.registerCapability({ + type: 'popup', + qualifier: {component: 'testee'}, + properties: { + path: 'popup', + cssClass: 'testee', + }, + }); + await registerCapabilityPagePO.registerCapability({ + type: 'popup', + qualifier: {component: 'testee'}, + properties: { + path: 'popup', + cssClass: 'testee', + }, + }); + + // open the popup + const popupOpenerPagePO = await PopupOpenerPagePO.openInNewTab('app1'); + await popupOpenerPagePO.enterQualifier({component: 'testee'}); + await expectPromise(popupOpenerPagePO.clickOpen()).toReject(/MultiProviderError/); + + // expect popup not to display + const popupPagePO = new PopupPagePO('testee'); + await expect(await popupPagePO.isPresent()).toBe(false); + }); +}); diff --git a/projects/scion/e2e-testing/src/workbench-client/popup-size.e2e-spec.ts b/projects/scion/e2e-testing/src/workbench-client/popup-size.e2e-spec.ts new file mode 100644 index 000000000..a2facf417 --- /dev/null +++ b/projects/scion/e2e-testing/src/workbench-client/popup-size.e2e-spec.ts @@ -0,0 +1,442 @@ +/* + * Copyright (c) 2018-2019 Swiss Federal Railways + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import { AppPO } from '../app.po'; +import { PopupOpenerPagePO } from './page-object/popup-opener-page.po'; +import { PopupPagePO } from './page-object/popup-page.po'; +import { consumeBrowserLog } from '../helper/testing.util'; +import { installSeleniumWebDriverClickFix } from '../helper/selenium-webdriver-click-fix'; +import { RegisterWorkbenchCapabilityPagePO } from './page-object/register-workbench-capability-page.po'; + +describe('Workbench Popup', () => { + + const appPO = new AppPO(); + + installSeleniumWebDriverClickFix(); + + beforeEach(async () => consumeBrowserLog()); + + it('should size the overlay as configured in the popup capability', async () => { + await appPO.navigateTo({microfrontendSupport: true}); + + // register testee popup + const registerCapabilityPagePO = await RegisterWorkbenchCapabilityPagePO.openInNewTab('app1'); + await registerCapabilityPagePO.registerCapability({ + type: 'popup', + qualifier: {component: 'testee'}, + properties: { + path: 'popup', + cssClass: 'testee', + size: { + width: '350px', + height: '450px', + }, + }, + }); + + // open the popup + const popupOpenerPagePO = await PopupOpenerPagePO.openInNewTab('app1'); + await popupOpenerPagePO.enterQualifier({component: 'testee'}); + await popupOpenerPagePO.clickOpen(); + + const popupPagePO = new PopupPagePO('testee'); + await expect(await popupPagePO.popupPO.getClientRect()).toEqual(jasmine.objectContaining({ + width: 350, + height: 450, + })); + }); + + describe('overlay size constraint', () => { + it('should not grow beyond the preferred overlay height', async () => { + await appPO.navigateTo({microfrontendSupport: true}); + + // register testee popup + const registerCapabilityPagePO = await RegisterWorkbenchCapabilityPagePO.openInNewTab('app1'); + await registerCapabilityPagePO.registerCapability({ + type: 'popup', + qualifier: {component: 'testee'}, + properties: { + path: 'popup', + cssClass: 'testee', + size: { + height: '400px', + }, + }, + }); + + // open the popup + const popupOpenerPagePO = await PopupOpenerPagePO.openInNewTab('app1'); + await popupOpenerPagePO.enterQualifier({component: 'testee'}); + await popupOpenerPagePO.clickOpen(); + + const popupPagePO = new PopupPagePO('testee'); + await popupPagePO.enterComponentSize({ + width: '600px', + height: '800px', + }); + + await expect(await popupPagePO.popupPO.getClientRect()).toEqual(jasmine.objectContaining({ + width: 600, + height: 400, + })); + await expect(await popupPagePO.getSize()).toEqual({ + width: 600, + height: 800, + }); + await expect(await popupPagePO.popupPO.hasVerticalOverflow()).toBe(true); + await expect(await popupPagePO.popupPO.hasHorizontalOverflow()).toBe(false); + }); + + it('should not grow beyond the preferred overlay width', async () => { + await appPO.navigateTo({microfrontendSupport: true}); + + // register testee popup + const registerCapabilityPagePO = await RegisterWorkbenchCapabilityPagePO.openInNewTab('app1'); + await registerCapabilityPagePO.registerCapability({ + type: 'popup', + qualifier: {component: 'testee'}, + properties: { + path: 'popup', + cssClass: 'testee', + size: { + width: '400px', + }, + }, + }); + + // open the popup + const popupOpenerPagePO = await PopupOpenerPagePO.openInNewTab('app1'); + await popupOpenerPagePO.enterQualifier({component: 'testee'}); + await popupOpenerPagePO.clickOpen(); + + const popupPagePO = new PopupPagePO('testee'); + await popupPagePO.enterComponentSize({ + width: '600px', + height: '800px', + }); + + await expect(await popupPagePO.popupPO.getClientRect()).toEqual(jasmine.objectContaining({ + width: 400, + height: 800, + })); + await expect(await popupPagePO.getSize()).toEqual({ + width: 600, + height: 800, + }); + await expect(await popupPagePO.popupPO.hasVerticalOverflow()).toBe(false); + await expect(await popupPagePO.popupPO.hasHorizontalOverflow()).toBe(true); + }); + + it('should not shrink below the preferred overlay height', async () => { + await appPO.navigateTo({microfrontendSupport: true}); + + // register testee popup + const registerCapabilityPagePO = await RegisterWorkbenchCapabilityPagePO.openInNewTab('app1'); + await registerCapabilityPagePO.registerCapability({ + type: 'popup', + qualifier: {component: 'testee'}, + properties: { + path: 'popup', + cssClass: 'testee', + size: { + height: '400px', + }, + }, + }); + + // open the popup + const popupOpenerPagePO = await PopupOpenerPagePO.openInNewTab('app1'); + await popupOpenerPagePO.enterQualifier({component: 'testee'}); + await popupOpenerPagePO.clickOpen(); + + const popupPagePO = new PopupPagePO('testee'); + await popupPagePO.enterComponentSize({ + width: '200px', + height: '250px', + }); + + await expect(await popupPagePO.popupPO.getClientRect()).toEqual(jasmine.objectContaining({ + width: 200, + height: 400, + })); + await expect(await popupPagePO.getSize()).toEqual({ + width: 200, + height: 250, + }); + await expect(await popupPagePO.popupPO.hasVerticalOverflow()).toBe(false); + await expect(await popupPagePO.popupPO.hasHorizontalOverflow()).toBe(false); + }); + + it('should not shrink below the preferred overlay width', async () => { + await appPO.navigateTo({microfrontendSupport: true}); + + // register testee popup + const registerCapabilityPagePO = await RegisterWorkbenchCapabilityPagePO.openInNewTab('app1'); + await registerCapabilityPagePO.registerCapability({ + type: 'popup', + qualifier: {component: 'testee'}, + properties: { + path: 'popup', + cssClass: 'testee', + size: { + width: '400px', + }, + }, + }); + + // open the popup + const popupOpenerPagePO = await PopupOpenerPagePO.openInNewTab('app1'); + await popupOpenerPagePO.enterQualifier({component: 'testee'}); + await popupOpenerPagePO.clickOpen(); + + const popupPagePO = new PopupPagePO('testee'); + await popupPagePO.enterComponentSize({ + width: '200px', + height: '250px', + }); + + await expect(await popupPagePO.popupPO.getClientRect()).toEqual(jasmine.objectContaining({ + width: 400, + height: 250, + })); + await expect(await popupPagePO.getSize()).toEqual({ + width: 200, + height: 250, + }); + await expect(await popupPagePO.popupPO.hasVerticalOverflow()).toBe(false); + await expect(await popupPagePO.popupPO.hasHorizontalOverflow()).toBe(false); + }); + }); + + describe('overlay maximal size constraint', () => { + it('should grow to the maximum height of the overlay', async () => { + await appPO.navigateTo({microfrontendSupport: true}); + + // register testee popup + const registerCapabilityPagePO = await RegisterWorkbenchCapabilityPagePO.openInNewTab('app1'); + await registerCapabilityPagePO.registerCapability({ + type: 'popup', + qualifier: {component: 'testee'}, + properties: { + path: 'popup', + cssClass: 'testee', + size: { + maxHeight: '400px', + }, + }, + }); + + // open the popup + const popupOpenerPagePO = await PopupOpenerPagePO.openInNewTab('app1'); + await popupOpenerPagePO.enterQualifier({component: 'testee'}); + await popupOpenerPagePO.clickOpen(); + + const popupPagePO = new PopupPagePO('testee'); + + // Set the component height to 300px (max overlay height is 400px) + await popupPagePO.enterComponentSize({ + height: '300px', + }); + await expect(await popupPagePO.popupPO.getClientRect()).toEqual(jasmine.objectContaining({ + height: 300, + width: jasmine.any(Number), + })); + await expect(await popupPagePO.getSize()).toEqual({ + height: 300, + width: jasmine.any(Number), + }); + await expect(await popupPagePO.popupPO.hasVerticalOverflow()).toBe(false); + await expect(await popupPagePO.popupPO.hasHorizontalOverflow()).toBe(false); + + // Set the component height to 500px (max overlay height is 400px) + await popupPagePO.enterComponentSize({ + height: '500px', + }); + await expect(await popupPagePO.popupPO.getClientRect()).toEqual(jasmine.objectContaining({ + height: 400, + width: jasmine.any(Number), + })); + await expect(await popupPagePO.getSize()).toEqual({ + height: 500, + width: jasmine.any(Number), + }); + await expect(await popupPagePO.popupPO.hasVerticalOverflow()).toBe(true); + await expect(await popupPagePO.popupPO.hasHorizontalOverflow()).toBe(false); + }); + + it('should grow to the maximum width of the overlay', async () => { + await appPO.navigateTo({microfrontendSupport: true}); + + // register testee popup + const registerCapabilityPagePO = await RegisterWorkbenchCapabilityPagePO.openInNewTab('app1'); + await registerCapabilityPagePO.registerCapability({ + type: 'popup', + qualifier: {component: 'testee'}, + properties: { + path: 'popup', + cssClass: 'testee', + size: { + maxWidth: '400px', + }, + }, + }); + + // open the popup + const popupOpenerPagePO = await PopupOpenerPagePO.openInNewTab('app1'); + await popupOpenerPagePO.enterQualifier({component: 'testee'}); + await popupOpenerPagePO.clickOpen(); + + const popupPagePO = new PopupPagePO('testee'); + + // Set the component width to 300px (max overlay width is 400px) + await popupPagePO.enterComponentSize({ + width: '300px', + }); + await expect(await popupPagePO.popupPO.getClientRect()).toEqual(jasmine.objectContaining({ + width: 300, + height: jasmine.any(Number), + })); + await expect(await popupPagePO.getSize()).toEqual({ + width: 300, + height: jasmine.any(Number), + }); + await expect(await popupPagePO.popupPO.hasVerticalOverflow()).toBe(false); + await expect(await popupPagePO.popupPO.hasHorizontalOverflow()).toBe(false); + + // Set the component width to 500px (max overlay width is 400px) + await popupPagePO.enterComponentSize({ + width: '500px', + }); + await expect(await popupPagePO.popupPO.getClientRect()).toEqual(jasmine.objectContaining({ + width: 400, + height: jasmine.any(Number), + })); + await expect(await popupPagePO.getSize()).toEqual({ + width: 500, + height: jasmine.any(Number), + }); + await expect(await popupPagePO.popupPO.hasVerticalOverflow()).toBe(false); + await expect(await popupPagePO.popupPO.hasHorizontalOverflow()).toBe(true); + }); + }); + + describe('overlay minimal size constraint', () => { + it('should not shrink below the minimum height of the overlay', async () => { + await appPO.navigateTo({microfrontendSupport: true}); + + // register testee popup + const registerCapabilityPagePO = await RegisterWorkbenchCapabilityPagePO.openInNewTab('app1'); + await registerCapabilityPagePO.registerCapability({ + type: 'popup', + qualifier: {component: 'testee'}, + properties: { + path: 'popup', + cssClass: 'testee', + size: { + minHeight: '400px', + }, + }, + }); + + // open the popup + const popupOpenerPagePO = await PopupOpenerPagePO.openInNewTab('app1'); + await popupOpenerPagePO.enterQualifier({component: 'testee'}); + await popupOpenerPagePO.clickOpen(); + + const popupPagePO = new PopupPagePO('testee'); + + // Set the component height to 300px (min overlay height is 400px) + await popupPagePO.enterComponentSize({ + height: '300px', + }); + await expect(await popupPagePO.popupPO.getClientRect()).toEqual(jasmine.objectContaining({ + height: 400, + width: jasmine.any(Number), + })); + await expect(await popupPagePO.getSize()).toEqual({ + height: 300, + width: jasmine.any(Number), + }); + await expect(await popupPagePO.popupPO.hasVerticalOverflow()).toBe(false); + await expect(await popupPagePO.popupPO.hasHorizontalOverflow()).toBe(false); + + // Set the component height to 500px (min overlay height is 400px) + await popupPagePO.enterComponentSize({ + height: '500px', + }); + await expect(await popupPagePO.popupPO.getClientRect()).toEqual(jasmine.objectContaining({ + height: 500, + width: jasmine.any(Number), + })); + await expect(await popupPagePO.getSize()).toEqual({ + height: 500, + width: jasmine.any(Number), + }); + await expect(await popupPagePO.popupPO.hasVerticalOverflow()).toBe(false); + await expect(await popupPagePO.popupPO.hasHorizontalOverflow()).toBe(false); + }); + + it('should not shrink below the minimum width of the overlay', async () => { + await appPO.navigateTo({microfrontendSupport: true}); + + // register testee popup + const registerCapabilityPagePO = await RegisterWorkbenchCapabilityPagePO.openInNewTab('app1'); + await registerCapabilityPagePO.registerCapability({ + type: 'popup', + qualifier: {component: 'testee'}, + properties: { + path: 'popup', + cssClass: 'testee', + size: { + minWidth: '400px', + }, + }, + }); + + // open the popup + const popupOpenerPagePO = await PopupOpenerPagePO.openInNewTab('app1'); + await popupOpenerPagePO.enterQualifier({component: 'testee'}); + await popupOpenerPagePO.clickOpen(); + + const popupPagePO = new PopupPagePO('testee'); + + // Set the component width to 300px (min overlay width is 400px) + await popupPagePO.enterComponentSize({ + width: '300px', + }); + await expect(await popupPagePO.popupPO.getClientRect()).toEqual(jasmine.objectContaining({ + width: 400, + height: jasmine.any(Number), + })); + await expect(await popupPagePO.getSize()).toEqual({ + width: 300, + height: jasmine.any(Number), + }); + await expect(await popupPagePO.popupPO.hasVerticalOverflow()).toBe(false); + await expect(await popupPagePO.popupPO.hasHorizontalOverflow()).toBe(false); + + // Set the component width to 500px (min overlay width is 400px) + await popupPagePO.enterComponentSize({ + width: '500px', + }); + await expect(await popupPagePO.popupPO.getClientRect()).toEqual(jasmine.objectContaining({ + width: 500, + height: jasmine.any(Number), + })); + await expect(await popupPagePO.getSize()).toEqual({ + width: 500, + height: jasmine.any(Number), + }); + await expect(await popupPagePO.popupPO.hasVerticalOverflow()).toBe(false); + await expect(await popupPagePO.popupPO.hasHorizontalOverflow()).toBe(false); + }); + }); +}); + diff --git a/projects/scion/e2e-testing/src/workbench-client/popup.e2e-spec.ts b/projects/scion/e2e-testing/src/workbench-client/popup.e2e-spec.ts new file mode 100644 index 000000000..a11b756a3 --- /dev/null +++ b/projects/scion/e2e-testing/src/workbench-client/popup.e2e-spec.ts @@ -0,0 +1,589 @@ +/* + * Copyright (c) 2018-2019 Swiss Federal Railways + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import { AppPO } from '../app.po'; +import { PopupOpenerPagePO } from './page-object/popup-opener-page.po'; +import { consumeBrowserLog, sendKeys } from '../helper/testing.util'; +import { RegisterWorkbenchCapabilityPagePO } from './page-object/register-workbench-capability-page.po'; +import { installSeleniumWebDriverClickFix } from '../helper/selenium-webdriver-click-fix'; +import { PopupPagePO } from './page-object/popup-page.po'; +import { Key } from 'protractor'; + +describe('Workbench Popup', () => { + + const appPO = new AppPO(); + + installSeleniumWebDriverClickFix(); + + beforeEach(async () => consumeBrowserLog()); + + it('should, by default, open in the north of the anchor', async () => { + await appPO.navigateTo({microfrontendSupport: true}); + + // register testee popup + const registerCapabilityPagePO = await RegisterWorkbenchCapabilityPagePO.openInNewTab('app1'); + await registerCapabilityPagePO.registerCapability({ + type: 'popup', + qualifier: {component: 'testee'}, + properties: { + path: 'popup', + cssClass: 'testee', + size: {width: '100px', height: '100px'}, + }, + }); + + // open the popup + const popupOpenerPagePO = await PopupOpenerPagePO.openInNewTab('app1'); + await popupOpenerPagePO.enterQualifier({component: 'testee'}); + await popupOpenerPagePO.clickOpen(); + + const popupPO = await appPO.findPopup({cssClass: 'testee'}); + await expect(await popupPO.isDisplayed()).toBe(true); + await expect(await popupPO.getAlign()).toEqual('north'); + }); + + it('should open in the north of the anchor', async () => { + await appPO.navigateTo({microfrontendSupport: true}); + + // register testee popup + const registerCapabilityPagePO = await RegisterWorkbenchCapabilityPagePO.openInNewTab('app1'); + await registerCapabilityPagePO.registerCapability({ + type: 'popup', + qualifier: {component: 'testee'}, + properties: { + path: 'popup', + cssClass: 'testee', + size: {width: '100px', height: '100px'}, + }, + }); + + // open the popup + const popupOpenerPagePO = await PopupOpenerPagePO.openInNewTab('app1'); + await popupOpenerPagePO.enterQualifier({component: 'testee'}); + await popupOpenerPagePO.selectAlign('north'); + await popupOpenerPagePO.clickOpen(); + + const popupPO = await appPO.findPopup({cssClass: 'testee'}); + await expect(await popupPO.isDisplayed()).toBe(true); + await expect(await popupPO.getAlign()).toEqual('north'); + }); + + it('should open in the south of the anchor', async () => { + await appPO.navigateTo({microfrontendSupport: true}); + + // register testee popup + const registerCapabilityPagePO = await RegisterWorkbenchCapabilityPagePO.openInNewTab('app1'); + await registerCapabilityPagePO.registerCapability({ + type: 'popup', + qualifier: {component: 'testee'}, + properties: { + path: 'popup', + cssClass: 'testee', + size: {width: '100px', height: '100px'}, + }, + }); + + // open the popup + const popupOpenerPagePO = await PopupOpenerPagePO.openInNewTab('app1'); + await popupOpenerPagePO.enterQualifier({component: 'testee'}); + await popupOpenerPagePO.selectAlign('south'); + + await popupOpenerPagePO.clickOpen(); + + const popupPO = await appPO.findPopup({cssClass: 'testee'}); + await expect(await popupPO.isDisplayed()).toBe(true); + await expect(await popupPO.getAlign()).toEqual('south'); + }); + + it('should open in the east of the anchor', async () => { + await appPO.navigateTo({microfrontendSupport: true}); + + // register testee popup + const registerCapabilityPagePO = await RegisterWorkbenchCapabilityPagePO.openInNewTab('app1'); + await registerCapabilityPagePO.registerCapability({ + type: 'popup', + qualifier: {component: 'testee'}, + properties: { + path: 'popup', + cssClass: 'testee', + size: {width: '100px', height: '100px'}, + }, + }); + + // open the popup + const popupOpenerPagePO = await PopupOpenerPagePO.openInNewTab('app1'); + await popupOpenerPagePO.enterQualifier({component: 'testee'}); + await popupOpenerPagePO.selectAlign('east'); + await popupOpenerPagePO.clickOpen(); + + const popupPO = await appPO.findPopup({cssClass: 'testee'}); + await expect(await popupPO.isDisplayed()).toBe(true); + await expect(await popupPO.getAlign()).toEqual('east'); + }); + + it('should open in the west of the anchor', async () => { + await appPO.navigateTo({microfrontendSupport: true}); + + // register testee popup + const registerCapabilityPagePO = await RegisterWorkbenchCapabilityPagePO.openInNewTab('app1'); + await registerCapabilityPagePO.registerCapability({ + type: 'popup', + qualifier: {component: 'testee'}, + properties: { + path: 'popup', + cssClass: 'testee', + size: {width: '100px', height: '100px'}, + }, + }); + + // open the popup + const popupOpenerPagePO = await PopupOpenerPagePO.openInNewTab('app1'); + await popupOpenerPagePO.enterQualifier({component: 'testee'}); + await popupOpenerPagePO.selectAlign('west'); + await popupOpenerPagePO.clickOpen(); + + const popupPO = await appPO.findPopup({cssClass: 'testee'}); + await expect(await popupPO.isDisplayed()).toBe(true); + await expect(await popupPO.getAlign()).toEqual('west'); + }); + + it('should allow closing the popup and returning a value to the popup opener', async () => { + await appPO.navigateTo({microfrontendSupport: true}); + + // register testee popup + const registerCapabilityPagePO = await RegisterWorkbenchCapabilityPagePO.openInNewTab('app1'); + await registerCapabilityPagePO.registerCapability({ + type: 'popup', + qualifier: {component: 'testee'}, + properties: { + path: 'popup', + cssClass: 'testee', + }, + }); + + // open the popup + const popupOpenerPagePO = await PopupOpenerPagePO.openInNewTab('app1'); + await popupOpenerPagePO.enterQualifier({component: 'testee'}); + await popupOpenerPagePO.clickOpen(); + + const popupPagePO = new PopupPagePO('testee'); + await popupPagePO.clickClose({returnValue: 'RETURN VALUE'}); + await expect(await popupOpenerPagePO.getPopupCloseAction()).toEqual({type: 'closed-with-value', value: 'RETURN VALUE'}); + }); + + it('should allow closing the popup with an error', async () => { + await appPO.navigateTo({microfrontendSupport: true}); + + // register testee popup + const registerCapabilityPagePO = await RegisterWorkbenchCapabilityPagePO.openInNewTab('app1'); + await registerCapabilityPagePO.registerCapability({ + type: 'popup', + qualifier: {component: 'testee'}, + properties: { + path: 'popup', + cssClass: 'testee', + }, + }); + + // open the popup + const popupOpenerPagePO = await PopupOpenerPagePO.openInNewTab('app1'); + await popupOpenerPagePO.enterQualifier({component: 'testee'}); + await popupOpenerPagePO.clickOpen(); + + const popupPagePO = new PopupPagePO('testee'); + await popupPagePO.clickClose({returnValue: 'ERROR', closeWithError: true}); + + await expect(await popupOpenerPagePO.getPopupCloseAction()).toEqual({type: 'closed-with-error', value: '[500] ERROR'}); + }); + + it('should stick the popup to the HTMLElement anchor when moving the anchor element', async () => { + await appPO.navigateTo({microfrontendSupport: true}); + + // register testee popup + const registerCapabilityPagePO = await RegisterWorkbenchCapabilityPagePO.openInNewTab('app1'); + await registerCapabilityPagePO.registerCapability({ + type: 'popup', + qualifier: {component: 'testee'}, + properties: { + path: 'popup', + cssClass: 'testee', + size: {width: '100px', height: '100px'}, + }, + }); + + // open the popup + const popupOpenerPagePO = await PopupOpenerPagePO.openInNewTab('app1'); + await popupOpenerPagePO.enterQualifier({component: 'testee'}); + await popupOpenerPagePO.enterCloseStrategy({closeOnFocusLost: false}); + await popupOpenerPagePO.selectAlign('north'); + await popupOpenerPagePO.clickOpen(); + + const popupPO = appPO.findPopup({cssClass: 'testee'}); + + // capture current popup and anchor location + const anchorClientRect1 = await popupOpenerPagePO.getAnchorElementClientRect(); + const popupClientRect1 = await popupPO.getClientRect(); + + // expand a collapsed panel to move the popup anchor downward + await popupOpenerPagePO.expandAnchorPanel(); + + const anchorClientRect2 = await popupOpenerPagePO.getAnchorElementClientRect(); + const popupClientRect2 = await popupPO.getClientRect(); + const xDelta = anchorClientRect2.left - anchorClientRect1.left; + const yDelta = anchorClientRect2.top - anchorClientRect1.top; + + // assert the anchor to moved downward + await expect(anchorClientRect2.top).toBeGreaterThan(anchorClientRect1.top); + await expect(anchorClientRect2.left).toEqual(anchorClientRect1.left); + + // assert the popup location + await expect(popupClientRect2.top).toEqual(popupClientRect1.top + yDelta); + await expect(popupClientRect2.left).toEqual(popupClientRect1.left + xDelta); + + // collapse the panel to move the popup anchor upward + await popupOpenerPagePO.collapseAnchorPanel(); + const popupClientRect3 = await popupPO.getClientRect(); + + // assert the popup location + await expect(popupClientRect3.top).toEqual(popupClientRect1.top); + await expect(popupClientRect3.left).toEqual(popupClientRect1.left); + }); + + it('should allow repositioning the popup if using a coordinate anchor', async () => { + await appPO.navigateTo({microfrontendSupport: true}); + + // register testee popup + const registerCapabilityPagePO = await RegisterWorkbenchCapabilityPagePO.openInNewTab('app1'); + await registerCapabilityPagePO.registerCapability({ + type: 'popup', + qualifier: {component: 'testee'}, + properties: { + path: 'popup', + cssClass: 'testee', + size: {width: '100px', height: '100px'}, + }, + }); + + // open the popup + const popupOpenerPagePO = await PopupOpenerPagePO.openInNewTab('app1'); + await popupOpenerPagePO.enterQualifier({component: 'testee'}); + await popupOpenerPagePO.enterCloseStrategy({closeOnFocusLost: false}); + await popupOpenerPagePO.selectAnchor('coordinate'); + await popupOpenerPagePO.enterAnchorCoordinate({x: 150, y: 150, width: 2, height: 0}); + await popupOpenerPagePO.selectAlign('south'); + await popupOpenerPagePO.clickOpen(); + + const popupPO = appPO.findPopup({cssClass: 'testee'}); + + // capture current popup and anchor location + const popupClientRectInitial = await popupPO.getClientRect(); + + // move the anachor + await popupOpenerPagePO.enterAnchorCoordinate({x: 200, y: 300, width: 2, height: 0}); + + // assert the popup location + await expect(await popupPO.getClientRect()).toEqual(jasmine.objectContaining({ + left: popupClientRectInitial.left + 50, + top: popupClientRectInitial.top + 150, + })); + + // move the anchor to its initial position + await popupOpenerPagePO.enterAnchorCoordinate({x: 150, y: 150, width: 2, height: 0}); + + // assert the popup location + await expect(await popupPO.getClientRect()).toEqual(popupClientRectInitial); + }); + + describe('view context', () => { + + it('should hide the popup when its contextual view (if any) is deactivated, and then display the popup again when activating it', async () => { + await appPO.navigateTo({microfrontendSupport: true}); + + // register testee popup + const registerCapabilityPagePO = await RegisterWorkbenchCapabilityPagePO.openInNewTab('app1'); + await registerCapabilityPagePO.registerCapability({ + type: 'popup', + qualifier: {component: 'testee'}, + properties: { + path: 'popup', + cssClass: 'testee', + }, + }); + + // open the popup + const popupOpenerPagePO = await PopupOpenerPagePO.openInNewTab('app1'); + await popupOpenerPagePO.enterQualifier({component: 'testee'}); + await popupOpenerPagePO.enterCloseStrategy({closeOnFocusLost: false}); + await popupOpenerPagePO.clickOpen(); + + const popupPO = appPO.findPopup({cssClass: 'testee'}); + await expect(await popupPO.isPresent()).toBe(true); + await expect(await popupPO.isDisplayed()).toBe(true); + + // activate another view + await appPO.openNewViewTab(); + await expect(await popupPO.isPresent()).toBe(true); + await expect(await popupPO.isDisplayed()).toBe(false); + + // re-activate the view + await popupOpenerPagePO.viewTabPO.activate(); + await expect(await popupPO.isPresent()).toBe(true); + await expect(await popupPO.isDisplayed()).toBe(true); + }); + + it('should not destroy the popup when its contextual view (if any) is deactivated', async () => { + await appPO.navigateTo({microfrontendSupport: true}); + + // register testee popup + const registerCapabilityPagePO = await RegisterWorkbenchCapabilityPagePO.openInNewTab('app1'); + await registerCapabilityPagePO.registerCapability({ + type: 'popup', + qualifier: {component: 'testee'}, + properties: { + path: 'popup', + cssClass: 'testee', + }, + }); + + // open the popup + const popupOpenerPagePO = await PopupOpenerPagePO.openInNewTab('app1'); + await popupOpenerPagePO.enterQualifier({component: 'testee'}); + await popupOpenerPagePO.enterCloseStrategy({closeOnFocusLost: false}); + await popupOpenerPagePO.clickOpen(); + + const popupPagePO = new PopupPagePO('testee'); + const componentInstanceId = await popupPagePO.getComponentInstanceId(); + await expect(await popupPagePO.isPresent()).toBe(true); + await expect(await popupPagePO.isDisplayed()).toBe(true); + + // activate another view + await appPO.openNewViewTab(); + await expect(await popupPagePO.isPresent()).toBe(true); + await expect(await popupPagePO.isDisplayed()).toBe(false); + + // re-activate the view + await popupOpenerPagePO.viewTabPO.activate(); + await expect(await popupPagePO.isPresent()).toBe(true); + await expect(await popupPagePO.isDisplayed()).toBe(true); + + // expect the component not to be constructed anew + await expect(await popupPagePO.getComponentInstanceId()).toEqual(componentInstanceId); + }); + + it('should bind the popup to the current view, if opened in the context of a view', async () => { + await appPO.navigateTo({microfrontendSupport: true}); + + // register testee popup + const registerCapabilityPagePO = await RegisterWorkbenchCapabilityPagePO.openInNewTab('app1'); + await registerCapabilityPagePO.registerCapability({ + type: 'popup', + qualifier: {component: 'testee'}, + properties: { + path: 'popup', + cssClass: 'testee', + }, + }); + + // open the popup + const popupOpenerPagePO = await PopupOpenerPagePO.openInNewTab('app1'); + await popupOpenerPagePO.enterQualifier({component: 'testee'}); + await popupOpenerPagePO.enterCloseStrategy({closeOnFocusLost: false}); + await popupOpenerPagePO.clickOpen(); + + const popupPO = appPO.findPopup({cssClass: 'testee'}); + await expect(await popupPO.isPresent()).toBe(true); + await expect(await popupPO.isDisplayed()).toBe(true); + + // deactivate the view + await appPO.openNewViewTab(); + await expect(await popupPO.isPresent()).toBe(true); + await expect(await popupPO.isDisplayed()).toBe(false); + + // activate the view again + await popupOpenerPagePO.viewTabPO.activate(); + await expect(await popupPO.isPresent()).toBe(true); + await expect(await popupPO.isDisplayed()).toBe(true); + + // close the view + await popupOpenerPagePO.viewTabPO.close(); + await expect(await popupPO.isPresent()).toBe(false); + await expect(await popupPO.isDisplayed()).toBe(false); + }); + }); + + describe('popup closing', () => { + + it('should close the popup on focus lost ', async () => { + await appPO.navigateTo({microfrontendSupport: true}); + + // register testee popup + const registerCapabilityPagePO = await RegisterWorkbenchCapabilityPagePO.openInNewTab('app1'); + await registerCapabilityPagePO.registerCapability({ + type: 'popup', + qualifier: {component: 'testee'}, + properties: { + path: 'popup', + cssClass: 'testee', + }, + }); + + // open the popup + const popupOpenerPagePO = await PopupOpenerPagePO.openInNewTab('app1'); + await popupOpenerPagePO.enterQualifier({component: 'testee'}); + await popupOpenerPagePO.enterCloseStrategy({closeOnFocusLost: true}); + await popupOpenerPagePO.clickOpen(); + + const popupPagePO = new PopupPagePO('testee'); + await expect(await popupPagePO.popupPO.isPresent()).toBe(true); + await expect(await popupPagePO.popupPO.isDisplayed()).toBe(true); + + await popupOpenerPagePO.viewTabPO.activate(); + + await expect(await popupPagePO.popupPO.isPresent()).toBe(false); + await expect(await popupPagePO.popupPO.isDisplayed()).toBe(false); + }); + + it('should not close the popup on focus lost ', async () => { + await appPO.navigateTo({microfrontendSupport: true}); + + // register testee popup + const registerCapabilityPagePO = await RegisterWorkbenchCapabilityPagePO.openInNewTab('app1'); + await registerCapabilityPagePO.registerCapability({ + type: 'popup', + qualifier: {component: 'testee'}, + properties: { + path: 'popup', + cssClass: 'testee', + }, + }); + + // open the popup + const popupOpenerPagePO = await PopupOpenerPagePO.openInNewTab('app1'); + await popupOpenerPagePO.enterQualifier({component: 'testee'}); + await popupOpenerPagePO.enterCloseStrategy({closeOnFocusLost: false}); + await popupOpenerPagePO.clickOpen(); + + const popupPagePO = new PopupPagePO('testee'); + await expect(await popupPagePO.popupPO.isPresent()).toBe(true); + await expect(await popupPagePO.popupPO.isDisplayed()).toBe(true); + + await popupOpenerPagePO.viewTabPO.activate(); + + await expect(await popupPagePO.popupPO.isPresent()).toBe(true); + await expect(await popupPagePO.popupPO.isDisplayed()).toBe(true); + }); + + it('should close the popup on escape keystroke ', async () => { + await appPO.navigateTo({microfrontendSupport: true}); + + // register testee popup + const registerCapabilityPagePO = await RegisterWorkbenchCapabilityPagePO.openInNewTab('app1'); + await registerCapabilityPagePO.registerCapability({ + type: 'popup', + qualifier: {component: 'testee'}, + properties: { + path: 'popup', + cssClass: 'testee', + }, + }); + + // open the popup + const popupOpenerPagePO = await PopupOpenerPagePO.openInNewTab('app1'); + await popupOpenerPagePO.enterQualifier({component: 'testee'}); + await popupOpenerPagePO.enterCloseStrategy({closeOnEscape: true}); + await popupOpenerPagePO.clickOpen(); + + const popupPage1PO = new PopupPagePO('testee'); + await expect(await popupPage1PO.popupPO.isPresent()).toBe(true); + await expect(await popupPage1PO.popupPO.isDisplayed()).toBe(true); + + await sendKeys(Key.ESCAPE); + + await expect(await popupPage1PO.popupPO.isPresent()).toBe(false); + await expect(await popupPage1PO.popupPO.isDisplayed()).toBe(false); + + // open the popup + await popupOpenerPagePO.enterQualifier({component: 'testee'}); + await popupOpenerPagePO.enterCloseStrategy({closeOnEscape: true}); + await popupOpenerPagePO.clickOpen(); + + const popupPage2PO = new PopupPagePO('testee'); + await expect(await popupPage2PO.popupPO.isPresent()).toBe(true); + await expect(await popupPage2PO.popupPO.isDisplayed()).toBe(true); + + await popupPage2PO.enterReturnValue('explicitly request the focus'); + await sendKeys(Key.ESCAPE); + + await expect(await popupPage2PO.popupPO.isPresent()).toBe(false); + await expect(await popupPage2PO.popupPO.isDisplayed()).toBe(false); + }); + + it('should not close the popup on escape keystroke ', async () => { + await appPO.navigateTo({microfrontendSupport: true}); + + // register testee popup + const registerCapabilityPagePO = await RegisterWorkbenchCapabilityPagePO.openInNewTab('app1'); + await registerCapabilityPagePO.registerCapability({ + type: 'popup', + qualifier: {component: 'testee'}, + properties: { + path: 'popup', + cssClass: 'testee', + }, + }); + + // open the popup + const popupOpenerPagePO = await PopupOpenerPagePO.openInNewTab('app1'); + await popupOpenerPagePO.enterQualifier({component: 'testee'}); + await popupOpenerPagePO.enterCloseStrategy({closeOnEscape: false}); + await popupOpenerPagePO.clickOpen(); + + const popupPagePO = new PopupPagePO('testee'); + await expect(await popupPagePO.popupPO.isPresent()).toBe(true); + await expect(await popupPagePO.popupPO.isDisplayed()).toBe(true); + + await sendKeys(Key.ESCAPE); + + await expect(await popupPagePO.popupPO.isPresent()).toBe(true); + await expect(await popupPagePO.popupPO.isDisplayed()).toBe(true); + }); + + it('should provide the popup\'s capability', async () => { + await appPO.navigateTo({microfrontendSupport: true}); + + // register testee popup + const registerCapabilityPagePO = await RegisterWorkbenchCapabilityPagePO.openInNewTab('app1'); + await registerCapabilityPagePO.registerCapability({ + type: 'popup', + qualifier: {component: 'testee'}, + properties: { + path: 'popup', + cssClass: 'testee', + }, + }); + + // open the popup + const popupOpenerPagePO = await PopupOpenerPagePO.openInNewTab('app1'); + await popupOpenerPagePO.enterQualifier({component: 'testee'}); + await popupOpenerPagePO.clickOpen(); + + // expect the popup of this app to display + const popupPagePO = new PopupPagePO('testee'); + await expect(await popupPagePO.getPopupCapability()).toEqual(jasmine.objectContaining({ + qualifier: {component: 'testee'}, + type: 'popup', + properties: jasmine.objectContaining({ + path: 'popup', + cssClass: ['testee'], + }), + })); + }); + }); +}); diff --git a/projects/scion/e2e-testing/src/workbench/page-object/popup-opener-page.po.ts b/projects/scion/e2e-testing/src/workbench/page-object/popup-opener-page.po.ts index 372df2e89..390f0ec10 100644 --- a/projects/scion/e2e-testing/src/workbench/page-object/popup-opener-page.po.ts +++ b/projects/scion/e2e-testing/src/workbench/page-object/popup-opener-page.po.ts @@ -93,7 +93,6 @@ export class PopupOpenerPagePO { public async enterCssClass(cssClass: string | string[]): Promise { await WebdriverExecutionContexts.switchToDefault(); await assertPageToDisplay(this._pageFinder); - await assertPageToDisplay(this._pageFinder); await enterText(coerceArray(cssClass).join(' '), this._pageFinder.$('input.e2e-class')); } diff --git a/projects/scion/workbench-client/src/lib/popup/workbench-popup-capability.ts b/projects/scion/workbench-client/src/lib/popup/workbench-popup-capability.ts new file mode 100644 index 000000000..3617c730b --- /dev/null +++ b/projects/scion/workbench-client/src/lib/popup/workbench-popup-capability.ts @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2018-2019 Swiss Federal Railways + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import { Capability } from '@scion/microfrontend-platform'; +import { WorkbenchCapabilities } from '../workbench-capabilities.enum'; + +/** + * Represents a microfrontend for display in a workbench popup. + * + * A popup is a visual workbench component for displaying content above other content. + * + * Unlike views, popups are not part of the persistent workbench navigation, meaning that popups do not survive a page reload. + * + * @category Popup + */ +export interface WorkbenchPopupCapability extends Capability { + + type: WorkbenchCapabilities.Popup; + + properties: { + /** + * Specifies the path of the microfrontend to be opened when navigating to this popup capability. + * + * The path is relative to the base URL, as specified in the application manifest. If the + * application does not declare a base URL, it is relative to the origin of the manifest file. + * + * In the path, you can reference qualifier and parameter values in the form of named parameters. + * Named parameters begin with a colon (`:`) followed by the parameter or qualifier name, and are allowed in path segments, query parameters, matrix parameters + * and the fragment part. The popup router will substitute named parameters in the URL accordingly. + * + * In addition to using qualifier and parameter values as named parameters in the URL, params are available in the microfrontend via {@link WorkbenchPopup.params} object. + * + * #### Usage of named parameters in the path: + * ```json + * { + * "type": "popup", + * "qualifier": { + * "entity": "product", + * "id": "*", + * }, + * "requiredParams": ["readonly"], + * "properties": { + * "path": "product/:id?readonly=:readonly", + * ... + * } + * } + * ``` + * + * #### Path parameter example: + * segment/:param1/segment/:param2 + * + * #### Matrix parameter example: + * segment/segment;matrixParam1=:param1;matrixParam2=:param2 + * + * #### Query parameter example: + * segment/segment?queryParam1=:param1&queryParam2=:param2 + */ + path: string; + /** + * Specifies the preferred popup size. + * + * If not set, the popup will adjust its size to the content size reported by the embedded content using {@link PreferredSizeService}. + * Note that the microfrontend may take some time to load, causing the popup to flicker when opened. Therefore, for fixed-sized popups, + * consider declaring the popup size in the popup capability. + */ + size?: PopupSize; + /** + * Specifies CSS class(es) added to the `` and popup overlay element, e.g., to locate the popup in e2e tests. + */ + cssClass?: string | string[]; + }; +} + +/** + * Represents the preferred popup size. + */ +export interface PopupSize { + /** + * Specifies the min-height of the popup. + */ + minHeight?: string; + /** + * Specifies the height of the popup. + */ + height?: string; + /** + * Specifies the max-height of the popup. + */ + maxHeight?: string; + /** + * Specifies the min-width of the popup. + */ + minWidth?: string; + /** + * Specifies the width of the popup. + */ + width?: string; + /** + * Specifies the max-width of the popup. + */ + maxWidth?: string; +} diff --git a/projects/scion/workbench-client/src/lib/popup/workbench-popup-context.ts b/projects/scion/workbench-client/src/lib/popup/workbench-popup-context.ts new file mode 100644 index 000000000..20f252946 --- /dev/null +++ b/projects/scion/workbench-client/src/lib/popup/workbench-popup-context.ts @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2018-2019 Swiss Federal Railways + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import { WorkbenchPopupCapability } from './workbench-popup-capability'; + +/** + * Information about the popup embedding a microfrontend. + * + * This object can be obtained from the {@link ContextService} using the name {@link ɵPOPUP_CONTEXT}. + * + * @docs-private Not public API, intended for internal use only. + * @ignore + */ +export interface ɵPopupContext { // tslint:disable-line:class-name + popupId: string; + params: Map; + capability: WorkbenchPopupCapability; + closeOnFocusLost: boolean; +} + +/** + * Key for obtaining the current popup context using {@link ContextService}. + * + * The popup context is only available to microfrontends loaded in a workbench popup. + * + * @docs-private Not public API, intended for internal use only. + * @ignore + * @see {@link ContextService} + * @see {@link ɵPopupContext} + */ +export const ɵPOPUP_CONTEXT = 'ɵworkbench.popup'; // tslint:disable-line:variable-name diff --git a/projects/scion/workbench-client/src/lib/popup/workbench-popup-initializer.ts b/projects/scion/workbench-client/src/lib/popup/workbench-popup-initializer.ts new file mode 100644 index 000000000..dda89c1f4 --- /dev/null +++ b/projects/scion/workbench-client/src/lib/popup/workbench-popup-initializer.ts @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2018-2019 Swiss Federal Railways + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import { Beans, Initializer } from '@scion/toolkit/bean-manager'; +import { ContextService } from '@scion/microfrontend-platform'; +import { WorkbenchPopup, ɵWorkbenchPopup } from './workbench-popup'; +import { ɵPOPUP_CONTEXT, ɵPopupContext } from './workbench-popup-context'; + +/** + * Registers {@link WorkbenchPopup} in the bean manager if in the context of a workbench popup. + * + * @internal + */ +export class WorkbenchPopupInitializer implements Initializer { + + public async init(): Promise { + const popupContext = await Beans.get(ContextService).lookup<ɵPopupContext>(ɵPOPUP_CONTEXT); + if (popupContext !== null) { + Beans.register(WorkbenchPopup, {useValue: new ɵWorkbenchPopup(popupContext)}); + } + } +} diff --git a/projects/scion/workbench-client/src/lib/popup/workbench-popup-open-command.ts b/projects/scion/workbench-client/src/lib/popup/workbench-popup-open-command.ts new file mode 100644 index 000000000..e0645a833 --- /dev/null +++ b/projects/scion/workbench-client/src/lib/popup/workbench-popup-open-command.ts @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2018-2019 Swiss Federal Railways + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import { CloseStrategy } from './workbench-popup.config'; +import { WorkbenchPopupCapability } from './workbench-popup-capability'; + +/** + * Command object for instructing the Workbench to open the microfrontend of given popup capability in a popup. + * + * @docs-private Not public API, intended for internal use only. + * @ignore + */ +export interface ɵWorkbenchPopupCommand { // tslint:disable-line:class-name + popupId: string; + capability: WorkbenchPopupCapability; + params: Map; + align?: 'east' | 'west' | 'north' | 'south'; + closeStrategy?: CloseStrategy; + viewId?: string; +} diff --git a/projects/scion/workbench-client/src/lib/popup/workbench-popup-service.ts b/projects/scion/workbench-client/src/lib/popup/workbench-popup-service.ts new file mode 100644 index 000000000..6a3bc9bbb --- /dev/null +++ b/projects/scion/workbench-client/src/lib/popup/workbench-popup-service.ts @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2018-2019 Swiss Federal Railways + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import { IntentClient, ManifestService, mapToBody, MessageClient, Qualifier, throwOnErrorStatus } from '@scion/microfrontend-platform'; +import { Beans } from '@scion/toolkit/bean-manager'; +import { map, take } from 'rxjs/operators'; +import { WorkbenchCapabilities } from '../workbench-capabilities.enum'; +import { Maps, Observables } from '@scion/toolkit/util'; +import { fromBoundingClientRect$ } from '@scion/toolkit/observable'; +import { Observable } from 'rxjs'; +import { WorkbenchView } from '../view/workbench-view'; +import { ɵWorkbenchPopupCommand } from './workbench-popup-open-command'; +import { ɵWorkbenchCommands } from '../ɵworkbench-commands'; +import { UUID } from '@scion/toolkit/uuid'; +import { WorkbenchPopupCapability } from './workbench-popup-capability'; +import { PopupOrigin, WorkbenchPopupConfig } from './workbench-popup.config'; + +/** + * Allows displaying a microfrontend in a workbench popup. + * + * A popup is a visual workbench component for displaying content above other content. It is positioned relative to an anchor, + * which can be either a page coordinate (x/y) or an HTML element. When using an element as the popup anchor, the popup also + * moves when the anchor element moves. + * + * In a popup, you can display a microfrontend, which an application provides in the form of a popup capability. A qualifier is + * used to identify the popup capability. Note that for displaying a microfrontend of any other application, you need to declare + * an intention in your application manifest. + * + * Unlike views, popups are not part of the persistent workbench navigation, meaning that popups do not survive a page reload. + * + * @see WorkbenchPopupCapability + * @category Popup + */ +export class WorkbenchPopupService { + + /** + * Displays a microfrontend in a workbench popup based on the given qualifier. + * + * The qualifier identifies the microfrontend which to display in the workbench popup. + * + * To position the popup, provide either an exact page coordinate (x/y) or an element to serve as the popup anchor. + * If you use an element as the popup anchor, the popup also moves when the anchor element moves. If you position the + * popup using page coordinates, consider passing an Observable to re-position the popup after it is created. If + * passing coordinates via an Observable, the popup will not display until the Observable emits the first coordinate. + * + * By setting the alignment of the popup, you can further control where the popup should open relative to its anchor. + * + * You can pass data to the popup microfrontend using parameters. The popup provider can declare mandatory and optional parameters. + * No additional parameters may be included. Refer to the documentation of the popup capability provider for more information. + * + * By default, the popup will close on focus loss, or when the user hits the escape key. + * + * When opening the popup in the context of a workbench view, the popup adheres to that view's lifecycle. Consequently, the + * popup is displayed only when the view is the active view in its viewpart, and is closed when the view is closed. + * + * @param qualifier - Identifies the popup capability that provides the microfrontend for display as popup. + * @param config - Controls popup behavior. + * @return Promise that resolves to the result when closed with a result, or to `undefined` otherwise. + * The Promise rejects if opening the popup failed, e.g., if missing the popup intention, or because no application + * provides the requested popup. The Promise also rejects when closing the popup with an error. + */ + public async open(qualifier: Qualifier, config: WorkbenchPopupConfig): Promise { + // To be able to integrate popups from apps without workbench integration, we do not delegate the opening of the popup to + // the app that provides the requested popup, but interact with the workbench directly. Nevertheless, we issue an intent + // so that the platform throws an error in case of unqualified interaction. + await Beans.get(IntentClient).publish({type: WorkbenchCapabilities.Popup, qualifier, params: Maps.coerce(config.params)}, {...config, anchor: undefined}); + + const popupCommand: ɵWorkbenchPopupCommand = { + popupId: UUID.randomUUID(), + capability: await this.lookupPopupCapabilityElseReject(qualifier), + align: config.align, + closeStrategy: config.closeStrategy, + params: new Map([ + ...Maps.coerce(config.params), + ...Maps.coerce(qualifier), + ]), + viewId: Beans.opt(WorkbenchView)?.viewId, + }; + const popupOriginPublisher = this.observePopupOrigin$(config).subscribe(origin => { + Beans.get(MessageClient).publish(ɵWorkbenchCommands.popupOriginTopic(popupCommand.popupId), origin, {retain: true}); + }); + + try { + return await Beans.get(MessageClient).request$(ɵWorkbenchCommands.popup, popupCommand) + .pipe( + take(1), + throwOnErrorStatus(), + mapToBody(), + ) + .toPromise(); + } + finally { + popupOriginPublisher.unsubscribe(); + // Instruct the message broker to delete retained messages to free resources. + Beans.get(MessageClient).publish(ɵWorkbenchCommands.popupOriginTopic(popupCommand.popupId), undefined, {retain: true}).then(); + } + } + + /** + * Looks up the requested popup capability. + * + * Returns a Promise that resolves to the requested capability, or that rejects if not found or if multiple providers match the qualifier. + * Only capabilities for which the requester is qualified are returned. + */ + private async lookupPopupCapabilityElseReject(qualifier: Qualifier): Promise { + const capabilityIds = await Beans.get(ManifestService).lookupCapabilities$({type: WorkbenchCapabilities.Popup, qualifier}) + .pipe(take(1)) + .toPromise(); + + if (capabilityIds.length === 0) { + throw Error(`[NullProviderError] Qualifier matches no popup capability. Maybe, the requested popup capability is not public API or the providing application not available. [type=${WorkbenchCapabilities.Popup}, qualifier=${qualifier}]`); + } + if (capabilityIds.length > 1) { + throw Error(`[MultiProviderError] The popup capability cannot be uniquely identified. Multiple providers match the popup qualifier. [type=${WorkbenchCapabilities.Popup}, qualifier=${JSON.stringify(qualifier)}]`); + } + return capabilityIds[0]; + } + + /** + * Observes the position of the popup anchor. + * + * The Observable emits the anchor's initial position, and each time its position changes. + */ + private observePopupOrigin$(config: WorkbenchPopupConfig): Observable { + if (config.anchor instanceof Element) { + return fromBoundingClientRect$(config.anchor as HTMLElement); + } + else { + return Observables.coerce(config.anchor) + .pipe(map(origin => ({ + top: origin.y, + left: origin.x, + bottom: origin.y + (origin.height || 0), + right: origin.x + (origin.width || 0), + width: origin.width || 0, + height: origin.height || 0, + }))); + } + } +} diff --git a/projects/scion/workbench-client/src/lib/popup/workbench-popup.config.ts b/projects/scion/workbench-client/src/lib/popup/workbench-popup.config.ts new file mode 100644 index 000000000..e7b8b4bf1 --- /dev/null +++ b/projects/scion/workbench-client/src/lib/popup/workbench-popup.config.ts @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2018-2019 Swiss Federal Railways + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import { Observable } from 'rxjs'; +import { Dictionary } from '@scion/toolkit/util'; + +/** + * Configures the popup to display a microfrontend in a workbench popup using {@link WorkbenchPopupService}. + * + * @category Popup + */ +export interface WorkbenchPopupConfig { + /** + * Specifies where to open the popup. + * + * Provide either an exact page coordinate (x/y) or an element to serve as the popup anchor. If you use + * an element as the popup anchor, the popup also moves when the anchor element moves. If you position the + * popup using page coordinates, consider passing an Observable to re-position the popup after it is created. + * If passing coordinates via an Observable, the popup will not display until the Observable emits the first coordinate. + * + * The align setting can be used to further control where the popup opens relative to its anchor. + */ + anchor: Element | PopupOrigin | Observable; + /** + * Allows passing data to the popup microfrontend. The popup provider can declare mandatory and optional parameters. + * No additional parameters may be included. Refer to the documentation of the popup capability provider for more information. + */ + params?: Map | Dictionary; + /** + * Hint where to align the popup relative to the popup anchor, unless there is not enough space available in that area. By default, + * if not specified, the popup opens north of the anchor. + */ + align?: 'east' | 'west' | 'north' | 'south'; + /** + * Controls when to close the popup. + */ + closeStrategy?: CloseStrategy; +} + +/** + * Specifies when to close the popup. + * + * @category Popup + */ +export interface CloseStrategy { + /** + * If `true`, which is by default, will close the popup on focus loss. + * No return value will be passed to the popup opener. + */ + onFocusLost?: boolean; + /** + * If `true`, which is by default, will close the popup when the user + * hits the escape key. No return value will be passed to the popup + * opener. + */ + onEscape?: boolean; +} + +/** + * Represents a point on the page, optionally with a dimension, where a workbench popup should be attached. + * + * @category Popup + */ +export interface PopupOrigin { + x: number; + y: number; + width?: number; + height?: number; +} diff --git a/projects/scion/workbench-client/src/lib/popup/workbench-popup.ts b/projects/scion/workbench-client/src/lib/popup/workbench-popup.ts new file mode 100644 index 000000000..75088bd18 --- /dev/null +++ b/projects/scion/workbench-client/src/lib/popup/workbench-popup.ts @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2018-2019 Swiss Federal Railways + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import { WorkbenchPopupCapability } from './workbench-popup-capability'; +import { Beans } from '@scion/toolkit/bean-manager'; +import { MessageClient } from '@scion/microfrontend-platform'; +import { ɵWorkbenchCommands } from '../ɵworkbench-commands'; +import { ɵPopupContext } from './workbench-popup-context'; + +/** + * A popup is a visual workbench component for displaying content above other content. + * + * If a microfrontend lives in the context of a workbench popup, regardless of its embedding level, it can inject an instance + * of this class to interact with the workbench popup, such as reading passed parameters or closing the popup. + * + * #### Preferred Size + * The workbench popup grows and shrinks with its content unless declaring a size constraint in the popup's capability. + * Unfortunately, the iframe, the content of the popup, does not natively adapt its size to its content, the microfrontend. + * Instead, an iframe has a fixed size of 300x150 pixels. + * + * To our help, you can use the {@link PreferredSizeService} to control the iframe size from inside the embedded microfrontend. + * Typically, you would subscribe to size changes of the microfrontend's primary content. As a convenience, {@link PreferredSizeService} + * provides API to pass an element for automatic dimension monitoring. If your content can grow and shrink, e.g., if using expandable + * panels, consider positioning primary content out of the document flow, that is, setting its position to `absolute`. This way, + * you give it infinite space so that it can always be rendered at its preferred size. + * + * ```typescript + * Beans.get(PreferredSizeService).fromDimension(); + * ``` + * + * Note that the microfrontend may take some time to load, causing the popup to flicker when opened. Therefore, for fixed-sized + * popups, consider declaring the popup size in the popup capability. + * + * @category Popup + */ +export abstract class WorkbenchPopup { + + /** + * Capability that represents the microfrontend loaded into this workbench popup. + */ + public readonly capability: WorkbenchPopupCapability; + + /** + * Parameters including qualifier entries as passed for navigation by the popup opener. + */ + public readonly params: Map; + + /** + * Closes the popup. Optionally, pass a result to the popup opener. + */ + public abstract close(result?: R | undefined): void; + + /** + * Closes the popup returning the given error to the popup opener. + */ + public abstract closeWithError(error: Error | string): void; +} + +/** + * @ignore + */ +export class ɵWorkbenchPopup implements WorkbenchPopup { // tslint:disable-line:class-name + + public params: Map; + public capability: WorkbenchPopupCapability; + + constructor(private _context: ɵPopupContext) { + this.capability = this._context.capability; + this.params = coerceMap(this._context.params); + + // In order to close the popup on focus loss, microfrontend content must gain the focus first. + if (this._context.closeOnFocusLost) { + this.requestFocus(); + } + } + + /** + * @inheritDoc + */ + public close(result?: R | undefined): void { + Beans.get(MessageClient).publish(ɵWorkbenchCommands.popupCloseTopic(this._context.popupId), result); + } + + /** + * @inheritDoc + */ + public closeWithError(error: Error | string): void { + Beans.get(MessageClient).publish(ɵWorkbenchCommands.popupCloseTopic(this._context.popupId), readErrorMessage(error), { + headers: new Map().set(ɵWorkbenchPopupMessageHeaders.CLOSE_WITH_ERROR, true), + }); + } + + /** + * If the document is not yet focused, make it focusable and request the focus. + */ + private requestFocus(): void { + if (document.activeElement !== document.body) { + return; + } + + // ensure the body element to be focusable + if (document.body.getAttribute('tabindex') === null) { + document.body.style.outline = 'none'; + document.body.setAttribute('tabindex', '-1'); + } + // request the focus + document.body.focus(); + } +} + +/** + * Message headers to interact with the workbench popup. + * + * @docs-private Not public API, intended for internal use only. + * @ignore + */ +export enum ɵWorkbenchPopupMessageHeaders { + CLOSE_WITH_ERROR = 'ɵWORKBENCH-POPUP:CLOSE_WITH_ERROR', +} + +/** + * Coerces the given Map-like object to a `Map`. + * + * Data sent from one JavaScript realm to another is serialized with the structured clone algorithm. + * Altought the algorithm supports the `Map` data type, a deserialized map object cannot be checked to be instance of `Map`. + * This is most likely because the serialization takes place in a different realm. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm + * @see http://man.hubwiz.com/docset/JavaScript.docset/Contents/Resources/Documents/developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm.html + */ +function coerceMap(mapLike: Map): Map { + return new Map(mapLike); +} + +/** + * Returns the error message if given an error object, or the `toString` representation otherwise. + * + * @internal + */ +function readErrorMessage(error: any): string { + if (error instanceof Error) { + return error.message; + } + return error?.toString(); +} diff --git a/projects/scion/workbench-client/src/lib/workbench-capabilities.enum.ts b/projects/scion/workbench-client/src/lib/workbench-capabilities.enum.ts index 311c220d5..b87c8643e 100644 --- a/projects/scion/workbench-client/src/lib/workbench-capabilities.enum.ts +++ b/projects/scion/workbench-client/src/lib/workbench-capabilities.enum.ts @@ -13,7 +13,11 @@ */ export enum WorkbenchCapabilities { /** - * Allows the contribution of microfrontends for display in workbench views. + * Allows the contribution of a microfrontend for display in workbench view. */ View = 'view', + /** + * Allows the contribution of a microfrontend for display in workbench popup. + */ + Popup = 'popup', } diff --git a/projects/scion/workbench-client/src/lib/workbench-client.ts b/projects/scion/workbench-client/src/lib/workbench-client.ts index c7d7ed4fb..aea6d95b5 100644 --- a/projects/scion/workbench-client/src/lib/workbench-client.ts +++ b/projects/scion/workbench-client/src/lib/workbench-client.ts @@ -12,6 +12,8 @@ import { Beans } from '@scion/toolkit/bean-manager'; import { WorkbenchViewInitializer } from './view/workbench-view-initializer'; import { MicroApplicationConfig, MicrofrontendPlatform } from '@scion/microfrontend-platform'; import { WorkbenchRouter } from './routing/workbench-router'; +import { WorkbenchPopupService } from './popup/workbench-popup-service'; +import { WorkbenchPopupInitializer } from './popup/workbench-popup-initializer'; /** * **SCION Workbench Client provides core API for a web app to interact with SCION Workbench and other microfrontends.** @@ -97,7 +99,9 @@ export class WorkbenchClient { */ public static async connect(config: MicroApplicationConfig): Promise { Beans.register(WorkbenchRouter); + Beans.register(WorkbenchPopupService); Beans.registerInitializer({useClass: WorkbenchViewInitializer}); + Beans.registerInitializer({useClass: WorkbenchPopupInitializer}); await MicrofrontendPlatform.connectToHost(config); } } diff --git "a/projects/scion/workbench-client/src/lib/\311\265workbench-commands.ts" "b/projects/scion/workbench-client/src/lib/\311\265workbench-commands.ts" index e54337e02..f562dbfa4 100644 --- "a/projects/scion/workbench-client/src/lib/\311\265workbench-commands.ts" +++ "b/projects/scion/workbench-client/src/lib/\311\265workbench-commands.ts" @@ -20,6 +20,11 @@ export namespace ɵWorkbenchCommands { */ export const navigate = 'ɵworkbench/navigate'; + /** + * Topic to instruct the workbench to display a microfrontend in a popup. + */ + export const popup = 'ɵworkbench/popup'; + /** * Computes the topic via which the title of a workbench view tab can be set. */ @@ -93,4 +98,18 @@ export namespace ɵWorkbenchCommands { export function viewParamsTopic(viewId: string): string { return `ɵworkbench/views/${viewId}/params`; } + + /** + * Computes the topic for observing the popup anchor. + */ + export function popupOriginTopic(popupId: string): string { + return `ɵworkbench/popups/${popupId}/origin`; + } + + /** + * Computes the topic via which a popup can be closed. + */ + export function popupCloseTopic(popupId: string): string { + return `ɵworkbench/popups/${popupId}/close`; + } } diff --git a/projects/scion/workbench-client/src/public_api.ts b/projects/scion/workbench-client/src/public_api.ts index fa84a21ab..56ae98387 100644 --- a/projects/scion/workbench-client/src/public_api.ts +++ b/projects/scion/workbench-client/src/public_api.ts @@ -12,9 +12,14 @@ * Entry point for all public APIs of this package. */ export { WorkbenchClient } from './lib/workbench-client'; -export { WorkbenchRouter, WorkbenchNavigationExtras } from './lib/routing/workbench-router'; -export { ɵMicrofrontendRouteParams, ɵWorkbenchNavigationMessageHeaders } from './lib/routing/workbench-router.constants'; +export { WorkbenchRouter, WorkbenchNavigationExtras, ɵMicrofrontendRouteParams, ɵWorkbenchNavigationMessageHeaders } from './lib/routing/workbench-router'; export { WorkbenchViewCapability } from './lib/view/workbench-view-capability'; export { WorkbenchView, ViewClosingListener, ViewClosingEvent, ɵVIEW_ID_CONTEXT_KEY } from './lib/view/workbench-view'; export { WorkbenchCapabilities } from './lib/workbench-capabilities.enum'; export { ɵWorkbenchCommands } from './lib/ɵworkbench-commands'; +export { ɵWorkbenchPopupCommand } from './lib/popup/workbench-popup-open-command'; +export { WorkbenchPopupService } from './lib/popup/workbench-popup-service'; +export { WorkbenchPopup, ɵWorkbenchPopupMessageHeaders } from './lib/popup/workbench-popup'; +export { WorkbenchPopupCapability, PopupSize } from './lib/popup/workbench-popup-capability'; +export { WorkbenchPopupConfig, PopupOrigin, CloseStrategy } from './lib/popup/workbench-popup.config'; +export { ɵPopupContext, ɵPOPUP_CONTEXT } from './lib/popup/workbench-popup-context'; diff --git a/projects/scion/workbench-client/tsconfig.lib.prod.typedoc.json b/projects/scion/workbench-client/tsconfig.lib.prod.typedoc.json index 83d110e4a..e71470b18 100644 --- a/projects/scion/workbench-client/tsconfig.lib.prod.typedoc.json +++ b/projects/scion/workbench-client/tsconfig.lib.prod.typedoc.json @@ -13,7 +13,11 @@ "toc": [ "WorkbenchClient", "WorkbenchRouter", - "WorkbenchView" + "WorkbenchPopupService", + "WorkbenchView", + "WorkbenchViewCapability", + "WorkbenchPopup", + "WorkbenchPopupCapability" ], "categorizeByGroup": true } diff --git a/projects/scion/workbench/src/lib/microfrontend-platform/initialization/microfrontend-platform-initializer.service.ts b/projects/scion/workbench/src/lib/microfrontend-platform/initialization/microfrontend-platform-initializer.service.ts index 1dbb4bee2..8db5b53b3 100644 --- a/projects/scion/workbench/src/lib/microfrontend-platform/initialization/microfrontend-platform-initializer.service.ts +++ b/projects/scion/workbench/src/lib/microfrontend-platform/initialization/microfrontend-platform-initializer.service.ts @@ -9,9 +9,8 @@ */ import { Injectable, InjectFlags, InjectionToken, Injector, OnDestroy } from '@angular/core'; -import { ApplicationConfig } from '@scion/microfrontend-platform/lib/host/platform-config'; +import { ApplicationConfig, ApplicationManifest, IntentClient, Logger as MicrofrontendPlatformLogger, ManifestService, MessageClient, MicrofrontendPlatform, PlatformConfig, Runlevel } from '@scion/microfrontend-platform'; import { WorkbenchModuleConfig } from '../../workbench-module-config'; -import { ApplicationManifest, IntentClient, Logger as MicrofrontendPlatformLogger, ManifestService, MessageClient, MicrofrontendPlatform, PlatformConfig, Runlevel } from '@scion/microfrontend-platform'; import { Beans } from '@scion/toolkit/bean-manager'; import { WorkbenchCapabilities } from '@scion/workbench-client'; import { Logger, LoggerNames } from '../../logging'; @@ -76,8 +75,8 @@ export class MicrofrontendPlatformInitializerService implements WorkbenchInitial // Start the microfrontend platform host. await MicrofrontendPlatform.startHost(effectiveMicrofrontendPlatformConfig, {symbolicName: effectiveHostSymbolicName}); - // Register a wildcard `view` intention to read all view capabilities, required for microfrontend routing. - await this.registerWildcardViewIntention(); + // Register wildcard intentions to look up view capabilities, required for reading the microfrontend path. + await this.registerWildcardIntention(WorkbenchCapabilities.View); this._logger.debug('SCION Microfrontend Platform started.', LoggerNames.LIFECYCLE, effectiveMicrofrontendPlatformConfig); } @@ -111,8 +110,8 @@ export class MicrofrontendPlatformInitializerService implements WorkbenchInitial microfrontendPlatformConfig.apps.find(app => app.symbolicName === appSymbolicName).scopeCheckDisabled = true; } - private async registerWildcardViewIntention(): Promise { - await Beans.get(ManifestService).registerIntention({type: WorkbenchCapabilities.View, qualifier: {'*': '*'}}); + private async registerWildcardIntention(type: WorkbenchCapabilities): Promise { + await Beans.get(ManifestService).registerIntention({type, qualifier: {'*': '*'}}); } public ngOnDestroy(): void { diff --git a/projects/scion/workbench/src/lib/microfrontend-platform/microfrontend-popup/microfrontend-popup-command-handler.service.ts b/projects/scion/workbench/src/lib/microfrontend-platform/microfrontend-popup/microfrontend-popup-command-handler.service.ts new file mode 100644 index 000000000..f8e49e06a --- /dev/null +++ b/projects/scion/workbench/src/lib/microfrontend-platform/microfrontend-popup/microfrontend-popup-command-handler.service.ts @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2018-2019 Swiss Federal Railways + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import { Injectable, NgZone, OnDestroy } from '@angular/core'; +import { mapToBody, MessageClient, MessageHeaders, ResponseStatusCodes } from '@scion/microfrontend-platform'; +import { ɵPopupContext, ɵWorkbenchCommands, ɵWorkbenchPopupCommand } from '@scion/workbench-client'; +import { combineLatest, Observable, of, Subject } from 'rxjs'; +import { filter, map, takeUntil } from 'rxjs/operators'; +import { Logger, LoggerNames } from '../../logging'; +import { SafeRunner } from '../../safe-runner'; +import { MicrofrontendPopupComponent } from './microfrontend-popup.component'; +import { WorkbenchViewRegistry } from '../../view/workbench-view.registry'; +import { fromDimension$ } from '@scion/toolkit/observable'; +import { PopupOrigin } from '../../popup/popup.config'; +import { observeInside, subscribeInside } from '@scion/toolkit/operators'; +import { PopupService } from '../../popup/popup.service'; + +/** + * Handles microfrontend popup commands, instructing the Workbench {@link PopupService} to navigate to the microfrontend of a given popup capability. + * + * This class is constructed before the Microfrontend Platform activates micro applications via {@link MICROFRONTEND_PLATFORM_PRE_ACTIVATION} DI token. + */ +@Injectable() +export class MicrofrontendPopupCommandHandler implements OnDestroy { + + private _destroy$ = new Subject(); + + constructor(private _messageClient: MessageClient, + private _popupService: PopupService, + private _logger: Logger, + private _viewRegistry: WorkbenchViewRegistry, + safeRunner: SafeRunner, + private _zone: NgZone) { + this._messageClient.observe$<ɵWorkbenchPopupCommand>(ɵWorkbenchCommands.popup) + .pipe(takeUntil(this._destroy$)) + .subscribe(popupCommand => safeRunner.run(async () => { + this._logger.debug(() => 'Handling microfrontend popup command', LoggerNames.MICROFRONTEND, popupCommand); + const replyTo = popupCommand.headers.get(MessageHeaders.ReplyTo); + try { + const result = await this.onPopupCommand(popupCommand.body); + await this._messageClient.publish(replyTo, result, {headers: new Map().set(MessageHeaders.Status, ResponseStatusCodes.OK)}); + } + catch (error) { + await this._messageClient.publish(replyTo, readErrorMessage(error), {headers: new Map().set(MessageHeaders.Status, ResponseStatusCodes.ERROR)}); + } + })); + } + + private async onPopupCommand(command: ɵWorkbenchPopupCommand): Promise { + const popupCapability = command.capability; + const popupContext: ɵPopupContext = { + popupId: command.popupId, + capability: popupCapability, + params: coerceMap(command.params), + closeOnFocusLost: command.closeStrategy?.onFocusLost ?? true, + }; + + return this._popupService.open({ + component: MicrofrontendPopupComponent, + input: popupContext, + anchor: this.observePopupOrigin$(command), + viewRef: command.viewId, + align: command.align, + size: popupCapability.properties?.size, + closeStrategy: { + ...command.closeStrategy, + onFocusLost: false, // Closing the popup on focus loss is handled in {MicrofrontendPopupComponent} + }, + cssClass: popupCapability.properties?.cssClass, + }); + } + + private observePopupOrigin$(command: ɵWorkbenchPopupCommand): Observable { + return combineLatest([ + command.viewId ? this.observeViewBoundingBox$(command.viewId) : of(undefined), + this.observeMicrofrontendPopupOrigin$(command.popupId), + ]) + .pipe( + filter(([viewBoundingBox, popupOrigin]) => { + // Swallow emissions until both sources report a non-empty dimension. For example, when deactivating + // the popup's contextual view, the view reports an empty bounding box, causing the popup to flicker + // when activating it again. + return !isNullClientRect(viewBoundingBox) && !isNullClientRect(popupOrigin); + }), + map(([viewBoundingBox, popupOrigin]: [ClientRect | undefined, ClientRect]) => { + return { + x: (viewBoundingBox?.left ?? 0) + popupOrigin.left, + y: (viewBoundingBox?.top ?? 0) + popupOrigin.top, + width: popupOrigin.width, + height: popupOrigin.height, + }; + }), + subscribeInside(continueFn => this._zone.runOutsideAngular(continueFn)), + observeInside(continueFn => this._zone.run(continueFn)), + ); + } + + private observeViewBoundingBox$(viewId: string): Observable { + const view = this._viewRegistry.getElseThrow(viewId); + return fromDimension$(view.portal.componentRef.location.nativeElement) + .pipe(map(dimension => dimension.element.getBoundingClientRect())); + } + + private observeMicrofrontendPopupOrigin$(popupId: string): Observable { + return this._messageClient.observe$(ɵWorkbenchCommands.popupOriginTopic(popupId)) + .pipe(mapToBody()); + } + + public ngOnDestroy(): void { + this._destroy$.next(); + } +} + +/** + * Returns the error message if given an error object, or the `toString` representation otherwise. + */ +function readErrorMessage(error: any): string { + if (error instanceof Error) { + return error.message; + } + return error?.toString(); +} + +/** + * Coerces the given Map-like object to a `Map`. + * + * Data sent from one JavaScript realm to another is serialized with the structured clone algorithm. + * Altought the algorithm supports the `Map` data type, a deserialized map object cannot be checked to be instance of `Map`. + * This is most likely because the serialization takes place in a different realm. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm + * @see http://man.hubwiz.com/docset/JavaScript.docset/Contents/Resources/Documents/developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm.html + * + * @ignore + */ +function coerceMap(mapLike: Map): Map { + return new Map(mapLike); +} + +function isNullClientRect(clientRect: ClientRect): boolean { + return clientRect.top === 0 && clientRect.right === 0 && clientRect.bottom === 0 && clientRect.left === 0; +} diff --git a/projects/scion/workbench/src/lib/microfrontend-platform/microfrontend-popup/microfrontend-popup.component.html b/projects/scion/workbench/src/lib/microfrontend-platform/microfrontend-popup/microfrontend-popup.component.html new file mode 100644 index 000000000..3eb849a01 --- /dev/null +++ b/projects/scion/workbench/src/lib/microfrontend-platform/microfrontend-popup/microfrontend-popup.component.html @@ -0,0 +1,6 @@ + + diff --git a/projects/scion/workbench/src/lib/microfrontend-platform/microfrontend-popup/microfrontend-popup.component.scss b/projects/scion/workbench/src/lib/microfrontend-platform/microfrontend-popup/microfrontend-popup.component.scss new file mode 100644 index 000000000..ee47e34e8 --- /dev/null +++ b/projects/scion/workbench/src/lib/microfrontend-platform/microfrontend-popup/microfrontend-popup.component.scss @@ -0,0 +1,3 @@ +:host { + display: grid; +} diff --git a/projects/scion/workbench/src/lib/microfrontend-platform/microfrontend-popup/microfrontend-popup.component.ts b/projects/scion/workbench/src/lib/microfrontend-platform/microfrontend-popup/microfrontend-popup.component.ts new file mode 100644 index 000000000..1db7cab0a --- /dev/null +++ b/projects/scion/workbench/src/lib/microfrontend-platform/microfrontend-popup/microfrontend-popup.component.ts @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2018-2019 Swiss Federal Railways + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import { Component, ElementRef, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { Subject } from 'rxjs'; +import { filter, takeUntil } from 'rxjs/operators'; +import { Application, ManifestService, MessageClient, OutletRouter, SciRouterOutletElement } from '@scion/microfrontend-platform'; +import { Arrays } from '@scion/toolkit/util'; +import { Logger, LoggerNames } from '../../logging'; +import { ɵPOPUP_CONTEXT, ɵPopupContext, ɵWorkbenchCommands, ɵWorkbenchPopupMessageHeaders } from '@scion/workbench-client'; +import { Popup } from '../../popup/popup.config'; + +/** + * Component displayed in a workbench popup for embedding the microfrontend of a popup capability. + */ +@Component({ + selector: 'wb-microfrontend-popup', + styleUrls: ['./microfrontend-popup.component.scss'], + templateUrl: './microfrontend-popup.component.html', +}) +export class MicrofrontendPopupComponent implements OnInit, OnDestroy { + + private _destroy$ = new Subject(); + private _focusWithin$ = new Subject(); + private _popupContext: ɵPopupContext; + + public microfrontendCssClasses: string[]; + + @ViewChild('router_outlet', {static: true}) + public routerOutletElement: ElementRef; + + constructor(private _popup: Popup<ɵPopupContext>, + private _outletRouter: OutletRouter, + private _manifestService: ManifestService, + private _messageClient: MessageClient, + private _logger: Logger) { + this._popupContext = this._popup.input; + this._logger.debug(() => 'Constructing MicrofrontendPopupComponent.', LoggerNames.MICROFRONTEND); + } + + public ngOnInit(): void { + this.onInit().then(); + } + + private async onInit(): Promise { + const popupCapability = this._popupContext.capability; + + // Obtain the capability provider. + const application = this.lookupApplication(popupCapability.metadata.appSymbolicName); + if (!application) { + this._popup.closeWithError(`[NullApplicationError] Unexpected. Cannot resolve application '${popupCapability.metadata.appSymbolicName}'.`); + return; + } + + // Obtain the microfrontend path. + const microfrontendPath = popupCapability.properties?.path; + if (microfrontendPath === undefined || microfrontendPath === null) { // empty path is a valid path + this._popup.closeWithError(`[PopupProviderError] Popup has no path to the microfrontend defined.`); + return; + } + + // Listen to popup close requests. + this._messageClient.observe$(ɵWorkbenchCommands.popupCloseTopic(this.popupId)) + .pipe(takeUntil(this._destroy$)) + .subscribe(closeRequest => { + if (closeRequest.headers.get(ɵWorkbenchPopupMessageHeaders.CLOSE_WITH_ERROR) === true) { + this._popup.closeWithError(closeRequest.body); + } + else { + this._popup.close(closeRequest.body); + } + }); + + // Close the popup on focus loss. + if (this._popupContext.closeOnFocusLost) { + this._focusWithin$ + .pipe( + filter(focusWithin => !focusWithin), + takeUntil(this._destroy$), + ) + .subscribe(() => { + this._popup.close(); + }); + } + + // Make the popup context available to embedded content. + this.routerOutletElement.nativeElement.setContextValue(ɵPOPUP_CONTEXT, this._popupContext); + this.microfrontendCssClasses = ['e2e-popup', `e2e-${popupCapability.metadata.appSymbolicName}`, ...Arrays.coerce(popupCapability.properties.cssClass)]; + + // Navigate to the microfrontend. + this._logger.debug(() => `Loading microfrontend into workbench popup [app=${popupCapability.metadata.appSymbolicName}, baseUrl=${application.baseUrl}, path=${microfrontendPath}].`, LoggerNames.MICROFRONTEND, this._popupContext.params, popupCapability); + await this._outletRouter.navigate(microfrontendPath, { + outlet: this.popupId, + relativeTo: application.baseUrl, + params: this._popupContext.params, + pushStateToSessionHistoryStack: false, + }); + } + + public onFocusWithin(event: Event): void { + this._focusWithin$.next((event as CustomEvent).detail); + } + + /** + * Looks up the application registered under the given symbolic name. Returns `undefined` if not found. + */ + private lookupApplication(symbolicName: string): Application | undefined { + return this._manifestService.applications.find(app => app.symbolicName === symbolicName); + } + + /** + * Unique identity of this popup. + */ + public get popupId(): string { + return this._popupContext.popupId; + } + + public ngOnDestroy(): void { + this._destroy$.next(); + } +} diff --git a/projects/scion/workbench/src/lib/microfrontend-platform/workbench-microfrontend-support.ts b/projects/scion/workbench/src/lib/microfrontend-platform/workbench-microfrontend-support.ts index a7bc11430..b75a85038 100644 --- a/projects/scion/workbench/src/lib/microfrontend-platform/workbench-microfrontend-support.ts +++ b/projects/scion/workbench/src/lib/microfrontend-platform/workbench-microfrontend-support.ts @@ -4,12 +4,13 @@ import { MICROFRONTEND_PLATFORM_PRE_ACTIVATION, MicrofrontendPlatformInitializer import { IntentClient, ManifestService, MessageClient, MicroApplicationConfig, OutletRouter, PlatformConfig, PlatformPropertyService } from '@scion/microfrontend-platform'; import { WorkbenchInitializer } from '../startup/workbench-initializer'; import { Beans } from '@scion/toolkit/bean-manager'; -import { WorkbenchRouter } from '@scion/workbench-client'; +import { WorkbenchPopupService, WorkbenchRouter } from '@scion/workbench-client'; import { MicrofrontendWorkbenchRouter } from './routing/microfrontend-workbench-router.service'; import { NgZoneIntentClientDecorator, NgZoneMessageClientDecorator } from './initialization/ng-zone-decorators'; import { WorkbenchModuleConfig } from '../workbench-module-config'; import { LogDelegate } from './initialization/log-delegate.service'; import { MicrofrontendViewCommandHandler } from './microfrontend-view/microfrontend-view-command-handler.service'; +import { MicrofrontendPopupCommandHandler } from './microfrontend-popup/microfrontend-popup-command-handler.service'; /** * Registers a set of DI providers to set up microfrontend support in the workbench. @@ -36,13 +37,20 @@ export function provideWorkbenchMicrofrontendSupport(workbenchModuleConfig: Work useExisting: MicrofrontendWorkbenchRouter, multi: true, }, + { + provide: MICROFRONTEND_PLATFORM_PRE_ACTIVATION, + useExisting: MicrofrontendPopupCommandHandler, + multi: true, + }, { provide: MicrofrontendPlatformConfigLoader, useClass: typeof workbenchModuleConfig.microfrontends.platform === 'function' ? workbenchModuleConfig.microfrontends.platform : MicrofrontendPlatformModuleConfigLoader, }, LogDelegate, WorkbenchRouter, + WorkbenchPopupService, MicrofrontendWorkbenchRouter, + MicrofrontendPopupCommandHandler, MicrofrontendViewCommandHandler, NgZoneMessageClientDecorator, NgZoneIntentClientDecorator, diff --git a/projects/scion/workbench/src/lib/workbench.module.ts b/projects/scion/workbench/src/lib/workbench.module.ts index c3dadcd7b..db54c7714 100644 --- a/projects/scion/workbench/src/lib/workbench.module.ts +++ b/projects/scion/workbench/src/lib/workbench.module.ts @@ -91,6 +91,7 @@ import { WbBeforeDestroyGuard } from './view/wb-before-destroy.guard'; import { ViewDragService } from './view-dnd/view-drag.service'; import { ViewTabDragImageRenderer } from './view-dnd/view-tab-drag-image-renderer.service'; import { PopupComponent } from './popup/popup.component'; +import { MicrofrontendPopupComponent } from './microfrontend-platform/microfrontend-popup/microfrontend-popup.component'; @NgModule({ imports: [ @@ -145,6 +146,7 @@ import { PopupComponent } from './popup/popup.component'; ArrayConcatPipe, ViewPortalPipe, MicrofrontendViewComponent, + MicrofrontendPopupComponent, SplashComponent, PopupComponent, ], diff --git a/projects/scion/workbench/src/theme/_popup-theme.scss b/projects/scion/workbench/src/theme/_popup-theme.scss index 5109c7e4c..51e1f6469 100644 --- a/projects/scion/workbench/src/theme/_popup-theme.scss +++ b/projects/scion/workbench/src/theme/_popup-theme.scss @@ -96,9 +96,12 @@ $popup-box-shadow: 3px 3px 20px -5px rgba(0, 0, 0, 0.5); } } - // Hide the popup if it is bound to a view but the view is not active. + // Hide the popup when its contextual view is not active. &.wb-view-context:not(.wb-view-active) { - display: none; + // To hide the popup, we set its visibility to 'hidden' and not its display to 'none'. This way it retains its dimension + // even when not displayed. Otherwise, the popup would flicker when its contextual view is activated. Flickering is most + // noticeable with popups that display a microfrontend. + visibility: hidden; } } }