From 34e5acc96b6c1b7ee78625fa8a7b19434e35f778 Mon Sep 17 00:00:00 2001 From: k-genov Date: Fri, 29 Sep 2023 17:08:16 +0200 Subject: [PATCH] feat(workbench): provide workbench dialog A dialog is a visual element for focused interaction with the user, such as prompting the user for input or confirming actions. The user can move or resize a dialog. Displayed on top of other content, a dialog blocks interaction with other parts of the application. A dialog can be view-modal or application-modal. Multiple dialogs are stacked, and only the topmost dialog in each modality stack can be interacted with. --- .../src/app/app.routes.ts | 5 + .../dialog-opener-page.component.html | 61 ++ .../dialog-opener-page.component.scss | 39 + .../dialog-opener-page.component.ts | 124 ++++ .../dialog-page/dialog-page.component.html | 73 ++ .../dialog-page/dialog-page.component.scss | 31 + .../app/dialog-page/dialog-page.component.ts | 109 +++ .../app/view-page/view-page.component.html | 4 - .../src/app/workbench.config.ts | 3 + .../workbench-startup-query-params.ts | 12 + docs/site/announcements.md | 3 + docs/site/features.md | 46 +- docs/site/howto/how-to-open-dialog.md | 125 ++++ docs/site/howto/how-to.md | 1 + projects/scion/e2e-testing/src/app.po.ts | 41 + .../scion/e2e-testing/src/dialog-page.po.ts | 87 +++ projects/scion/e2e-testing/src/dialog.po.ts | 44 ++ .../src/view-tab-context-menu.po.ts | 8 +- projects/scion/e2e-testing/src/view-tab.po.ts | 59 +- .../page-object/popup-page.po.ts | 6 +- .../src/workbench-client/router.e2e-spec.ts | 4 +- .../view-properties.e2e-spec.ts | 8 +- .../src/workbench-client/view.e2e-spec.ts | 4 +- .../src/workbench/contextual-view.e2e-spec.ts | 132 ---- .../src/workbench/dialog.e2e-spec.ts | 702 ++++++++++++++++++ .../src/workbench/message-box.e2e-spec.ts | 54 +- .../page-object/dialog-opener-page.po.ts | 119 +++ .../test-pages/focus-test-page.po.ts | 41 +- .../src/workbench/popup.e2e-spec.ts | 97 ++- .../src/workbench/view.e2e-spec.ts | 16 +- .../src/workbench/workbench-navigator.ts | 9 + projects/scion/workbench/_index.scss | 2 + .../_workbench-dark-theme-design-tokens.scss | 8 + .../_workbench-dialog-global-styles.scss | 11 + .../_workbench-light-theme-design-tokens.scss | 8 + .../src/lib/common/\311\265destroy-ref.ts" | 41 + .../view-container.reference.ts | 8 + .../workbench/src/lib/dialog/public_api.ts | 13 + .../dialog/workbench-dialog.component.html | 21 + .../dialog/workbench-dialog.component.scss | 115 +++ .../lib/dialog/workbench-dialog.component.ts | 244 ++++++ .../lib/dialog/workbench-dialog.options.ts | 77 ++ .../lib/dialog/workbench-dialog.registry.ts | 74 ++ .../lib/dialog/workbench-dialog.service.ts | 53 ++ .../src/lib/dialog/workbench-dialog.ts | 89 +++ .../\311\265workbench-dialog.service.ts" | 93 +++ .../lib/dialog/\311\265workbench-dialog.ts" | 303 ++++++++ .../src/lib/message-box/message-box.spec.ts | 4 +- .../lib/part/view-tab/view-tab.component.html | 2 +- .../scion/workbench/src/lib/public_api.ts | 1 + .../workbench/src/lib/view/view.component.ts | 36 +- .../src/lib/view/workbench-view.model.ts | 5 - .../lib/view/\311\265workbench-view.model.ts" | 46 +- .../src/lib/workbench-module-config.ts | 13 + .../workbench/src/lib/workbench.component.ts | 27 +- 55 files changed, 3060 insertions(+), 301 deletions(-) create mode 100644 apps/workbench-testing-app/src/app/dialog-opener-page/dialog-opener-page.component.html create mode 100644 apps/workbench-testing-app/src/app/dialog-opener-page/dialog-opener-page.component.scss create mode 100644 apps/workbench-testing-app/src/app/dialog-opener-page/dialog-opener-page.component.ts create mode 100644 apps/workbench-testing-app/src/app/dialog-page/dialog-page.component.html create mode 100644 apps/workbench-testing-app/src/app/dialog-page/dialog-page.component.scss create mode 100644 apps/workbench-testing-app/src/app/dialog-page/dialog-page.component.ts create mode 100644 docs/site/howto/how-to-open-dialog.md create mode 100644 projects/scion/e2e-testing/src/dialog-page.po.ts create mode 100644 projects/scion/e2e-testing/src/dialog.po.ts delete mode 100644 projects/scion/e2e-testing/src/workbench/contextual-view.e2e-spec.ts create mode 100644 projects/scion/e2e-testing/src/workbench/dialog.e2e-spec.ts create mode 100644 projects/scion/e2e-testing/src/workbench/page-object/dialog-opener-page.po.ts create mode 100644 projects/scion/workbench/design/_workbench-dialog-global-styles.scss create mode 100644 "projects/scion/workbench/src/lib/common/\311\265destroy-ref.ts" create mode 100644 projects/scion/workbench/src/lib/dialog/public_api.ts create mode 100644 projects/scion/workbench/src/lib/dialog/workbench-dialog.component.html create mode 100644 projects/scion/workbench/src/lib/dialog/workbench-dialog.component.scss create mode 100644 projects/scion/workbench/src/lib/dialog/workbench-dialog.component.ts create mode 100644 projects/scion/workbench/src/lib/dialog/workbench-dialog.options.ts create mode 100644 projects/scion/workbench/src/lib/dialog/workbench-dialog.registry.ts create mode 100644 projects/scion/workbench/src/lib/dialog/workbench-dialog.service.ts create mode 100644 projects/scion/workbench/src/lib/dialog/workbench-dialog.ts create mode 100644 "projects/scion/workbench/src/lib/dialog/\311\265workbench-dialog.service.ts" create mode 100644 "projects/scion/workbench/src/lib/dialog/\311\265workbench-dialog.ts" diff --git a/apps/workbench-testing-app/src/app/app.routes.ts b/apps/workbench-testing-app/src/app/app.routes.ts index 3df703ccf..72159123f 100644 --- a/apps/workbench-testing-app/src/app/app.routes.ts +++ b/apps/workbench-testing-app/src/app/app.routes.ts @@ -58,6 +58,11 @@ export const routes: Routes = [ loadComponent: () => import('./message-box-opener-page/message-box-opener-page.component'), data: {[WorkbenchRouteData.title]: 'Workbench Messagebox', [WorkbenchRouteData.heading]: 'Workbench E2E Testpage', [WorkbenchRouteData.cssClass]: 'e2e-test-message-box-opener', pinToStartPage: true}, }, + { + path: 'test-dialog-opener', + loadComponent: () => import('./dialog-opener-page/dialog-opener-page.component'), + data: {[WorkbenchRouteData.title]: 'Workbench Dialog', [WorkbenchRouteData.heading]: 'Workbench E2E Testpage', [WorkbenchRouteData.cssClass]: 'e2e-test-dialog-opener', pinToStartPage: true}, + }, { path: 'test-notification-opener', loadComponent: () => import('./notification-opener-page/notification-opener-page.component'), diff --git a/apps/workbench-testing-app/src/app/dialog-opener-page/dialog-opener-page.component.html b/apps/workbench-testing-app/src/app/dialog-opener-page/dialog-opener-page.component.html new file mode 100644 index 000000000..d7b67c10a --- /dev/null +++ b/apps/workbench-testing-app/src/app/dialog-opener-page/dialog-opener-page.component.html @@ -0,0 +1,61 @@ +
+
+ + + +
+ +
+ + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + +
+
+ + + + + {{returnValue}} + + + + {{dialogError}} + diff --git a/apps/workbench-testing-app/src/app/dialog-opener-page/dialog-opener-page.component.scss b/apps/workbench-testing-app/src/app/dialog-opener-page/dialog-opener-page.component.scss new file mode 100644 index 000000000..1035560bb --- /dev/null +++ b/apps/workbench-testing-app/src/app/dialog-opener-page/dialog-opener-page.component.scss @@ -0,0 +1,39 @@ +:host { + display: flex; + flex-direction: column; + gap: 1em; + padding: 1em; + + > form { + display: flex; + flex-direction: column; + gap: 1em; + + > section { + display: flex; + flex-direction: column; + gap: .5em; + border: 1px solid var(--sci-color-border); + border-radius: var(--sci-corner); + padding: 1em; + } + } + + > output.return-value { + border: 1px solid var(--sci-color-positive); + background-color: var(--sci-color-background-positive); + color: var(--sci-color-positive); + border-radius: var(--sci-corner); + padding: 1em; + font-family: monospace; + } + + > output.dialog-error { + border: 1px solid var(--sci-color-negative); + background-color: var(--sci-color-background-negative); + color: var(--sci-color-negative); + border-radius: var(--sci-corner); + padding: 1em; + font-family: monospace; + } +} diff --git a/apps/workbench-testing-app/src/app/dialog-opener-page/dialog-opener-page.component.ts b/apps/workbench-testing-app/src/app/dialog-opener-page/dialog-opener-page.component.ts new file mode 100644 index 000000000..81c3b5469 --- /dev/null +++ b/apps/workbench-testing-app/src/app/dialog-opener-page/dialog-opener-page.component.ts @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2018-2023 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 {ApplicationRef, Component, Type} from '@angular/core'; +import {FormGroup, NonNullableFormBuilder, ReactiveFormsModule, Validators} from '@angular/forms'; +import {WorkbenchDialogService} from '@scion/workbench'; +import {startWith} from 'rxjs/operators'; +import {NgIf} from '@angular/common'; +import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; +import {stringifyError} from '../common/stringify-error.util'; +import {KeyValueEntry, SciKeyValueFieldComponent} from '@scion/components.internal/key-value-field'; +import {SciFormFieldComponent} from '@scion/components.internal/form-field'; +import {SciCheckboxComponent} from '@scion/components.internal/checkbox'; +import {DialogPageComponent} from '../dialog-page/dialog-page.component'; +import BlankTestPageComponent from '../test-pages/blank-test-page/blank-test-page.component'; +import {FocusTestPageComponent} from '../test-pages/focus-test-page/focus-test-page.component'; + +@Component({ + selector: 'app-dialog-opener-page', + templateUrl: './dialog-opener-page.component.html', + styleUrls: ['./dialog-opener-page.component.scss'], + standalone: true, + imports: [ + NgIf, + ReactiveFormsModule, + SciFormFieldComponent, + SciKeyValueFieldComponent, + SciCheckboxComponent, + ], +}) +export default class DialogOpenerPageComponent { + + public form = this._formBuilder.group({ + component: this._formBuilder.control('dialog-page', Validators.required), + options: this._formBuilder.group({ + inputs: this._formBuilder.array>([]), + modality: this._formBuilder.control<'application' | 'view' | ''>(''), + contextualViewId: this._formBuilder.control(''), + cssClass: this._formBuilder.control(''), + animate: this._formBuilder.control(undefined), + }), + count: this._formBuilder.control(''), + viewContext: this._formBuilder.control(true), + }); + public dialogError: string | undefined; + public returnValue: string | undefined; + + constructor(private _formBuilder: NonNullableFormBuilder, + private _dialogService: WorkbenchDialogService, + private _appRef: ApplicationRef) { + this.installContextualViewIdEnabler(); + } + + public async onDialogOpen(): Promise { + this.dialogError = undefined; + this.returnValue = undefined; + + const unsetViewContext = !this.form.controls.viewContext.value; + const dialogService = unsetViewContext ? this._appRef.injector.get(WorkbenchDialogService) : this._dialogService; + + const dialogs = []; + for (let i = 0; i < Number(this.form.controls.count.value || 1); i++) { + dialogs.push(this.openDialog(dialogService, i)); + } + await Promise.all(dialogs); + } + + private openDialog(dialogService: WorkbenchDialogService, index: number): Promise { + const component = this.parseComponentFromUI(); + return dialogService.open(component, { + inputs: SciKeyValueFieldComponent.toDictionary(this.form.controls.options.controls.inputs) ?? undefined, + modality: this.form.controls.options.controls.modality.value || undefined, + cssClass: [`index-${index}`].concat(this.form.controls.options.controls.cssClass.value.split(/\s+/).filter(Boolean) || []), + animate: this.form.controls.options.controls.animate.value, + context: { + viewId: this.form.controls.options.controls.contextualViewId.value || undefined, + }, + }) + .then(result => this.returnValue = result) + .catch(error => this.dialogError = stringifyError(error) || 'Workbench Dialog was closed with an error'); + } + + private parseComponentFromUI(): Type { + switch (this.form.controls.component.value) { + case 'dialog-page': + return DialogPageComponent; + case 'dialog-opener-page': + return DialogOpenerPageComponent; + case 'focus-test-page': + return FocusTestPageComponent; + case 'blank': + return BlankTestPageComponent; + default: + throw Error(`[IllegalDialogComponent] Dialog component not supported: ${this.form.controls.component.value}`); + } + } + + /** + * Enables the field for setting a contextual view reference when choosing view modality. + */ + private installContextualViewIdEnabler(): void { + this.form.controls.options.controls.modality.valueChanges + .pipe( + startWith(this.form.controls.options.controls.modality.value), + takeUntilDestroyed(), + ) + .subscribe(modality => { + if (modality === 'view') { + this.form.controls.options.controls.contextualViewId.enable(); + } + else { + this.form.controls.options.controls.contextualViewId.setValue(''); + this.form.controls.options.controls.contextualViewId.disable(); + } + }); + } +} diff --git a/apps/workbench-testing-app/src/app/dialog-page/dialog-page.component.html b/apps/workbench-testing-app/src/app/dialog-page/dialog-page.component.html new file mode 100644 index 000000000..96749f0ae --- /dev/null +++ b/apps/workbench-testing-app/src/app/dialog-page/dialog-page.component.html @@ -0,0 +1,73 @@ +
+ + + + + + + + + + +
Size
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
Miscellaneous
+
+ + + + + + + + + + + + + +
+ + + +
Return Value
+
+ + + +
+
+ +
+ + +
diff --git a/apps/workbench-testing-app/src/app/dialog-page/dialog-page.component.scss b/apps/workbench-testing-app/src/app/dialog-page/dialog-page.component.scss new file mode 100644 index 000000000..9e994c13e --- /dev/null +++ b/apps/workbench-testing-app/src/app/dialog-page/dialog-page.component.scss @@ -0,0 +1,31 @@ +@use '@scion/components.internal/design' as sci-design; + +:host { + display: flex; + flex-direction: column; + + > form { + flex: auto; + display: flex; + flex-direction: column; + gap: 1em; + + > sci-accordion { + header { + font-weight: bold; + } + + &.return-value input { + @include sci-design.style-input-field(); + } + } + } + + > div.buttons { + flex: none; + display: grid; + grid-template-columns: 1fr 1fr; + column-gap: .25em; + margin-top: 1em; + } +} diff --git a/apps/workbench-testing-app/src/app/dialog-page/dialog-page.component.ts b/apps/workbench-testing-app/src/app/dialog-page/dialog-page.component.ts new file mode 100644 index 000000000..34d634555 --- /dev/null +++ b/apps/workbench-testing-app/src/app/dialog-page/dialog-page.component.ts @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2018-2023 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, Input} from '@angular/core'; +import {WorkbenchDialog} from '@scion/workbench'; +import {FormGroup, NonNullableFormBuilder, ReactiveFormsModule} from '@angular/forms'; +import {UUID} from '@scion/toolkit/uuid'; +import {NgIf} from '@angular/common'; +import {SciFormFieldComponent} from '@scion/components.internal/form-field'; +import {SciAccordionComponent, SciAccordionItemDirective} from '@scion/components.internal/accordion'; +import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; +import {SciCheckboxComponent} from '@scion/components.internal/checkbox'; + +@Component({ + selector: 'app-dialog-page', + templateUrl: './dialog-page.component.html', + styleUrls: ['./dialog-page.component.scss'], + standalone: true, + imports: [ + NgIf, + ReactiveFormsModule, + SciFormFieldComponent, + SciAccordionComponent, + SciAccordionItemDirective, + SciCheckboxComponent, + ], +}) +export class DialogPageComponent { + + public uuid = UUID.randomUUID(); + public form = this._formBuilder.group({ + title: this._formBuilder.control(''), + size: new FormGroup({ + minHeight: this._formBuilder.control(''), + height: this._formBuilder.control(''), + maxHeight: this._formBuilder.control(''), + minWidth: this._formBuilder.control(''), + width: this._formBuilder.control(''), + maxWidth: this._formBuilder.control(''), + }), + miscellaneous: new FormGroup({ + padding: this._formBuilder.control(''), + closable: this._formBuilder.control(true), + }), + result: this._formBuilder.control(''), + }); + + @Input() + public input: string | undefined; + + constructor(public dialog: WorkbenchDialog, private _formBuilder: NonNullableFormBuilder) { + this.installPropertyUpdater(); + } + + public onClose(): void { + this.dialog.close(this.form.controls.result.value); + } + + public onCloseWithError(): void { + this.dialog.closeWithError(this.form.controls.result.value); + } + + private installPropertyUpdater(): void { + this.form.controls.title.valueChanges + .pipe(takeUntilDestroyed()) + .subscribe(title => this.dialog.title = title); + + this.form.controls.size.controls.minWidth.valueChanges + .pipe(takeUntilDestroyed()) + .subscribe(minWidth => this.dialog.size.minWidth = minWidth); + + this.form.controls.size.controls.width.valueChanges + .pipe(takeUntilDestroyed()) + .subscribe(width => this.dialog.size.width = width); + + this.form.controls.size.controls.maxWidth.valueChanges + .pipe(takeUntilDestroyed()) + .subscribe(maxWidth => this.dialog.size.maxWidth = maxWidth); + + this.form.controls.size.controls.minHeight.valueChanges + .pipe(takeUntilDestroyed()) + .subscribe(minHeight => this.dialog.size.minHeight = minHeight); + + this.form.controls.size.controls.height.valueChanges + .pipe(takeUntilDestroyed()) + .subscribe(height => this.dialog.size.height = height); + + this.form.controls.size.controls.maxHeight.valueChanges + .pipe(takeUntilDestroyed()) + .subscribe(maxHeight => { + this.dialog.size.maxHeight = maxHeight; + }); + + this.form.controls.miscellaneous.controls.closable.valueChanges + .pipe(takeUntilDestroyed()) + .subscribe(closable => this.dialog.closable = closable); + + this.form.controls.miscellaneous.controls.padding.valueChanges + .pipe(takeUntilDestroyed()) + .subscribe(padding => this.dialog.padding = padding); + } +} diff --git a/apps/workbench-testing-app/src/app/view-page/view-page.component.html b/apps/workbench-testing-app/src/app/view-page/view-page.component.html index f928a1205..e912d8d89 100644 --- a/apps/workbench-testing-app/src/app/view-page/view-page.component.html +++ b/apps/workbench-testing-app/src/app/view-page/view-page.component.html @@ -61,10 +61,6 @@ - - - - diff --git a/apps/workbench-testing-app/src/app/workbench.config.ts b/apps/workbench-testing-app/src/app/workbench.config.ts index 5ef0d6087..a06298456 100644 --- a/apps/workbench-testing-app/src/app/workbench.config.ts +++ b/apps/workbench-testing-app/src/app/workbench.config.ts @@ -28,4 +28,7 @@ export const workbenchModuleConfig: WorkbenchModuleConfig = { ], initialPerspective: PerspectiveDefinitions.initialPerspective, }, + dialog: { + modalityScope: WorkbenchStartupQueryParams.dialogModalityScope(), + }, }; diff --git a/apps/workbench-testing-app/src/app/workbench/workbench-startup-query-params.ts b/apps/workbench-testing-app/src/app/workbench/workbench-startup-query-params.ts index 549ae67a2..7da7bf10e 100644 --- a/apps/workbench-testing-app/src/app/workbench/workbench-startup-query-params.ts +++ b/apps/workbench-testing-app/src/app/workbench/workbench-startup-query-params.ts @@ -43,6 +43,11 @@ export namespace WorkbenchStartupQueryParams { */ export const SIMULATE_SLOW_CAPABILITY_LOOKUP = 'simulateSlowCapabilityLookup'; + /** + * Query param to set the scope for workbench application-modal dialogs. + */ + export const DIALOG_MODALITY_SCOPE = 'dialogModalityScope'; + /** * Reads the query param to set the workbench launching strategy. */ @@ -50,6 +55,13 @@ export namespace WorkbenchStartupQueryParams { return new URL(window.location.href).searchParams.get(LAUNCHER_QUERY_PARAM) as 'APP_INITIALIZER' | 'LAZY' ?? undefined; } + /** + * Reads the query param to set the scope for workbench application-modal dialogs. + */ + export function dialogModalityScope(): 'workbench' | 'viewport' | undefined { + return new URL(window.location.href).searchParams.get(DIALOG_MODALITY_SCOPE) as 'workbench' | 'viewport' ?? undefined; + } + /** * Reads the query param to decide if to run the workbench standalone, or to start it with microfrontend support enabled. */ diff --git a/docs/site/announcements.md b/docs/site/announcements.md index f10a41b05..f87ac33e5 100644 --- a/docs/site/announcements.md +++ b/docs/site/announcements.md @@ -7,6 +7,9 @@ On this page you will find the latest news about the development of the SCION Workbench. +- **2023-11: Added Workbench Dialog**\ + SCION Workbench enables the display of a component in a modal dialog. A dialog can be view-modal or application-modal. + - **2023-10: Theming of SCION Workbench**\ SCION Workbench has introduced design tokens for applications to control the look of the workbench. diff --git a/docs/site/features.md b/docs/site/features.md index e362ddb3a..f96ecd053 100644 --- a/docs/site/features.md +++ b/docs/site/features.md @@ -12,29 +12,29 @@ This page gives you an overview of existing and planned workbench features. Deve [![][planned]](#) Planned       [![][deprecated]](#) Deprecated -|Feature|Category|Status|Note -|-|-|-|-| -|Workbench Layout|layout|[![][done]](#)|Layout for the flexible arrangement of views side-by-side or stacked, all personalizable by the user via drag & drop. -|Activity Layout|layout|[![][progress]](#)|Compact presentation of views around the main area, similar to activities known from Visual Studio Code or IntelliJ. -|Perspective|layout|[![][done]](#)|Multiple layouts, called perspectives, are supported. Perspectives can be switched with one perspective active at a time. Perspectives share the same main area, if any. [#305](https://github.com/SchweizerischeBundesbahnen/scion-workbench/issues/305). -|View|layout|[![][done]](#)|Visual component for displaying content stacked or side-by-side in the workbench layout. -|Multi-Window|layout|[![][done]](#)|Views can be opened in new browser windows. -|Part Actions|layout|[![][done]](#)|Actions that are displayed in the tabbar of a part. Actions can stick to a view, so they are only visible if the view is active. -|View Context Menu|layout|[![][done]](#)|A viewtab has a context menu. By default, the workbench adds some workbench-specific menu items to the context menu, such as for closing other views. Custom menu items can be added to the context menu as well. -|Persistent Navigation|navigation|[![][done]](#)|The arrangement of the views is added to the browser URL or local storage, enabling persistent navigation. -|Start Page|layout|[![][done]](#)|A start page can be used to display content when all views are closed. -|Microfrontend Support|microfrontend|[![][done]](#)|Microfrontends can be opened in views. Embedded microfrontends can interact with the workbench using a framework-angostic workbench API. The documentation is still missing. [#304](https://github.com/SchweizerischeBundesbahnen/scion-workbench/issues/304). -|Theming|customization|[![][done]](#)|An application can define a custom theme to change the default look of the SCION Workbench. [#110](https://github.com/SchweizerischeBundesbahnen/scion-workbench/issues/110) -|Responsive Design|layout|[![][planned]](#)|The workbench adapts its layout to the current display size and device. [#112](https://github.com/SchweizerischeBundesbahnen/scion-workbench/issues/112) -|Electron/Edge Webview 2|env|[![][planned]](#)|The workbench can be used in desktop applications built with [Electron](https://www.electronjs.org/) and/or [Microsoft Edge WebView2](https://docs.microsoft.com/en-us/microsoft-edge/webview2/) to support window arrangements. [#306](https://github.com/SchweizerischeBundesbahnen/scion-workbench/issues/306) -|Localization (l10n)|env|[![][planned]](#)|The workbench allows the localization of built-in texts such as texts in context menus and manifest entries. [#255](https://github.com/SchweizerischeBundesbahnen/scion-workbench/issues/255) -|Browser Support|env|[![][planned]](#)|The workbench works with most modern browsers. As of now, the workbench is optimized and tested on browsers based on the Chromium rendering engine (Google Chrome, Microsoft Edge). However, the workbench should work fine on other modern browsers as well. [#111](https://github.com/SchweizerischeBundesbahnen/scion-workbench/issues/111) -|Message Box|control|[![][done]](#)|The workbench allows displaying content in a message box. The message box can be either view or application modal. -|Notification Ribbon|control|[![][done]](#)|The workbench allows showing content in notifications ribbons. Notifications slide in at the upper-right corner. Multiple notifications are displayed one below the other. -|Popup|control|[![][done]](#)|The workbench allows displaying content in a popup overlay. -|Dialog|control|[![][progress]](#)|The workbench allows displaying content in a dialog overlay. -|Developer guide|doc|[![][planned]](#)|Developer Guide describing the workbench layout, its conceptsm fundamental APIs and built-in microfrontend support. [#304](https://github.com/SchweizerischeBundesbahnen/scion-workbench/issues/304) -|Tab|customization|[![][done]](#)|The built-in viewtab can be replaced with a custom viewtab implementation, e.g., to add additional functionality. +| Feature |Category|Status|Note +|-------------------------|-|-|-| +| Workbench Layout |layout|[![][done]](#)|Layout for the flexible arrangement of views side-by-side or stacked, all personalizable by the user via drag & drop. +| Activity Layout |layout|[![][progress]](#)|Compact presentation of views around the main area, similar to activities known from Visual Studio Code or IntelliJ. +| Perspective |layout|[![][done]](#)|Multiple layouts, called perspectives, are supported. Perspectives can be switched with one perspective active at a time. Perspectives share the same main area, if any. [#305](https://github.com/SchweizerischeBundesbahnen/scion-workbench/issues/305). +| View |layout|[![][done]](#)|Visual component for displaying content stacked or side-by-side in the workbench layout. +| Multi-Window |layout|[![][done]](#)|Views can be opened in new browser windows. +| Part Actions |layout|[![][done]](#)|Actions that are displayed in the tabbar of a part. Actions can stick to a view, so they are only visible if the view is active. +| View Context Menu |layout|[![][done]](#)|A viewtab has a context menu. By default, the workbench adds some workbench-specific menu items to the context menu, such as for closing other views. Custom menu items can be added to the context menu as well. +| Persistent Navigation |navigation|[![][done]](#)|The arrangement of the views is added to the browser URL or local storage, enabling persistent navigation. +| Start Page |layout|[![][done]](#)|A start page can be used to display content when all views are closed. +| Microfrontend Support |microfrontend|[![][done]](#)|Microfrontends can be opened in views. Embedded microfrontends can interact with the workbench using a framework-angostic workbench API. The documentation is still missing. [#304](https://github.com/SchweizerischeBundesbahnen/scion-workbench/issues/304). +| Theming |customization|[![][done]](#)|An application can define a custom theme to change the default look of the SCION Workbench. [#110](https://github.com/SchweizerischeBundesbahnen/scion-workbench/issues/110) +| Responsive Design |layout|[![][planned]](#)|The workbench adapts its layout to the current display size and device. [#112](https://github.com/SchweizerischeBundesbahnen/scion-workbench/issues/112) +| Electron/Edge Webview 2 |env|[![][planned]](#)|The workbench can be used in desktop applications built with [Electron](https://www.electronjs.org/) and/or [Microsoft Edge WebView2](https://docs.microsoft.com/en-us/microsoft-edge/webview2/) to support window arrangements. [#306](https://github.com/SchweizerischeBundesbahnen/scion-workbench/issues/306) +| Localization (l10n) |env|[![][planned]](#)|The workbench allows the localization of built-in texts such as texts in context menus and manifest entries. [#255](https://github.com/SchweizerischeBundesbahnen/scion-workbench/issues/255) +| Browser Support |env|[![][planned]](#)|The workbench works with most modern browsers. As of now, the workbench is optimized and tested on browsers based on the Chromium rendering engine (Google Chrome, Microsoft Edge). However, the workbench should work fine on other modern browsers as well. [#111](https://github.com/SchweizerischeBundesbahnen/scion-workbench/issues/111) +| Dialog |control|[![][progress]](#)|Content can be displayed in a modal dialog. A dialog can be view or application modal. Multiple dialogs are stacked. +| Message Box |control|[![][done]](#)|Content can be displayed in a modal message box. A message box can be view or application modal. Multiple message boxes are stacked. +| Notification Ribbon |control|[![][done]](#)|Notifications can be displayed to the user. Notifications slide in in the upper-right corner. Multiple notifications are displayed one below the other. +| Popup |control|[![][done]](#)|Content can be displayed in a popup overlay. A popup does not block the application. +| Developer guide |doc|[![][planned]](#)|Developer Guide describing the workbench layout, its conceptsm fundamental APIs and built-in microfrontend support. [#304](https://github.com/SchweizerischeBundesbahnen/scion-workbench/issues/304) +| Tab |customization|[![][done]](#)|The built-in viewtab can be replaced with a custom viewtab implementation, e.g., to add additional functionality. [done]: /docs/site/images/icon-done.svg [progress]: /docs/site/images/icon-in-progress.svg diff --git a/docs/site/howto/how-to-open-dialog.md b/docs/site/howto/how-to-open-dialog.md new file mode 100644 index 000000000..23a46a599 --- /dev/null +++ b/docs/site/howto/how-to-open-dialog.md @@ -0,0 +1,125 @@ +SCION Workbench + +| SCION Workbench | [Projects Overview][menu-projects-overview] | [Changelog][menu-changelog] | [Contributing][menu-contributing] | [Sponsoring][menu-sponsoring] | +| --- | --- | --- | --- | --- | + +## [SCION Workbench][menu-home] > [How To Guides][menu-how-to] > Dialog + +A dialog is a visual element for focused interaction with the user, such as prompting the user for input or confirming actions. The user can move or resize a dialog. + +Displayed on top of other content, a dialog blocks interaction with other parts of the application. A dialog can be view-modal or application-modal. Multiple dialogs are stacked, and only the topmost dialog in each modality stack can be interacted with. + +### How to open a dialog +To open a dialog, inject `WorkbenchDialogService` and invoke the `open` method, passing the component to display. + +```ts +const dialogService = inject(WorkbenchDialogService); + +dialogService.open(MyDialogComponent); +``` + +### How to control the modality of a dialog +A dialog can be view-modal or application-modal. A view-modal dialog blocks only a specific view, allowing the user to interact with other views. An application-modal dialog blocks the workbench by default, or the browser's viewport, if set in the global workbench settings. + +By default, the calling context determines the modality of the dialog. If the dialog is opened from a view, only this view is blocked. To open the dialog with a different modality, specify the modality in the dialog options. + +```ts +const dialogService = inject(WorkbenchDialogService); + +dialogService.open(MyDialogComponent, { + modality: 'application', +}); +``` + +An application-modal dialog blocks the workbench element, still allowing interaction with elements outside the workbench element. To block the entire browser viewport, change the global modality scope setting in the workbench module configuration. + +```ts +import {WorkbenchModule} from '@scion/workbench'; + +WorkbenchModule.forRoot({ + dialog: { + modalityScope: 'viewport', + }, + ... // ommited configuration +}); +``` + +### How to pass data to the dialog +Data can be passed to the dialog component as inputs in the dialog options. + + +```ts +const dialogService = inject(WorkbenchDialogService); + +dialogService.open(MyDialogComponent, { + inputs: { + firstname: 'Firstname', + lastname: 'Lastname' + }, +}); +``` + +Dialog inputs are available as input properties in the dialog component. + +```ts +@Component({...}) +export class MyDialogComponent { + + @Input() + public firstname: string; + + @Input() + public lastname: string; +} +``` + +### How to set a dialog title +The dialog component can inject the `WorkbenchDialog` handle and set the title. + +```ts +inject(WorkbenchDialog).title = 'My dialog title'; +``` + +### How to close the dialog +The dialog component can inject the `WorkbenchDialog` handle and close the dialog, optionally passing a result to the dialog opener. + +```ts +// Closes the dialog. +inject(WorkbenchDialog).close(); + +// Closes the dialog with a result. +inject(WorkbenchDialog).close('some result'); +``` + +Opening the dialog returns a Promise, that resolves to the result when the dialog is closed. + +```ts +const dialogService = inject(WorkbenchDialogService); + +const result = await dialogService.open(MyDialogComponent); +``` + +### How to size the dialog +The dialog handle can be used to specify a preferred size, displaying scrollbar(s) if the component overflows. If no size is specified, the dialog has the size of the component. + +```ts +// Sets a fixed size. +inject(WorkbenchDialog).size.height = '500px'; +inject(WorkbenchDialog).size.width = '600px'; + +// Sets the minimum size of the dialog. +inject(WorkbenchDialog).size.minHeight = '300px'; +inject(WorkbenchDialog).size.minWidth = '200px'; + +// Sets the maximum size of the dialog. +inject(WorkbenchDialog).size.maxHeight = '900px'; +inject(WorkbenchDialog).size.maxWidth = '700px'; +``` + +[menu-how-to]: /docs/site/howto/how-to.md + +[menu-home]: /README.md +[menu-projects-overview]: /docs/site/projects-overview.md +[menu-changelog]: /docs/site/changelog.md +[menu-contributing]: /CONTRIBUTING.md +[menu-sponsoring]: /docs/site/sponsoring.md diff --git a/docs/site/howto/how-to.md b/docs/site/howto/how-to.md index 03195a04b..9c78d7f09 100644 --- a/docs/site/howto/how-to.md +++ b/docs/site/howto/how-to.md @@ -35,6 +35,7 @@ We are working on a comprehensive guide that explains the features and concepts #### Miscellaneous +- [How to open a dialog](how-to-open-dialog.md) - [How to open a popup](how-to-open-popup.md) - [How to show a message box](how-to-show-message-box.md) - [How to show a notification](how-to-show-notification.md) diff --git a/projects/scion/e2e-testing/src/app.po.ts b/projects/scion/e2e-testing/src/app.po.ts index 3e170f324..28fdb29c6 100644 --- a/projects/scion/e2e-testing/src/app.po.ts +++ b/projects/scion/e2e-testing/src/app.po.ts @@ -18,6 +18,7 @@ import {PopupPO} from './popup.po'; import {MessageBoxPO} from './message-box.po'; import {NotificationPO} from './notification.po'; import {AppHeaderPO} from './app-header.po'; +import {DialogPO} from './dialog.po'; export class AppPO { @@ -47,6 +48,7 @@ export class AppPO { this._workbenchStartupQueryParams.append(WorkenchStartupQueryParams.CONFIRM_STARTUP, `${options?.confirmStartup ?? false}`); this._workbenchStartupQueryParams.append(WorkenchStartupQueryParams.SIMULATE_SLOW_CAPABILITY_LOOKUP, `${options?.simulateSlowCapabilityLookup ?? false}`); this._workbenchStartupQueryParams.append(WorkenchStartupQueryParams.PERSPECTIVES, `${(options?.perspectives ?? []).join(';')}`); + this._workbenchStartupQueryParams.append(WorkenchStartupQueryParams.DIALOG_MODALITY_SCOPE, options?.dialogModalityScope ?? 'workbench'); const featureQueryParams = new URLSearchParams(); if (options?.stickyStartViewTab !== undefined) { @@ -193,6 +195,20 @@ export class AppPO { }; } + /** + * Returns bounding box of the 'wb-workbench' element. + */ + public async workbenchBoundingBox(): Promise { + return fromRect(await this.workbenchLocator.boundingBox()); + } + + /** + * Returns the bounding box of the browser page viewport. + */ + public async pageBoundingBox(): Promise { + return fromRect(await this.page.viewportSize()); + } + /** * Handle to the specified notification. */ @@ -211,6 +227,15 @@ export class AppPO { return new MessageBoxPO(locateBy?.nth !== undefined ? locator.nth(locateBy.nth) : locator); } + /** + * Handle to the specified dialog. + */ + public dialog(locateBy?: {cssClass?: string | string[]; nth?: number}): DialogPO { + const cssClasses = coerceArray(locateBy?.cssClass).map(cssClass => cssClass.replace(/\./g, '\\.')); + const locator = this.page.locator(['wb-dialog'].concat(cssClasses).join('.')); + return new DialogPO(locateBy?.nth !== undefined ? locator.nth(locateBy.nth) : locator); + } + /** * Returns the number of opened message boxes. */ @@ -218,6 +243,13 @@ export class AppPO { return this.page.locator('wb-message-box').count(); } + /** + * Returns the number of opened dialogs. + */ + public getDialogCount(): Promise { + return this.page.locator('wb-dialog').count(); + } + /** * Returns the number of displayed notifications. */ @@ -352,6 +384,10 @@ export interface Options { * Specifies perspectives to be registered in the testing app. Separate multiple perspectives by semicolon. */ perspectives?: string[]; + /** + * Controls the scope of application-modal workbench dialogs. By default, if not specified, workbench scope will be used. + */ + dialogModalityScope?: 'workbench' | 'viewport'; } /** @@ -382,4 +418,9 @@ export enum WorkenchStartupQueryParams { * Query param to register perspectives. Multiple perspectives are separated by semicolon. */ PERSPECTIVES = 'perspectives', + + /** + * Query param to set the scope for application-modal dialogs. + */ + DIALOG_MODALITY_SCOPE = 'dialogModalityScope', } diff --git a/projects/scion/e2e-testing/src/dialog-page.po.ts b/projects/scion/e2e-testing/src/dialog-page.po.ts new file mode 100644 index 000000000..a8069fe5e --- /dev/null +++ b/projects/scion/e2e-testing/src/dialog-page.po.ts @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2018-2023 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 {Locator} from '@playwright/test'; +import {SciAccordionPO} from './@scion/components.internal/accordion.po'; +import {SciCheckboxPO} from './@scion/components.internal/checkbox.po'; +import {DialogPO} from './dialog.po'; + +/** + * Page object to interact with {@link DialogPageComponent}. + */ +export class DialogPagePO { + + public readonly locator: Locator; + public readonly input: Locator; + private readonly _title: Locator; + private readonly _closeButton: Locator; + private readonly _closeWithErrorButton: Locator; + private readonly _sizeAccordion: SciAccordionPO; + private readonly _miscellaneousAccordion: SciAccordionPO; + private readonly _returnValueAccordion: SciAccordionPO; + + constructor(dialog: DialogPO) { + this.locator = dialog.locator.locator('app-dialog-page'); + this.input = this.locator.locator('input.e2e-input'); + this._title = this.locator.locator('input.e2e-title'); + this._sizeAccordion = new SciAccordionPO(this.locator.locator('sci-accordion.e2e-size')); + this._miscellaneousAccordion = new SciAccordionPO(this.locator.locator('sci-accordion.e2e-miscellaneous')); + this._returnValueAccordion = new SciAccordionPO(this.locator.locator('sci-accordion.e2e-return-value')); + this._closeButton = this.locator.locator('button.e2e-close'); + this._closeWithErrorButton = this.locator.locator('button.e2e-close-with-error'); + } + + public async getComponentInstanceId(): Promise { + await this._miscellaneousAccordion.expand(); + try { + return await this._miscellaneousAccordion.itemLocator().locator('input.e2e-component-instance-id').inputValue(); + } + finally { + await this._miscellaneousAccordion.collapse(); + } + } + + public async enterTitle(title: string): Promise { + await this._title.fill(title); + } + + public async setClosable(closable: boolean): Promise { + await this._miscellaneousAccordion.expand(); + try { + await new SciCheckboxPO(this._miscellaneousAccordion.itemLocator().locator('sci-checkbox.e2e-closable')).toggle(closable); + } + finally { + await this._miscellaneousAccordion.collapse(); + } + } + + public async close(options?: {returnValue?: string; closeWithError?: boolean}): Promise { + if (options?.returnValue !== undefined) { + await this.enterReturnValue(options.returnValue); + } + + if (options?.closeWithError) { + await this._closeWithErrorButton.click(); + } + else { + await this._closeButton.click(); + } + } + + private async enterReturnValue(returnValue: string): Promise { + await this._returnValueAccordion.expand(); + try { + await this._returnValueAccordion.itemLocator().locator('input.e2e-return-value').fill(returnValue); + } + finally { + await this._returnValueAccordion.collapse(); + } + } +} diff --git a/projects/scion/e2e-testing/src/dialog.po.ts b/projects/scion/e2e-testing/src/dialog.po.ts new file mode 100644 index 000000000..bc22f9fe3 --- /dev/null +++ b/projects/scion/e2e-testing/src/dialog.po.ts @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2018-2023 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 {Locator} from '@playwright/test'; +import {fromRect} from './helper/testing.util'; + +/** + * PO for interacting with a workbench dialog. + */ +export class DialogPO { + + public readonly header: Locator; + public readonly title: Locator; + public readonly closeButton: Locator; + + constructor(public readonly locator: Locator) { + this.header = this.locator.locator('header'); + this.title = this.locator.locator('header > div.e2e-title > span'); + this.closeButton = locator.locator('header > button.e2e-close'); + } + + public async getDialogBoundingBox(): Promise { + return fromRect(await this.locator.locator('.e2e-dialog-pane').boundingBox()); + } + + public async getGlassPaneBoundingBox(): Promise { + return fromRect(await this.locator.page().locator('.cdk-overlay-pane.wb-dialog-glass-pane', {has: this.locator}).boundingBox()); + } + + public async close(options?: {timeout?: number}): Promise { + await this.closeButton.click(options); + } + + public async clickHeader(options?: {timeout?: number}): Promise { + await this.header.click(options); + } +} diff --git a/projects/scion/e2e-testing/src/view-tab-context-menu.po.ts b/projects/scion/e2e-testing/src/view-tab-context-menu.po.ts index 484e51e35..f7049b8b1 100644 --- a/projects/scion/e2e-testing/src/view-tab-context-menu.po.ts +++ b/projects/scion/e2e-testing/src/view-tab-context-menu.po.ts @@ -35,14 +35,10 @@ export class ViewTabContextMenuPO { export class ContextMenuItem { - constructor(private _locator: Locator) { + constructor(public locator: Locator) { } public async click(): Promise { - await this._locator.click(); - } - - public isDisabled(): Promise { - return this._locator.isDisabled(); + await this.locator.click(); } } diff --git a/projects/scion/e2e-testing/src/view-tab.po.ts b/projects/scion/e2e-testing/src/view-tab.po.ts index 48d14782c..f8c5d0c13 100644 --- a/projects/scion/e2e-testing/src/view-tab.po.ts +++ b/projects/scion/e2e-testing/src/view-tab.po.ts @@ -26,79 +26,80 @@ export class ViewTabPO { /** * Locates the title of the view tab. */ - public readonly titleLocator = this._locator.locator('.e2e-title'); + public readonly title = this.locator.locator('.e2e-title'); /** * Locates the heading of the view tab. */ - public readonly headingLocator = this._locator.locator('.e2e-heading'); + public readonly heading = this.locator.locator('.e2e-heading'); - constructor(private readonly _locator: Locator, part: PartPO) { + /** + * Locates the close button of the view tab. + */ + public readonly closeButton = this.locator.locator('.e2e-close'); + + constructor(public readonly locator: Locator, part: PartPO) { this.part = part; } public async getViewId(): Promise { - return (await this._locator.getAttribute('data-viewid'))!; + return (await this.locator.getAttribute('data-viewid'))!; } public async isPresent(): Promise { - return isPresent(this._locator); + return isPresent(this.locator); } public async click(): Promise { - await this._locator.click(); + await this.locator.click(); } public async dblclick(): Promise { - await this._locator.dblclick(); + await this.locator.dblclick(); } /** * Performs a mouse down on this view tab. */ public async mousedown(): Promise { - const bounds = fromRect(await this._locator.boundingBox()); - const mouse = this._locator.page().mouse; + const bounds = fromRect(await this.locator.boundingBox()); + const mouse = this.locator.page().mouse; await mouse.move(bounds.hcenter, bounds.vcenter); await mouse.down(); } public async close(): Promise { - await this._locator.hover(); - await this._locator.locator('.e2e-close').click(); + await this.locator.hover(); + await this.locator.locator('.e2e-close').click(); } public getTitle(): Promise { - return waitUntilStable(() => this.titleLocator.innerText()); + return waitUntilStable(() => this.title.innerText()); } public getHeading(): Promise { - return waitUntilStable(() => this.headingLocator.innerText()); + return waitUntilStable(() => this.heading.innerText()); } public isDirty(): Promise { - return waitUntilStable(() => hasCssClass(this._locator, 'e2e-dirty')); - } - - public async isClosable(): Promise { - return (await waitUntilStable(() => this._locator.locator('.e2e-close').count()) !== 0); + return waitUntilStable(() => hasCssClass(this.locator, 'e2e-dirty')); } public isActive(): Promise { - return hasCssClass(this._locator, 'active'); + return hasCssClass(this.locator, 'active'); } public getCssClasses(): Promise { - return getCssClasses(this._locator); + return getCssClasses(this.locator); } /** * Opens the context menu of this view tab. */ public async openContextMenu(): Promise { - await this._locator.click({button: 'right'}); + await this.locator.click({button: 'right'}); const viewId = await this.getViewId(); - return new ViewTabContextMenuPO(this._locator.page().locator(`div.cdk-overlay-pane wb-view-menu[data-viewid="${viewId}"]`)); + return new ViewTabContextMenuPO(this.locator.page().locator(`div.cdk-overlay-pane wb-view-menu[data-viewid="${viewId}"]`)); } /** @@ -108,16 +109,16 @@ export class ViewTabPO { public async moveTo(target: {grid: 'workbench' | 'mainArea'}, options?: {steps?: number}): Promise; public async moveTo(target: {partId?: string; grid?: 'workbench' | 'mainArea'}, options?: {steps?: number}): Promise { // 1. Perform a "mousedown" on the view tab. - const mouse = this._locator.page().mouse; + const mouse = this.locator.page().mouse; await this.mousedown(); // 2. Locate the target. const targetLocator = (() => { if (target.partId) { - return this._locator.page().locator(`wb-part[data-partid="${target.partId}"]`).locator('div.e2e-active-view'); + return this.locator.page().locator(`wb-part[data-partid="${target.partId}"]`).locator('div.e2e-active-view'); } else { - return this._locator.page().locator(target.grid === 'mainArea' ? 'wb-main-area-layout' : 'wb-workbench-layout'); + return this.locator.page().locator(target.grid === 'mainArea' ? 'wb-main-area-layout' : 'wb-workbench-layout'); } })(); @@ -153,17 +154,17 @@ export class ViewTabPO { // 2. Locate the drop zone. const dropZoneLocator = (() => { if (target.partId) { - return this._locator.page().locator(`wb-part[data-partid="${target.partId}"]`).locator(`div.e2e-view-drop-zone.e2e-${target.region}.e2e-part`); + return this.locator.page().locator(`wb-part[data-partid="${target.partId}"]`).locator(`div.e2e-view-drop-zone.e2e-${target.region}.e2e-part`); } else { const dropZoneCssClass = target.grid === 'mainArea' ? 'e2e-main-area-grid' : 'e2e-workbench-grid'; - return this._locator.page().locator(`div.e2e-view-drop-zone.e2e-${target.region}.${dropZoneCssClass}`); + return this.locator.page().locator(`div.e2e-view-drop-zone.e2e-${target.region}.${dropZoneCssClass}`); } })(); // 3. Move the view tab over the drop zone. const dropZoneBounds = fromRect(await dropZoneLocator.boundingBox()); - const mouse = this._locator.page().mouse; + const mouse = this.locator.page().mouse; switch (target.region) { case 'north': // Moves the mouse to the bottom edge, just one pixel inside the drop zone @@ -189,7 +190,7 @@ export class ViewTabPO { // 4. Perform a "mouseup". if (options?.performDrop ?? true) { - await this._locator.page().mouse.up(); + await this.locator.page().mouse.up(); } } } 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 index e5979839a..10749629e 100644 --- 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 @@ -130,16 +130,16 @@ export class PopupPagePO { } } - public async clickClose(options?: {returnValue?: string; closeWithError?: boolean}): Promise { + public async clickClose(options?: {returnValue?: string; closeWithError?: boolean; timeout?: number}): Promise { if (options?.returnValue !== undefined) { await this.enterReturnValue(options.returnValue); } if (options?.closeWithError === true) { - await this.locator.locator('button.e2e-close-with-error').click(); + await this.locator.locator('button.e2e-close-with-error').click({timeout: options?.timeout}); } else { - await this.locator.locator('button.e2e-close').click(); + await this.locator.locator('button.e2e-close').click({timeout: options?.timeout}); } } diff --git a/projects/scion/e2e-testing/src/workbench-client/router.e2e-spec.ts b/projects/scion/e2e-testing/src/workbench-client/router.e2e-spec.ts index b0334e229..95f955345 100644 --- a/projects/scion/e2e-testing/src/workbench-client/router.e2e-spec.ts +++ b/projects/scion/e2e-testing/src/workbench-client/router.e2e-spec.ts @@ -1132,7 +1132,7 @@ test.describe('Workbench Router', () => { await expect(await testeeView.viewTab.getTitle()).toEqual('VIEW TITLE 1'); await expect(await testeeView.viewTab.getHeading()).toEqual('VIEW HEADING 1'); await expect(await testeeView.viewTab.getCssClasses()).toEqual(expect.arrayContaining(['testee-1', 'class-1'])); - await expect(await testeeView.viewTab.isClosable()).toBe(true); + await expect(testeeView.viewTab.closeButton).toBeVisible(); // navigate to the testee-2 view await routerPage.viewTab.click(); @@ -1145,7 +1145,7 @@ test.describe('Workbench Router', () => { await expect(await testeeView.viewTab.getTitle()).toEqual('VIEW TITLE 2'); await expect(await testeeView.viewTab.getHeading()).toEqual('VIEW HEADING 2'); await expect(await testeeView.viewTab.getCssClasses()).toEqual(expect.arrayContaining(['testee-2', 'class-2'])); - await expect(await testeeView.viewTab.isClosable()).toBe(false); + await expect(testeeView.viewTab.closeButton).not.toBeVisible(); }); test('should not set view properties when performing self navigation, e.g., when updating view params', async ({appPO, microfrontendNavigator}) => { diff --git a/projects/scion/e2e-testing/src/workbench-client/view-properties.e2e-spec.ts b/projects/scion/e2e-testing/src/workbench-client/view-properties.e2e-spec.ts index e4651a4d3..57e2af25e 100644 --- a/projects/scion/e2e-testing/src/workbench-client/view-properties.e2e-spec.ts +++ b/projects/scion/e2e-testing/src/workbench-client/view-properties.e2e-spec.ts @@ -204,7 +204,7 @@ test.describe('Workbench View Properties', () => { await routerPage.enterParams({closable: 'true,false'}); await routerPage.enterTarget(viewId); await routerPage.clickNavigate(); - await expect(await appPO.view({viewId}).viewTab.isClosable()).toBe(false); + await expect(appPO.view({viewId}).viewTab.closeButton).not.toBeVisible(); }); await test.step('navigating to new view [target="blank"]', async () => { @@ -218,7 +218,7 @@ test.describe('Workbench View Properties', () => { const viewId = await appPO.view({cssClass: 'testee-blank'}).getViewId(); const viewPropertiesTest = new ViewPropertiesTestPagePO(appPO, viewId); await viewPropertiesTest.waitUntilPresent(); - await expect(await viewPropertiesTest.viewTab.isClosable()).toBe(false); + await expect(viewPropertiesTest.viewTab.closeButton).not.toBeVisible(); }); }); @@ -245,7 +245,7 @@ test.describe('Workbench View Properties', () => { await routerPage.enterParams({closable: 'true,false,true'}); await routerPage.enterTarget(viewId); await routerPage.clickNavigate(); - await expect(await appPO.view({viewId}).viewTab.isClosable()).toBe(true); + await expect(appPO.view({viewId}).viewTab.closeButton).toBeVisible(); }); await test.step('navigating to new view [target="blank"]', async () => { @@ -259,7 +259,7 @@ test.describe('Workbench View Properties', () => { const viewId = await appPO.view({cssClass: 'testee-blank'}).getViewId(); const viewPropertiesTest = new ViewPropertiesTestPagePO(appPO, viewId); await viewPropertiesTest.waitUntilPresent(); - await expect(await viewPropertiesTest.viewTab.isClosable()).toBe(true); + await expect(viewPropertiesTest.viewTab.closeButton).toBeVisible(); }); }); }); diff --git a/projects/scion/e2e-testing/src/workbench-client/view.e2e-spec.ts b/projects/scion/e2e-testing/src/workbench-client/view.e2e-spec.ts index 8194ba7b4..6416317f5 100644 --- a/projects/scion/e2e-testing/src/workbench-client/view.e2e-spec.ts +++ b/projects/scion/e2e-testing/src/workbench-client/view.e2e-spec.ts @@ -198,10 +198,10 @@ test.describe('Workbench View', () => { const viewTab = viewPage.view.viewTab; await viewPage.checkClosable(true); - await expect(await viewTab.isClosable()).toBe(true); + await expect(viewTab.closeButton).toBeVisible(); await viewPage.checkClosable(false); - await expect(await viewTab.isClosable()).toBe(false); + await expect(viewTab.closeButton).not.toBeVisible(); }); test('should allow closing the view', async ({appPO, microfrontendNavigator}) => { diff --git a/projects/scion/e2e-testing/src/workbench/contextual-view.e2e-spec.ts b/projects/scion/e2e-testing/src/workbench/contextual-view.e2e-spec.ts deleted file mode 100644 index f1265c0ba..000000000 --- a/projects/scion/e2e-testing/src/workbench/contextual-view.e2e-spec.ts +++ /dev/null @@ -1,132 +0,0 @@ -/* - * Copyright (c) 2018-2022 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 {expect} from '@playwright/test'; -import {test} from '../fixtures'; -import {ViewPagePO} from './page-object/view-page.po'; -import {MessageBoxOpenerPagePO} from './page-object/message-box-opener-page.po'; -import {PopupOpenerPagePO} from './page-object/popup-opener-page.po'; - -test.describe('Contextual Workbench View', () => { - - test('should detach message box if contextual view is not active', async ({appPO, workbenchNavigator}) => { - await appPO.navigateTo({microfrontendSupport: false}); - - // Open message box. - const messageBoxOpenerView = await workbenchNavigator.openInNewTab(MessageBoxOpenerPagePO); - await messageBoxOpenerView.enterCssClass('testee'); - await messageBoxOpenerView.clickOpen(); - - const messageBox = appPO.messagebox({cssClass: 'testee'}); - await expect(messageBox.locator).toBeVisible(); - - // Open another view. - await appPO.openNewViewTab(); - await expect(messageBox.locator).not.toBeVisible(); - - // Activate message box opener view. - await messageBoxOpenerView.viewTab.click(); - await expect(messageBox.locator).toBeVisible(); - }); - - test('should detach message box if contextual view is opened in peripheral area and the main area is maximized', async ({appPO, workbenchNavigator}) => { - await appPO.navigateTo({microfrontendSupport: false}); - - // Open view in main area. - const viewPageInMainArea = await workbenchNavigator.openInNewTab(ViewPagePO); - - // Open message box opener view. - const messageBoxOpenerView = await workbenchNavigator.openInNewTab(MessageBoxOpenerPagePO); - - // Drag message box opener view into peripheral area. - await messageBoxOpenerView.viewTab.dragTo({grid: 'workbench', region: 'east'}); - - // Open message box. - await messageBoxOpenerView.enterCssClass('testee'); - await messageBoxOpenerView.clickOpen(); - - const messageBox = appPO.messagebox({cssClass: 'testee'}); - await expect(messageBox.locator).toBeVisible(); - - // Maximize the main area. - await viewPageInMainArea.viewTab.dblclick(); - await expect(messageBoxOpenerView.view.locator).not.toBeVisible(); - await expect(messageBox.locator).not.toBeVisible(); - - // Restore the layout. - await viewPageInMainArea.viewTab.dblclick(); - await expect(messageBoxOpenerView.view.locator).toBeVisible(); - await expect(messageBox.locator).toBeVisible(); - }); - - test('should detach popup if contextual view is not active', async ({appPO, workbenchNavigator}) => { - await appPO.navigateTo({microfrontendSupport: false}); - - // Open popop. - const popupOpenerView = await workbenchNavigator.openInNewTab(PopupOpenerPagePO); - await popupOpenerView.enterCssClass('testee'); - await popupOpenerView.selectPopupComponent('popup-page'); - await popupOpenerView.enterCloseStrategy({closeOnFocusLost: false}); - await popupOpenerView.clickOpen(); - - const popup = appPO.popup({cssClass: 'testee'}); - await expect(popup.locator).toBeVisible(); - - // Open another view. - await appPO.openNewViewTab(); - await expect(popup.locator).not.toBeVisible(); - - // Activate popup opener view. - await popupOpenerView.viewTab.click(); - await expect(popup.locator).toBeVisible(); - }); - - test('should detach popup if contextual view is opened in peripheral area and the main area is maximized', async ({appPO, workbenchNavigator}) => { - await appPO.navigateTo({microfrontendSupport: false}); - - // Open view in main area. - const viewPageInMainArea = await workbenchNavigator.openInNewTab(ViewPagePO); - - // Open popup opener view. - const popupOpenerView = await workbenchNavigator.openInNewTab(PopupOpenerPagePO); - - // Drag popup opener view into peripheral area. - await popupOpenerView.viewTab.dragTo({grid: 'workbench', region: 'east'}); - - // Open popup. - await popupOpenerView.enterCssClass('testee'); - await popupOpenerView.selectPopupComponent('popup-page'); - await popupOpenerView.enterCloseStrategy({closeOnFocusLost: false}); - await popupOpenerView.clickOpen(); - - const popup = appPO.popup({cssClass: 'testee'}); - await expect(popup.locator).toBeVisible(); - - // Maximize the main area. - await viewPageInMainArea.viewTab.dblclick(); - await expect(popupOpenerView.view.locator).not.toBeVisible(); - await expect(popup.locator).not.toBeVisible(); - - // Restore the layout. - await viewPageInMainArea.viewTab.dblclick(); - await expect(popupOpenerView.view.locator).toBeVisible(); - await expect(popup.locator).toBeVisible(); - }); - - // TODO [#488]: Implement test for feature #488. - test('should detach dialog if contextual view is not active', async () => { - expect(true).toBe(true); - }); - - // TODO [#488]: Implement test for feature #488. - test('should detach dialog if contextual view is opened in peripheral area and the main area is maximized', async () => { - expect(true).toBe(true); - }); -}); diff --git a/projects/scion/e2e-testing/src/workbench/dialog.e2e-spec.ts b/projects/scion/e2e-testing/src/workbench/dialog.e2e-spec.ts new file mode 100644 index 000000000..24d63ee4d --- /dev/null +++ b/projects/scion/e2e-testing/src/workbench/dialog.e2e-spec.ts @@ -0,0 +1,702 @@ +/* + * Copyright (c) 2018-2023 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 {expect} from '@playwright/test'; +import {test} from '../fixtures'; +import {DialogOpenerPagePO} from './page-object/dialog-opener-page.po'; +import {DialogPagePO} from '../dialog-page.po'; +import {ViewPagePO} from './page-object/view-page.po'; +import {PopupOpenerPagePO} from './page-object/popup-opener-page.po'; +import {PopupPagePO} from '../workbench-client/page-object/popup-page.po'; +import {FocusTestPagePO} from './page-object/test-pages/focus-test-page.po'; + +test.describe('Workbench Dialog', () => { + + test.describe('Contextual View', () => { + + test('should, by default and if in the context of a view, open a view-modal dialog', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + // Open the dialog. + const dialogOpenerPage = await workbenchNavigator.openInNewTab(DialogOpenerPagePO); + await dialogOpenerPage.open('blank', {cssClass: 'testee'}); + + const dialog = appPO.dialog({cssClass: 'testee'}); + await expect(dialog.locator).toBeVisible(); + await expect(await dialog.getGlassPaneBoundingBox()).toEqual(await dialogOpenerPage.view.getBoundingBox()); + }); + + test('should reject the promise when attaching the dialog to a non-existent view', async ({appPO, workbenchNavigator, consoleLogs}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + // Open the dialog. + const dialogOpenerPage = await workbenchNavigator.openInNewTab(DialogOpenerPagePO); + + // Expect to error when opening the dialog. + await expect(dialogOpenerPage.open('blank', {modality: 'view', contextualViewId: 'non-existent'})).rejects.toThrow('[NullViewError] View \'non-existent\' not found.'); + + // Expect no error to be logged to the console. + await expect(await consoleLogs.get({severity: 'error'})).toEqual([]); + }); + + // TODO [REVIEW] Why not moving to contextual-view.e2e-spec.ts + test('should detach dialog when its contextual view is deactivated', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + // Open the dialog. + const dialogOpenerPage = await workbenchNavigator.openInNewTab(DialogOpenerPagePO); + await dialogOpenerPage.open('dialog-page', {cssClass: 'testee'}); + const dialog = appPO.dialog({cssClass: 'testee'}); + + const dialogPage = new DialogPagePO(dialog); + const componentInstanceId = await dialogPage.getComponentInstanceId(); + await expect(dialog.locator).toBeVisible(); + + // Activate another view. + await appPO.openNewViewTab(); + await expect(dialog.locator).not.toBeVisible(); + await expect(dialog.locator).toBeAttached(); + + // Re-activate the view. + await dialogOpenerPage.viewTab.click(); + await expect(dialog.locator).toBeVisible(); + + // Expect the component not to be constructed anew. + await expect(await dialogPage.getComponentInstanceId()).toEqual(componentInstanceId); + }); + + // TODO [REVIEW] Why not moving to contextual-view.e2e-spec.ts + test('should detach the dialog if contextual view is opened in peripheral area and the main area is maximized', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + // Open view in main area. + const viewPageInMainArea = await workbenchNavigator.openInNewTab(ViewPagePO); + + // Open dialog opener view. + const dialogOpenerPage = await workbenchNavigator.openInNewTab(DialogOpenerPagePO); + + // Drag dialog opener view into peripheral area. + await dialogOpenerPage.viewTab.dragTo({grid: 'workbench', region: 'east'}); + + // Open dialog. + await dialogOpenerPage.open('dialog-page', {cssClass: 'testee'}); + + const dialog = appPO.dialog({cssClass: 'testee'}); + const dialogPage = new DialogPagePO(dialog); + const componentInstanceId = await dialogPage.getComponentInstanceId(); + await expect(dialog.locator).toBeVisible(); + + // Maximize the main area. + await viewPageInMainArea.viewTab.dblclick(); + await expect(dialogOpenerPage.view.locator).not.toBeVisible(); + await expect(dialog.locator).not.toBeVisible(); + await expect(dialog.locator).toBeAttached(); + + // Restore the layout. + await viewPageInMainArea.viewTab.dblclick(); + await expect(dialogOpenerPage.view.locator).toBeVisible(); + await expect(dialog.locator).toBeVisible(); + + // Expect the component not to be constructed anew. + await expect(await dialogPage.getComponentInstanceId()).toEqual(componentInstanceId); + }); + + test('should allow opening a dialog in any view', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + const viewTab1 = (await appPO.openNewViewTab()).view!.viewTab; + const viewTab2 = (await appPO.openNewViewTab()).view!.viewTab; + const viewTab3 = (await appPO.openNewViewTab()).view!.viewTab; + + // Open the dialog in view 2. + const dialogOpenerPage = await workbenchNavigator.openInNewTab(DialogOpenerPagePO); + await dialogOpenerPage.open('blank', {cssClass: 'testee', modality: 'view', contextualViewId: await viewTab2.getViewId()}); + + const dialog = appPO.dialog({cssClass: 'testee'}); + await expect(await appPO.getDialogCount()).toEqual(1); + await expect(dialog.locator).not.toBeVisible(); + await expect(dialog.locator).toBeAttached(); + + // Activate view 1. + await viewTab1.click(); + await expect(dialog.locator).not.toBeVisible(); + await expect(dialog.locator).toBeAttached(); + await expect(await appPO.getDialogCount()).toEqual(1); + + // Activate view 2. + await viewTab2.click(); + await expect(dialog.locator).toBeVisible(); + await expect(await appPO.getDialogCount()).toEqual(1); + + // Activate view 3. + await viewTab3.click(); + await expect(dialog.locator).not.toBeVisible(); + await expect(dialog.locator).toBeAttached(); + await expect(await appPO.getDialogCount()).toEqual(1); + }); + + test('should prevent closing a view if it displays a dialog with view modality', async ({page, appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + // Open the dialog. + const dialogOpenerPage = await workbenchNavigator.openInNewTab(DialogOpenerPagePO); + await dialogOpenerPage.open('blank', {cssClass: 'testee'}); + const dialog = appPO.dialog({cssClass: 'testee'}); + + // Expect close button not to be visible. + await expect(dialogOpenerPage.viewTab.closeButton).not.toBeVisible(); + + // Expect context menu item to be disabled. + const viewTabContextMenu = await dialogOpenerPage.viewTab.openContextMenu(); + await expect(viewTabContextMenu.menuItems.closeTab.locator).toBeDisabled(); + + // Expect closing the view via keystroke not to close the view. + await page.keyboard.press('Control+K'); + await expect(dialogOpenerPage.viewTab.locator).toBeVisible(); + await expect(dialog.locator).toBeVisible(); + + // Expect closing all views via keystroke not to close the view. + await page.keyboard.press('Control+Shift+Alt+K'); + await expect(dialogOpenerPage.viewTab.locator).toBeVisible(); + await expect(dialog.locator).toBeVisible(); + + // Expect view to be closable when dialog is closed. + await dialog.close(); + await expect(dialogOpenerPage.viewTab.closeButton).toBeVisible(); + + await dialogOpenerPage.viewTab.close(); + await expect(dialogOpenerPage.viewTab.locator).not.toBeAttached(); + }); + }); + + test.describe('Application Modality', () => { + + test('should open an application-modal dialog if not in the context of a view', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + // Open the dialog. + const dialogOpenerPage = await workbenchNavigator.openInNewTab(DialogOpenerPagePO); + await dialogOpenerPage.open('blank', {cssClass: 'testee', viewContextActive: false}); + + const dialog = appPO.dialog({cssClass: 'testee'}); + await expect(dialog.locator).toBeVisible(); + await expect(await dialog.getGlassPaneBoundingBox()).toEqual(await appPO.workbenchBoundingBox()); + }); + + test('should open an application-modal dialog if in the context of a view and application-modality selected', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + // Open the dialog. + const dialogOpenerPage = await workbenchNavigator.openInNewTab(DialogOpenerPagePO); + await dialogOpenerPage.open('blank', {cssClass: 'testee', viewContextActive: true, modality: 'application'}); + + const dialog = appPO.dialog({cssClass: 'testee'}); + await expect(dialog.locator).toBeVisible(); + await expect(await dialog.getGlassPaneBoundingBox()).toEqual(await appPO.workbenchBoundingBox()); + }); + + test('should open an application-modal dialog with viewport scope when configured', async ({appPO, workbenchNavigator}) => { + // Start the workbench with viewport application-modality. + await appPO.navigateTo({microfrontendSupport: false, dialogModalityScope: 'viewport'}); + + // Open the dialog. + const dialogOpenerPage = await workbenchNavigator.openInNewTab(DialogOpenerPagePO); + await dialogOpenerPage.open('blank', {cssClass: 'testee', modality: 'application'}); + + const dialog = appPO.dialog({cssClass: 'testee'}); + await expect(dialog.locator).toBeVisible(); + await expect(await dialog.getGlassPaneBoundingBox()).toEqual(await appPO.pageBoundingBox()); + }); + + test('should delay opening view-modal dialog until all application-modal dialogs are closed', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + // Open two application-modal dialogs. + const dialogOpenerViewPage = await workbenchNavigator.openInNewTab(DialogOpenerPagePO); + await dialogOpenerViewPage.open('dialog-opener-page', {cssClass: 'test-application-modal', modality: 'application', count: 2}); + + const applicationModalDialog1 = appPO.dialog({cssClass: 'test-application-modal', nth: 0}); + const applicationModalDialog2 = appPO.dialog({cssClass: 'test-application-modal', nth: 1}); + + await expect(applicationModalDialog1.locator).toBeVisible(); + await expect(applicationModalDialog2.locator).toBeVisible(); + + const dialogOpenerDialogPage = new DialogOpenerPagePO(appPO, {dialog: applicationModalDialog2}); + const contextualViewId = await dialogOpenerViewPage.view.getViewId(); + + // Open view-modal dialog. + // Expect view-modal dialog to be attached only after all application-modal dialogs are closed. + await dialogOpenerDialogPage.open('dialog-page', {cssClass: 'testee', modality: 'view', contextualViewId, waitUntilOpened: false}); + + const testeeDialog = appPO.dialog({cssClass: 'testee'}); + await expect(testeeDialog.locator).not.toBeAttached(); + + await applicationModalDialog2.close(); + await expect(testeeDialog.locator).not.toBeAttached(); + + await applicationModalDialog1.close(); + await expect(testeeDialog.locator).toBeVisible(); + }); + + test('should hide application-modal dialog when unmounting the workbench', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + // Open the dialog. + const dialogOpenerPage = await workbenchNavigator.openInNewTab(DialogOpenerPagePO); + await dialogOpenerPage.open('dialog-page', {cssClass: 'testee', viewContextActive: true, modality: 'application'}); + + const dialog = appPO.dialog({cssClass: 'testee'}); + const dialogPage = new DialogPagePO(dialog); + const componentInstanceId = await dialogPage.getComponentInstanceId(); + await expect(dialog.locator).toBeVisible(); + + // Unmount the workbench component by navigating the primary router outlet. + await appPO.header.clickMenuItem({cssClass: 'e2e-navigate-to-blank-page'}); + await expect(dialog.locator).not.toBeVisible(); + await expect(dialog.locator).toBeAttached(); + + // Re-mount the workbench component by navigating the primary router. + await appPO.header.clickMenuItem({cssClass: 'e2e-navigate-to-workbench-page'}); + await expect(dialog.locator).toBeVisible(); + await expect(await dialog.getGlassPaneBoundingBox()).toEqual(await appPO.workbenchBoundingBox()); + + // Expect the component not to be constructed anew. + await expect(await dialogPage.getComponentInstanceId()).toEqual(componentInstanceId); + }); + }); + + test.describe('Focus Trap', () => { + + test('should focus first element when opened', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + // Open the dialog. + const dialogOpenerPage = await workbenchNavigator.openInNewTab(DialogOpenerPagePO); + await dialogOpenerPage.open('focus-test-page', {cssClass: 'testee'}); + + const dialog = appPO.dialog({cssClass: 'testee'}); + const focusTestPage = new FocusTestPagePO(dialog); + await expect(focusTestPage.firstField).toBeFocused(); + }); + + test('should not focus elements under the glass pane', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + // Open the dialog. + const dialogOpenerPage = await workbenchNavigator.openInNewTab(DialogOpenerPagePO); + await dialogOpenerPage.open('focus-test-page', {cssClass: 'testee'}); + + const dialog = appPO.dialog({cssClass: 'testee'}); + const focusTestPage = new FocusTestPagePO(dialog); + + await expect(dialogOpenerPage.click({timeout: 1000})).rejects.toThrowError(); + await expect(focusTestPage.firstField).toBeFocused(); + }); + + test('should not focus popup under the glass pane', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + // Open a global popup. + const popupOpenerPage = await workbenchNavigator.openInNewTab(PopupOpenerPagePO); + await popupOpenerPage.enterCssClass('testee'); + await popupOpenerPage.enterPosition({bottom: 100, right: 100}); + await popupOpenerPage.enterContextualViewId(''); + await popupOpenerPage.enterCloseStrategy({closeOnFocusLost: false}); + await popupOpenerPage.clickOpen(); + + const popup = appPO.popup({cssClass: 'testee'}); + await expect(popup.locator).toBeVisible(); + const popupPage = new PopupPagePO(appPO, {cssClass: 'testee'}); + + // Open a dialog. + const dialogOpenerPage = await workbenchNavigator.openInNewTab(DialogOpenerPagePO); + await dialogOpenerPage.open('focus-test-page', {cssClass: 'testee'}); + + const dialog = appPO.dialog({cssClass: 'testee'}); + const focusTestPage = new FocusTestPagePO(dialog); + + await expect(popupPage.clickClose({timeout: 1000})).rejects.toThrowError(); + await expect(focusTestPage.firstField).toBeFocused(); + }); + + test('should trap focus and cycle through all elements (pressing TAB)', async ({page, appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + // Open the dialog. + const dialogOpenerPage = await workbenchNavigator.openInNewTab(DialogOpenerPagePO); + await dialogOpenerPage.open('focus-test-page', {cssClass: 'testee'}); + + const dialog = appPO.dialog({cssClass: 'testee'}); + const focusTestPage = new FocusTestPagePO(dialog); + + await page.keyboard.press('Tab'); + await expect(focusTestPage.middleField).toBeFocused(); + + await page.keyboard.press('Tab'); + await expect(focusTestPage.lastField).toBeFocused(); + + await page.keyboard.press('Tab'); + await expect(focusTestPage.firstField).toBeFocused(); + }); + + test('should trap focus and cycle through all elements (pressing SHIFT+TAB)', async ({page, appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + // Open the dialog. + const dialogOpenerPage = await workbenchNavigator.openInNewTab(DialogOpenerPagePO); + await dialogOpenerPage.open('focus-test-page', {cssClass: 'testee'}); + const dialog = appPO.dialog({cssClass: 'testee'}); + const focusTestPage = new FocusTestPagePO(dialog); + + await page.keyboard.press('Shift+Tab'); + await expect(focusTestPage.lastField).toBeFocused(); + + await page.keyboard.press('Shift+Tab'); + await expect(focusTestPage.middleField).toBeFocused(); + + await page.keyboard.press('Shift+Tab'); + await expect(focusTestPage.firstField).toBeFocused(); + }); + + test('should restore focus after re-activating its contextual view', async ({page, appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + // Open the dialog. + const dialogOpenerPage = await workbenchNavigator.openInNewTab(DialogOpenerPagePO); + await dialogOpenerPage.open('focus-test-page', {cssClass: 'testee'}); + const dialog = appPO.dialog({cssClass: 'testee'}); + const focusTestPage = new FocusTestPagePO(dialog); + + // Move focus. + await page.keyboard.press('Tab'); + await expect(focusTestPage.middleField).toBeFocused(); + + // Activate another view. + await appPO.openNewViewTab(); + await expect(focusTestPage.middleField).not.toBeFocused(); + + // Re-activate the dialog view. + await dialogOpenerPage.viewTab.click(); + await expect(focusTestPage.middleField).toBeFocused(); + }); + + test('should restore focus to application-modal dialog after re-mounting the workbench', async ({page, appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + // Open the dialog. + const dialogOpenerPage = await workbenchNavigator.openInNewTab(DialogOpenerPagePO); + await dialogOpenerPage.open('focus-test-page', {cssClass: 'testee'}); + const dialog = appPO.dialog({cssClass: 'testee'}); + const focusTestPage = new FocusTestPagePO(dialog); + + // Move focus. + await page.keyboard.press('Tab'); + await expect(focusTestPage.middleField).toBeFocused(); + + // Unmount the workbench component by navigating the primary router outlet. + await appPO.header.clickMenuItem({cssClass: 'e2e-navigate-to-blank-page'}); + await expect(focusTestPage.middleField).not.toBeFocused(); + + // Re-mount the workbench component by navigating the primary router. + await appPO.header.clickMenuItem({cssClass: 'e2e-navigate-to-workbench-page'}); + await expect(focusTestPage.middleField).toBeFocused(); + }); + + test('should focus top dialog from the stack when previous top-most dialog is closed', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + // Open 3 dialogs. + const dialogOpenerPage = await workbenchNavigator.openInNewTab(DialogOpenerPagePO); + await dialogOpenerPage.open('focus-test-page', {cssClass: 'testee', count: 3}); + + const dialog1 = appPO.dialog({cssClass: 'testee', nth: 0}); + const dialog2 = appPO.dialog({cssClass: 'testee', nth: 1}); + const dialog3 = appPO.dialog({cssClass: 'testee', nth: 2}); + const focusTestPage1 = new FocusTestPagePO(dialog1); + const focusTestPage2 = new FocusTestPagePO(dialog2); + const focusTestPage3 = new FocusTestPagePO(dialog3); + + await expect(focusTestPage1.firstField).not.toBeFocused(); + await expect(focusTestPage2.firstField).not.toBeFocused(); + await expect(focusTestPage3.firstField).toBeFocused(); + + // Close top-most dialog. + await dialog3.close(); + await expect(dialog3.locator).not.toBeAttached(); + await expect(focusTestPage1.firstField).not.toBeFocused(); + await expect(focusTestPage2.firstField).toBeFocused(); + }); + + test('should restore focus to top dialog after re-activating its contextual view', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + // Open 3 dialogs. + const dialogOpenerPage = await workbenchNavigator.openInNewTab(DialogOpenerPagePO); + await dialogOpenerPage.open('focus-test-page', {cssClass: 'testee', count: 3}); + + const dialog1 = appPO.dialog({cssClass: 'testee', nth: 0}); + const dialog2 = appPO.dialog({cssClass: 'testee', nth: 1}); + const dialog3 = appPO.dialog({cssClass: 'testee', nth: 2}); + const focusTestPage1 = new FocusTestPagePO(dialog1); + const focusTestPage2 = new FocusTestPagePO(dialog2); + const focusTestPage3 = new FocusTestPagePO(dialog3); + + await expect(focusTestPage1.firstField).not.toBeFocused(); + await expect(focusTestPage2.firstField).not.toBeFocused(); + await expect(focusTestPage3.firstField).toBeFocused(); + + // Activate another view. + await appPO.openNewViewTab(); + await expect(focusTestPage1.firstField).not.toBeFocused(); + await expect(focusTestPage2.firstField).not.toBeFocused(); + await expect(focusTestPage3.firstField).not.toBeFocused(); + + // Re-activate the dialog view. + await dialogOpenerPage.viewTab.click(); + await expect(focusTestPage1.firstField).not.toBeFocused(); + await expect(focusTestPage2.firstField).not.toBeFocused(); + await expect(focusTestPage3.firstField).toBeFocused(); + }); + + test('should restore focus to opener view when last dialog is closed', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + // Open 2 dialogs. + const dialogOpenerPage = await workbenchNavigator.openInNewTab(DialogOpenerPagePO); + await dialogOpenerPage.open('focus-test-page', {cssClass: 'testee', count: 2}); + + const dialog1 = appPO.dialog({cssClass: 'testee', nth: 0}); + const dialog2 = appPO.dialog({cssClass: 'testee', nth: 1}); + + const focusTestPage1 = new FocusTestPagePO(dialog1); + const focusTestPage2 = new FocusTestPagePO(dialog2); + + // Expect dialog 2 to have focus. + await expect(focusTestPage2.firstField).toBeFocused(); + + // Close dialog 2. + await dialog2.close(); + + // Expect dialog 1 to have focus. + await expect(focusTestPage1.firstField).toBeFocused(); + + // Close dialog 1. + await dialog1.close(); + + // Expect open button to have focus. + await expect(dialogOpenerPage.openButton).toBeFocused(); + }); + }); + + test.describe('Title', () => { + + test('should allow setting title from the dialog component', async ({appPO, workbenchNavigator, consoleLogs}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + // Open the dialog. + const dialogOpenerPage = await workbenchNavigator.openInNewTab(DialogOpenerPagePO); + await dialogOpenerPage.open('dialog-page', {cssClass: 'testee'}); + + const dialog = appPO.dialog({cssClass: 'testee'}); + const dialogPage = new DialogPagePO(dialog); + await dialogPage.enterTitle('TITLE'); + + // Expect the title to be set. + await expect(dialog.title).toHaveText('TITLE'); + + // Expect no error to be thrown, e.g. ExpressionChangedAfterItHasBeenCheckedError. + await expect(await consoleLogs.get({severity: 'error'})).toEqual([]); + }); + + test('should not change dialog width when setting long title', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + // Open the dialog. + const dialogOpenerPage = await workbenchNavigator.openInNewTab(DialogOpenerPagePO); + await dialogOpenerPage.open('dialog-page', {cssClass: 'testee'}); + + const dialog = appPO.dialog({cssClass: 'testee'}); + const dialogPage = new DialogPagePO(dialog); + const dialogPaneBoundingBox = await dialog.getDialogBoundingBox(); + await dialogPage.enterTitle('Very Long Title'.repeat(100)); + + // Expect title not to change dialog width. + await expect(await dialog.getDialogBoundingBox()).toEqual(dialogPaneBoundingBox); + }); + }); + + test.describe('Input', () => { + + test('should make inputs available as input properties.', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + // Open the dialog. + const dialogOpenerPage = await workbenchNavigator.openInNewTab(DialogOpenerPagePO); + await dialogOpenerPage.open('dialog-page', {inputs: {input: 'ABC'}, cssClass: 'testee'}); + + const dialog = appPO.dialog({cssClass: 'testee'}); + const dialogPage = new DialogPagePO(dialog); + + await expect(dialogPage.input).toHaveValue('ABC'); + }); + }); + + test.describe('Closing', () => { + + test('should allow closing the dialog', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + // Open the dialog. + const dialogOpenerPage = await workbenchNavigator.openInNewTab(DialogOpenerPagePO); + await dialogOpenerPage.open('blank', {cssClass: 'testee'}); + + const dialog = appPO.dialog({cssClass: 'testee'}); + await expect(dialog.locator).toBeVisible(); + + // Close the dialog. + await dialog.close(); + await expect(dialog.locator).not.toBeAttached(); + }); + + test('should close the dialog on escape keystroke', async ({page, appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + // Open the dialog. + const dialogOpenerPage = await workbenchNavigator.openInNewTab(DialogOpenerPagePO); + await dialogOpenerPage.open('dialog-page', {cssClass: 'testee'}); + + const dialog = appPO.dialog({cssClass: 'testee'}); + await expect(dialog.locator).toBeVisible(); + + // Close the dialog by Escape keystroke. + await page.keyboard.press('Escape'); + await expect(dialog.locator).not.toBeAttached(); + }); + + test('should allow non-closable dialog', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + // Open the dialog. + const dialogOpenerPage = await workbenchNavigator.openInNewTab(DialogOpenerPagePO); + await dialogOpenerPage.open('dialog-page', {cssClass: 'testee'}); + + const dialog = appPO.dialog({cssClass: 'testee'}); + await expect(dialog.locator).toBeVisible(); + + const dialogPage = new DialogPagePO(dialog); + await dialogPage.setClosable(false); + + // Expect the close button not to be visible. + await expect(dialog.closeButton).not.toBeVisible(); + }); + + test('should NOT close the dialog on escape keystroke if dialog is NOT closable', async ({page, appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + // Open the dialog. + const dialogOpenerPage = await workbenchNavigator.openInNewTab(DialogOpenerPagePO); + await dialogOpenerPage.open('dialog-page', {cssClass: 'testee'}); + + const dialog = appPO.dialog({cssClass: 'testee'}); + const dialogPage = new DialogPagePO(dialog); + await dialogPage.setClosable(false); + + // Try closing the dialog by Escape keystroke. + await page.keyboard.press('Escape'); + + // Expect dialog not to be closed. + await expect(dialog.locator).toBeVisible(); + }); + + test('should allow closing the dialog with a result', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + // Open the dialog. + const dialogOpenerPage = await workbenchNavigator.openInNewTab(DialogOpenerPagePO); + await dialogOpenerPage.open('dialog-page', {cssClass: 'testee'}); + + const dialog = appPO.dialog({cssClass: 'testee'}); + const dialogPage = new DialogPagePO(dialog); + await dialogPage.close({returnValue: 'SUCCESS'}); + + await expect(dialogOpenerPage.returnValue).toHaveText('SUCCESS'); + }); + + test('should allow closing the dialog with an error', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + // Open the dialog. + const dialogOpenerPage = await workbenchNavigator.openInNewTab(DialogOpenerPagePO); + await dialogOpenerPage.open('dialog-page', {cssClass: 'testee'}); + + const dialog = appPO.dialog({cssClass: 'testee'}); + const dialogPage = new DialogPagePO(dialog); + await dialogPage.close({returnValue: 'ERROR', closeWithError: true}); + + await expect(dialogOpenerPage.error).toHaveText('ERROR'); + }); + }); + + test.describe('Stacking', () => { + + test('should stack multiple dialogs and offset them horizontally and vertically', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + // Open 3 dialogs. + const dialogOpenerPage = await workbenchNavigator.openInNewTab(DialogOpenerPagePO); + await dialogOpenerPage.open('blank', {cssClass: 'testee', count: 3}); + + const dialog1 = appPO.dialog({cssClass: 'testee', nth: 0}); + const dialog1Bounds = await dialog1.getDialogBoundingBox(); + + const dialog2 = appPO.dialog({cssClass: 'testee', nth: 1}); + const dialog2Bounds = await dialog2.getDialogBoundingBox(); + + const dialog3 = appPO.dialog({cssClass: 'testee', nth: 2}); + const dialog3Bounds = await dialog3.getDialogBoundingBox(); + + await expect(dialog1.locator).toBeVisible(); + await expect(dialog2.locator).toBeVisible(); + await expect(dialog3.locator).toBeVisible(); + + // Expect the dialogs to be displayed offset. + expect(dialog2Bounds.left - dialog1Bounds.left).toEqual(10); + expect(dialog2Bounds.top - dialog1Bounds.top).toEqual(10); + expect(dialog3Bounds.left - dialog2Bounds.left).toEqual(10); + expect(dialog3Bounds.top - dialog2Bounds.top).toEqual(10); + }); + + test('should allow interaction only with top dialog from the stack', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + // Open 3 dialogs. + const dialogOpenerPage = await workbenchNavigator.openInNewTab(DialogOpenerPagePO); + await dialogOpenerPage.open('blank', {cssClass: 'testee', count: 3}); + + const firstDialog = appPO.dialog({cssClass: 'testee', nth: 0}); + await expect(firstDialog.locator).toBeVisible(); + + const middleDialog = appPO.dialog({cssClass: 'testee', nth: 1}); + await expect(middleDialog.locator).toBeVisible(); + + const topDialog = appPO.dialog({cssClass: 'testee', nth: 2}); + await expect(topDialog.locator).toBeVisible(); + + // Expect first dialog not to be interactable. + await expect(firstDialog.clickHeader({timeout: 1000})).rejects.toThrowError(); + + // Expect middle dialog not to be interactable. + await expect(middleDialog.clickHeader({timeout: 1000})).rejects.toThrowError(); + + // Expect top dialog to be interactable. + await expect(topDialog.clickHeader()).resolves.toBeUndefined(); + }); + }); +}); diff --git a/projects/scion/e2e-testing/src/workbench/message-box.e2e-spec.ts b/projects/scion/e2e-testing/src/workbench/message-box.e2e-spec.ts index 557ffcd3a..c7e584b9a 100644 --- a/projects/scion/e2e-testing/src/workbench/message-box.e2e-spec.ts +++ b/projects/scion/e2e-testing/src/workbench/message-box.e2e-spec.ts @@ -13,6 +13,7 @@ import {test} from '../fixtures'; import {InspectMessageBoxComponentPO} from '../inspect-message-box-component.po'; import {TextMessageComponentPO} from '../text-message-component.po'; import {MessageBoxOpenerPagePO} from './page-object/message-box-opener-page.po'; +import {ViewPagePO} from './page-object/view-page.po'; test.describe('Workbench Message Box', () => { @@ -149,19 +150,46 @@ test.describe('Workbench Message Box', () => { await msgboxOpenerPage.clickOpen(); const msgbox = appPO.messagebox({cssClass: 'testee'}); - await expect(await msgbox.isVisible()).toBe(true); - await expect(await msgbox.isPresent()).toBe(true); - await expect(await msgbox.getModality()).toEqual('view'); + await expect(msgbox.locator).toBeVisible(); // activate another view await appPO.openNewViewTab(); - await expect(await msgbox.isPresent()).toBe(true); - await expect(await msgbox.isVisible()).toBe(false); + await expect(msgbox.locator).toBeAttached(); + await expect(msgbox.locator).not.toBeVisible(); // re-activate the view await msgboxOpenerPage.viewTab.click(); - await expect(await msgbox.isPresent()).toBe(true); - await expect(await msgbox.isVisible()).toBe(true); + await expect(msgbox.locator).toBeVisible(); + }); + + test('should detach message box if contextual view is opened in peripheral area and the main area is maximized', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + // Open view in main area. + const viewPageInMainArea = await workbenchNavigator.openInNewTab(ViewPagePO); + + // Open message box opener view. + const messageBoxOpenerView = await workbenchNavigator.openInNewTab(MessageBoxOpenerPagePO); + + // Drag message box opener view into peripheral area. + await messageBoxOpenerView.viewTab.dragTo({grid: 'workbench', region: 'east'}); + + // Open message box. + await messageBoxOpenerView.enterCssClass('testee'); + await messageBoxOpenerView.clickOpen(); + + const messageBox = appPO.messagebox({cssClass: 'testee'}); + await expect(messageBox.locator).toBeVisible(); + + // Maximize the main area. + await viewPageInMainArea.viewTab.dblclick(); + await expect(messageBoxOpenerView.view.locator).not.toBeVisible(); + await expect(messageBox.locator).not.toBeVisible(); + + // Restore the layout. + await viewPageInMainArea.viewTab.dblclick(); + await expect(messageBoxOpenerView.view.locator).toBeVisible(); + await expect(messageBox.locator).toBeVisible(); }); test('should not destroy the message box when its contextual view (if any) is deactivated', async ({appPO, workbenchNavigator}) => { @@ -258,8 +286,7 @@ test.describe('Workbench Message Box', () => { await expect(await appPO.getMessageBoxCount()).toEqual(1); }); - test.fixme('should prevent closing a view if it displays a message box with view modality', async ({appPO, workbenchNavigator}) => { - // FIXME: this test will run as soon as https://github.com/SchweizerischeBundesbahnen/scion-workbench/issues/344 is fixed + test('should prevent closing a view if it displays a message box with view modality', async ({page, appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); const viewTab1 = (await appPO.openNewViewTab()).view!.viewTab; @@ -305,8 +332,13 @@ test.describe('Workbench Message Box', () => { await expect(await appPO.getMessageBoxCount()).toEqual(1); // close view 2, should not be possible - await viewTab2.close(); - // also try to close by keystrokes and context menu + await expect(viewTab2.closeButton).not.toBeVisible(); + // context menu should be disabled + const viewTab2ContextMenu = await viewTab2.openContextMenu(); + await expect(viewTab2ContextMenu.menuItems.closeTab.locator).toBeDisabled(); + // also try to close by keystrokes + await page.keyboard.press('Control+k'); + await expect(await viewTab2.isActive()).toBe(true); await expect(await msgbox.isVisible()).toBe(true); await expect(await msgbox.isPresent()).toBe(true); diff --git a/projects/scion/e2e-testing/src/workbench/page-object/dialog-opener-page.po.ts b/projects/scion/e2e-testing/src/workbench/page-object/dialog-opener-page.po.ts new file mode 100644 index 000000000..8ddf7e190 --- /dev/null +++ b/projects/scion/e2e-testing/src/workbench/page-object/dialog-opener-page.po.ts @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2018-2023 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 {coerceArray, orElseThrow, rejectWhenAttached} from '../../helper/testing.util'; +import {AppPO} from '../../app.po'; +import {ViewTabPO} from '../../view-tab.po'; +import {SciCheckboxPO} from '../../@scion/components.internal/checkbox.po'; +import {Locator} from '@playwright/test'; +import {ViewPO} from '../../view.po'; +import {SciKeyValueFieldPO} from '../../@scion/components.internal/key-value-field.po'; +import {DialogPO} from '../../dialog.po'; + +/** + * Page object to interact with {@link DialogOpenerPageComponent}. + */ +export class DialogOpenerPagePO { + + public readonly locator: Locator; + public readonly returnValue: Locator; + public readonly error: Locator; + public readonly openButton: Locator; + private readonly _view: ViewPO | undefined; + private readonly _viewTab: ViewTabPO | undefined; + + constructor(private _appPO: AppPO, options: {viewId?: string; dialog?: DialogPO}) { + if (options.viewId) { + this._view = this._appPO.view({viewId: options.viewId}); + this._viewTab = this.view.viewTab; + this.locator = this.view.locate('app-dialog-opener-page'); + } + else if (options.dialog) { + this.locator = options.dialog.locator.locator('app-dialog-opener-page'); + } + else { + throw Error('[IllegalArgumentError] either viewId or dialog must be provided.'); + } + this.returnValue = this.locator.locator('output.e2e-return-value'); + this.error = this.locator.locator('output.e2e-dialog-error'); + this.openButton = this.locator.locator('button.e2e-open'); + } + + public get view(): ViewPO { + return orElseThrow(this._view, () => Error('[IllegalStateError] Test page not opened in a view.')); + } + + public get viewTab(): ViewTabPO { + return orElseThrow(this._viewTab, () => Error('[IllegalStateError] Test page not opened in a view.')); + } + + public async open(component: 'blank' | 'dialog-page' | 'dialog-opener-page' | 'focus-test-page', options?: DialogOpenerPageOptions): Promise { + await this.locator.locator('select.e2e-component').selectOption(component); + + if (options?.inputs) { + const keyValueField = new SciKeyValueFieldPO(this.locator.locator('sci-key-value-field.e2e-inputs')); + await keyValueField.clear(); + await keyValueField.addEntries(options.inputs); + } + + if (options?.modality) { + await this.locator.locator('select.e2e-modality').selectOption(options.modality); + } + + if (options?.contextualViewId) { + await this.locator.locator('input.e2e-contextual-view-id').fill(options.contextualViewId); + } + + if (options?.cssClass) { + await this.locator.locator('input.e2e-class').fill(coerceArray(options.cssClass).join(' ')); + } + + if (options?.count) { + await this.locator.locator('input.e2e-count').fill(`${options.count}`); + } + + if (options?.viewContextActive !== undefined) { + await new SciCheckboxPO(this.locator.locator('sci-checkbox.e2e-view-context')).toggle(options.viewContextActive); + } + + await this.openButton.click(); + + if (options?.waitUntilOpened ?? true) { + // Evaluate the response: resolve the promise on success, or reject it on error. + await Promise.race([ + this.waitUntilDialogsAttached(options), + rejectWhenAttached(this.error), + ]); + } + } + + public async click(options?: {timeout?: number}): Promise { + await this.locator.click(options); + } + + private async waitUntilDialogsAttached(options?: DialogOpenerPageOptions): Promise { + const cssClasses = coerceArray(options?.cssClass).filter(Boolean); + + for (let i = 0; i < (options?.count ?? 1); i++) { + const dialog = this._appPO.dialog({cssClass: [`index-${i}`].concat(cssClasses)}); + await dialog.locator.waitFor({state: 'attached'}); + } + } +} + +export interface DialogOpenerPageOptions { + inputs?: {[name: string]: unknown}; + modality?: 'view' | 'application'; + contextualViewId?: string; + cssClass?: string | string[]; + count?: number; + viewContextActive?: boolean; + waitUntilOpened?: boolean; +} diff --git a/projects/scion/e2e-testing/src/workbench/page-object/test-pages/focus-test-page.po.ts b/projects/scion/e2e-testing/src/workbench/page-object/test-pages/focus-test-page.po.ts index 0edb2382a..9223d7eef 100644 --- a/projects/scion/e2e-testing/src/workbench/page-object/test-pages/focus-test-page.po.ts +++ b/projects/scion/e2e-testing/src/workbench/page-object/test-pages/focus-test-page.po.ts @@ -8,11 +8,10 @@ * SPDX-License-Identifier: EPL-2.0 */ -import {isActiveElement, isPresent} from '../../../helper/testing.util'; -import {AppPO} from '../../../app.po'; import {Locator} from '@playwright/test'; import {PopupPO} from '../../../popup.po'; import {ViewPO} from '../../../view.po'; +import {DialogPO} from '../../../dialog.po'; /** * Page object to interact with {@link FocusTestPageComponent}. @@ -21,23 +20,31 @@ export class FocusTestPagePO { public readonly locator: Locator; - constructor(appPO: AppPO, locateBy: ViewPO | PopupPO) { - this.locator = locateBy.locate('app-focus-test-page'); - } - - public async isPresent(): Promise { - return isPresent(this.locator); - } - - public async isVisible(): Promise { - return this.locator.isVisible(); - } + public firstField: Locator; + public middleField: Locator; + public lastField: Locator; - public async isActiveElement(field: 'first-field' | 'middle-field' | 'last-field'): Promise { - return isActiveElement(this.locator.locator(`input.e2e-${field}`)); + constructor(locateBy: ViewPO | PopupPO | DialogPO) { + this.locator = locateBy.locator.locator('app-focus-test-page'); + this.firstField = this.locator.locator('input.e2e-first-field'); + this.middleField = this.locator.locator('input.e2e-middle-field'); + this.lastField = this.locator.locator('input.e2e-last-field'); } - public async clickField(field: 'first-field' | 'middle-field' | 'last-field'): Promise { - await this.locator.locator(`input.e2e-${field}`).click(); + public clickField(field: 'first-field' | 'middle-field' | 'last-field'): Promise { + switch (field) { + case 'first-field': { + return this.firstField.click(); + } + case 'middle-field': { + return this.middleField.click(); + } + case 'last-field': { + return this.lastField.click(); + } + default: { + throw Error(`[IllegalArgumentError] Specified field not found: ${field}`); + } + } } } diff --git a/projects/scion/e2e-testing/src/workbench/popup.e2e-spec.ts b/projects/scion/e2e-testing/src/workbench/popup.e2e-spec.ts index a49c160d6..fe6dbe403 100644 --- a/projects/scion/e2e-testing/src/workbench/popup.e2e-spec.ts +++ b/projects/scion/e2e-testing/src/workbench/popup.e2e-spec.ts @@ -14,6 +14,7 @@ import {FocusTestPagePO} from './page-object/test-pages/focus-test-page.po'; import {PopupOpenerPagePO} from './page-object/popup-opener-page.po'; import {PopupPagePO} from './page-object/popup-page.po'; import {InputFieldTestPagePO} from './page-object/test-pages/input-field-test-page.po'; +import {ViewPagePO} from './page-object/view-page.po'; const POPUP_DIAMOND_ANCHOR_SIZE = 8; @@ -373,18 +374,48 @@ test.describe('Workbench Popup', () => { await popupOpenerPage.clickOpen(); const popup = appPO.popup({cssClass: 'testee'}); - await expect(await popup.isPresent()).toBe(true); - await expect(await popup.isVisible()).toBe(true); + await expect(popup.locator).toBeVisible(); // activate another view await appPO.openNewViewTab(); - await expect(await popup.isPresent()).toBe(true); - await expect(await popup.isVisible()).toBe(false); + await expect(popup.locator).toBeAttached(); + await expect(popup.locator).not.toBeVisible(); // re-activate the view await popupOpenerPage.view.viewTab.click(); - await expect(await popup.isPresent()).toBe(true); - await expect(await popup.isVisible()).toBe(true); + await expect(popup.locator).toBeVisible(); + }); + + test('should detach popup if contextual view is opened in peripheral area and the main area is maximized', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + // Open view in main area. + const viewPageInMainArea = await workbenchNavigator.openInNewTab(ViewPagePO); + + // Open popup opener view. + const popupOpenerView = await workbenchNavigator.openInNewTab(PopupOpenerPagePO); + + // Drag popup opener view into peripheral area. + await popupOpenerView.viewTab.dragTo({grid: 'workbench', region: 'east'}); + + // Open popup. + await popupOpenerView.enterCssClass('testee'); + await popupOpenerView.selectPopupComponent('popup-page'); + await popupOpenerView.enterCloseStrategy({closeOnFocusLost: false}); + await popupOpenerView.clickOpen(); + + const popup = appPO.popup({cssClass: 'testee'}); + await expect(popup.locator).toBeVisible(); + + // Maximize the main area. + await viewPageInMainArea.viewTab.dblclick(); + await expect(popupOpenerView.view.locator).not.toBeVisible(); + await expect(popup.locator).not.toBeVisible(); + + // Restore the layout. + await viewPageInMainArea.viewTab.dblclick(); + await expect(popupOpenerView.view.locator).toBeVisible(); + await expect(popup.locator).toBeVisible(); }); test('should not destroy the popup when its contextual view (if any) is deactivated', async ({appPO, workbenchNavigator}) => { @@ -604,8 +635,8 @@ test.describe('Workbench Popup', () => { // Expect popup to have focus. const popup = appPO.popup({cssClass: 'testee'}); - const focusTestPage = new FocusTestPagePO(appPO, popup); - await expect(await focusTestPage.isActiveElement('first-field')).toBe(true); + const focusTestPage = new FocusTestPagePO(popup); + await expect(focusTestPage.firstField).toBeFocused(); // Click the input field to make popup lose focus await inputFieldPage.clickInputField(); @@ -629,8 +660,8 @@ test.describe('Workbench Popup', () => { await popupOpenerPage.clickOpen(); const popup = appPO.popup({cssClass: 'testee'}); - const focusTestPage = new FocusTestPagePO(appPO, popup); - await expect(await focusTestPage.isActiveElement('first-field')).toBe(true); + const focusTestPage = new FocusTestPagePO(popup); + await expect(focusTestPage.firstField).toBeFocused(); }); test('should install a focus trap to cycle focus (pressing tab)', async ({page, appPO, workbenchNavigator}) => { @@ -642,23 +673,23 @@ test.describe('Workbench Popup', () => { await popupOpenerPage.clickOpen(); const popup = appPO.popup({cssClass: 'testee'}); - const focusTestPage = new FocusTestPagePO(appPO, popup); - await expect(await focusTestPage.isActiveElement('first-field')).toBe(true); + const focusTestPage = new FocusTestPagePO(popup); + await expect(focusTestPage.firstField).toBeFocused(); await page.keyboard.press('Tab'); - await expect(await focusTestPage.isActiveElement('middle-field')).toBe(true); + await expect(focusTestPage.middleField).toBeFocused(); await page.keyboard.press('Tab'); - await expect(await focusTestPage.isActiveElement('last-field')).toBe(true); + await expect(focusTestPage.lastField).toBeFocused(); await page.keyboard.press('Tab'); - await expect(await focusTestPage.isActiveElement('first-field')).toBe(true); + await expect(focusTestPage.firstField).toBeFocused(); await page.keyboard.press('Tab'); - await expect(await focusTestPage.isActiveElement('middle-field')).toBe(true); + await expect(focusTestPage.middleField).toBeFocused(); await page.keyboard.press('Tab'); - await expect(await focusTestPage.isActiveElement('last-field')).toBe(true); + await expect(focusTestPage.lastField).toBeFocused(); }); test('should install a focus trap to cycle focus (pressing shift-tab)', async ({page, appPO, workbenchNavigator}) => { @@ -670,26 +701,26 @@ test.describe('Workbench Popup', () => { await popupOpenerPage.clickOpen(); const popup = appPO.popup({cssClass: 'testee'}); - const focusTestPage = new FocusTestPagePO(appPO, popup); - await expect(await focusTestPage.isActiveElement('first-field')).toBe(true); + const focusTestPage = new FocusTestPagePO(popup); + await expect(focusTestPage.firstField).toBeFocused(); await page.keyboard.press('Shift+Tab'); - await expect(await focusTestPage.isActiveElement('last-field')).toBe(true); + await expect(focusTestPage.lastField).toBeFocused(); await page.keyboard.press('Shift+Tab'); - await expect(await focusTestPage.isActiveElement('middle-field')).toBe(true); + await expect(focusTestPage.middleField).toBeFocused(); await page.keyboard.press('Shift+Tab'); - await expect(await focusTestPage.isActiveElement('first-field')).toBe(true); + await expect(focusTestPage.firstField).toBeFocused(); await page.keyboard.press('Shift+Tab'); - await expect(await focusTestPage.isActiveElement('last-field')).toBe(true); + await expect(focusTestPage.lastField).toBeFocused(); await page.keyboard.press('Shift+Tab'); - await expect(await focusTestPage.isActiveElement('middle-field')).toBe(true); + await expect(focusTestPage.middleField).toBeFocused(); await page.keyboard.press('Shift+Tab'); - await expect(await focusTestPage.isActiveElement('first-field')).toBe(true); + await expect(focusTestPage.firstField).toBeFocused(); }); test('should restore focus after re-activating its contextual view, if any', async ({appPO, workbenchNavigator}) => { @@ -702,22 +733,20 @@ test.describe('Workbench Popup', () => { await popupOpenerPage.clickOpen(); const popup = appPO.popup({cssClass: 'testee'}); - const focusTestPage = new FocusTestPagePO(appPO, popup); + const focusTestPage = new FocusTestPagePO(popup); await focusTestPage.clickField('middle-field'); - await expect(await focusTestPage.isActiveElement('middle-field')).toBe(true); - await expect(await focusTestPage.isPresent()).toBe(true); - await expect(await focusTestPage.isVisible()).toBe(true); + await expect(focusTestPage.middleField).toBeFocused(); + await expect(focusTestPage.locator).toBeVisible(); // activate another view await appPO.openNewViewTab(); - await expect(await focusTestPage.isPresent()).toBe(true); - await expect(await focusTestPage.isVisible()).toBe(false); + await expect(focusTestPage.locator).toBeAttached(); + await expect(focusTestPage.locator).not.toBeVisible(); // re-activate the view await popupOpenerPage.view.viewTab.click(); - await expect(await focusTestPage.isPresent()).toBe(true); - await expect(await focusTestPage.isVisible()).toBe(true); - await expect(await focusTestPage.isActiveElement('middle-field')).toBe(true); + await expect(focusTestPage.locator).toBeVisible(); + await expect(focusTestPage.middleField).toBeFocused(); }); }); }); diff --git a/projects/scion/e2e-testing/src/workbench/view.e2e-spec.ts b/projects/scion/e2e-testing/src/workbench/view.e2e-spec.ts index a760c9416..bbac5fec0 100644 --- a/projects/scion/e2e-testing/src/workbench/view.e2e-spec.ts +++ b/projects/scion/e2e-testing/src/workbench/view.e2e-spec.ts @@ -44,7 +44,7 @@ test.describe('Workbench View', () => { const viewPage = await workbenchNavigator.openInNewTab(ViewPagePO); await viewPage.enterHeading('heading'); - await expect(viewPage.viewTab.headingLocator).not.toBeVisible(); + await expect(viewPage.viewTab.heading).not.toBeVisible(); }); test('should not display the view tab heading if the tab height < 3.5rem', async ({appPO, workbenchNavigator}) => { @@ -53,7 +53,7 @@ test.describe('Workbench View', () => { const viewPage = await workbenchNavigator.openInNewTab(ViewPagePO); await viewPage.enterHeading('heading'); - await expect(viewPage.viewTab.headingLocator).not.toBeVisible(); + await expect(viewPage.viewTab.heading).not.toBeVisible(); }); test('should display the view tab heading if the tab height >= 3.5rem', async ({appPO, workbenchNavigator}) => { @@ -62,7 +62,7 @@ test.describe('Workbench View', () => { const viewPage = await workbenchNavigator.openInNewTab(ViewPagePO); await viewPage.enterHeading('heading'); - await expect(viewPage.viewTab.headingLocator).toBeVisible(); + await expect(viewPage.viewTab.heading).toBeVisible(); await expect(await viewPage.viewTab.getHeading()).toEqual('heading'); }); @@ -187,15 +187,15 @@ test.describe('Workbench View', () => { const viewPage = await workbenchNavigator.openInNewTab(ViewPagePO); // View tab expected to be pristine for new views - await expect(await viewPage.viewTab.isClosable()).toBe(true); + await expect(viewPage.viewTab.closeButton).toBeVisible(); // Prevent the view from being closed await viewPage.checkClosable(false); - await expect(await viewPage.viewTab.isClosable()).toBe(false); + await expect(viewPage.viewTab.closeButton).not.toBeVisible(); // Mark the view closable await viewPage.checkClosable(true); - await expect(await viewPage.viewTab.isClosable()).toBe(true); + await expect(viewPage.viewTab.closeButton).toBeVisible(); }); test('should emit when activating or deactivating a viewtab', async ({appPO, workbenchNavigator, consoleLogs}) => { @@ -283,12 +283,12 @@ test.describe('Workbench View', () => { const contextMenu1 = await viewPage.view.viewTab.openContextMenu(); // Expect menu item to be enabled. - await expect(await contextMenu1.menuItems.closeTab.isDisabled()).toBe(false); + await expect(contextMenu1.menuItems.closeTab.locator).not.toBeDisabled(); await viewPage.checkClosable(false); const contextMenu2 = await viewPage.view.viewTab.openContextMenu(); // Expect menu item to be disabled. - await expect(await contextMenu2.menuItems.closeTab.isDisabled()).toBe(true); + await expect(contextMenu2.menuItems.closeTab.locator).toBeDisabled(); }); test(`should not close 'non-closable' views via context menu 'Close all tabs'`, async ({appPO, workbenchNavigator}) => { diff --git a/projects/scion/e2e-testing/src/workbench/workbench-navigator.ts b/projects/scion/e2e-testing/src/workbench/workbench-navigator.ts index c8fcb1a2d..91a99ad18 100644 --- a/projects/scion/e2e-testing/src/workbench/workbench-navigator.ts +++ b/projects/scion/e2e-testing/src/workbench/workbench-navigator.ts @@ -16,6 +16,7 @@ import {RouterPagePO} from './page-object/router-page.po'; import {ViewPagePO} from './page-object/view-page.po'; import {LayoutPagePO} from './page-object/layout-page.po'; import {PerspectivePagePO} from './page-object/perspective-page.po'; +import {DialogOpenerPagePO} from './page-object/dialog-opener-page.po'; export interface Type extends Function { new(...args: any[]): T; @@ -33,6 +34,10 @@ export class WorkbenchNavigator { * Opens the page to test the message box in a new workbench tab. */ public openInNewTab(page: Type): Promise; + /** + * Opens the page to test the dialog in a new workbench tab. + */ + public openInNewTab(page: Type): Promise; /** * Opens the page to test the notification in a new workbench tab. */ @@ -67,6 +72,10 @@ export class WorkbenchNavigator { await startPage.openWorkbenchView('e2e-test-message-box-opener'); return new MessageBoxOpenerPagePO(this._appPO, viewId); } + case DialogOpenerPagePO: { + await startPage.openWorkbenchView('e2e-test-dialog-opener'); + return new DialogOpenerPagePO(this._appPO, {viewId}); + } case NotificationOpenerPagePO: { await startPage.openWorkbenchView('e2e-test-notification-opener'); return new NotificationOpenerPagePO(this._appPO, viewId); diff --git a/projects/scion/workbench/_index.scss b/projects/scion/workbench/_index.scss index 1d7d6a5ec..679c05d0a 100644 --- a/projects/scion/workbench/_index.scss +++ b/projects/scion/workbench/_index.scss @@ -129,6 +129,7 @@ @use './design/workbench-icon-font' as workbench-icons; @use './design/workbench-popup-global-styles' as workbench-popup; @use './design/workbench-view-drag-image-global-styles' as workbench-view-drag-image; +@use './design/workbench-dialog-global-styles' as workbench-dialog; // Install workbench theme $-built-in-themes: ( @@ -146,6 +147,7 @@ $icon-font: null !default; // Install global workbench styles @include workbench-popup.install-global-styles(); @include workbench-view-drag-image.install-global-styles(); +@include workbench-dialog.install-global-styles(); // Install Angular CDK styles @include cdk.a11y-visually-hidden(); diff --git a/projects/scion/workbench/design/_workbench-dark-theme-design-tokens.scss b/projects/scion/workbench/design/_workbench-dark-theme-design-tokens.scss index be5aa7058..7d95dbb65 100644 --- a/projects/scion/workbench/design/_workbench-dark-theme-design-tokens.scss +++ b/projects/scion/workbench/design/_workbench-dark-theme-design-tokens.scss @@ -37,6 +37,14 @@ $tokens: ( --sci-workbench-notification-severity-indicator-size: 6px, --sci-workbench-messagebox-max-width: 400px, --sci-workbench-messagebox-severity-indicator-size: 6px, + --sci-workbench-dialog-padding: .75em, + --sci-workbench-dialog-header-height: 2.75rem, + --sci-workbench-dialog-header-background-color: var(--sci-color-background-elevation), + --sci-workbench-dialog-header-divider-color: var(--sci-color-border), + --sci-workbench-dialog-title-font-family: inherit, + --sci-workbench-dialog-title-font-weight: normal, + --sci-workbench-dialog-title-font-size: 1.1rem, + --sci-workbench-dialog-title-align: center, --sci-workbench-contextmenu-width: 18rem, --sci-throbber-color: var(--sci-color-accent), ); diff --git a/projects/scion/workbench/design/_workbench-dialog-global-styles.scss b/projects/scion/workbench/design/_workbench-dialog-global-styles.scss new file mode 100644 index 000000000..6aaf201a5 --- /dev/null +++ b/projects/scion/workbench/design/_workbench-dialog-global-styles.scss @@ -0,0 +1,11 @@ +/** + * Provides styles for the workbench dialog. + */ +@mixin install-global-styles() { + .cdk-overlay-pane.wb-dialog-glass-pane { + width: 100%; + height: 100%; + display: grid; + pointer-events: none; + } +} diff --git a/projects/scion/workbench/design/_workbench-light-theme-design-tokens.scss b/projects/scion/workbench/design/_workbench-light-theme-design-tokens.scss index 8fae3e732..429b7cb90 100644 --- a/projects/scion/workbench/design/_workbench-light-theme-design-tokens.scss +++ b/projects/scion/workbench/design/_workbench-light-theme-design-tokens.scss @@ -37,5 +37,13 @@ $tokens: ( --sci-workbench-notification-severity-indicator-size: 6px, --sci-workbench-messagebox-max-width: 400px, --sci-workbench-messagebox-severity-indicator-size: 6px, + --sci-workbench-dialog-padding: .75em, + --sci-workbench-dialog-header-height: 2.75rem, + --sci-workbench-dialog-header-background-color: var(--sci-color-background-elevation), + --sci-workbench-dialog-header-divider-color: var(--sci-color-border), + --sci-workbench-dialog-title-font-family: inherit, + --sci-workbench-dialog-title-font-weight: normal, + --sci-workbench-dialog-title-font-size: 1.1rem, + --sci-workbench-dialog-title-align: center, --sci-workbench-contextmenu-width: 18rem, ); diff --git "a/projects/scion/workbench/src/lib/common/\311\265destroy-ref.ts" "b/projects/scion/workbench/src/lib/common/\311\265destroy-ref.ts" new file mode 100644 index 000000000..e416108cf --- /dev/null +++ "b/projects/scion/workbench/src/lib/common/\311\265destroy-ref.ts" @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2018-2023 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 {DestroyRef} from '@angular/core'; +import {noop} from 'rxjs'; + +/** + * Implementation of {@link DestroyRef} that can be used in non-Angular managed workbench objects such as handles. + */ +export class ɵDestroyRef implements DestroyRef { + + private _callbacks = new Set<() => void>(); + private _destroyed = false; + + public onDestroy(callback: () => void): () => void { + if (this._destroyed) { + callback(); + return noop; + } + + this._callbacks.add(callback); + return () => this._callbacks.delete(callback); + } + + public destroy(): void { + this._callbacks.forEach(callback => callback()); + this._callbacks.clear(); + this._destroyed = true; + } + + public get destroyed(): boolean { + return this._destroyed; + } +} diff --git a/projects/scion/workbench/src/lib/content-projection/view-container.reference.ts b/projects/scion/workbench/src/lib/content-projection/view-container.reference.ts index cf7c4dd26..06b928beb 100644 --- a/projects/scion/workbench/src/lib/content-projection/view-container.reference.ts +++ b/projects/scion/workbench/src/lib/content-projection/view-container.reference.ts @@ -78,3 +78,11 @@ export const VIEW_DROP_PLACEHOLDER_HOST = new InjectionToken new ViewContainerReference(), }); + +/** + * DI token to inject the DOM location of the {@link WorkbenchComponent} HTML element. + */ +export const WORKBENCH_ELEMENT_REF = new InjectionToken('WORKBENCH_ELEMENT_REF', { + providedIn: 'root', + factory: () => new ViewContainerReference(), +}); diff --git a/projects/scion/workbench/src/lib/dialog/public_api.ts b/projects/scion/workbench/src/lib/dialog/public_api.ts new file mode 100644 index 000000000..ae059a86b --- /dev/null +++ b/projects/scion/workbench/src/lib/dialog/public_api.ts @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2018-2023 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 + */ + +export {WorkbenchDialog, WorkbenchDialogSize} from './workbench-dialog'; +export {WorkbenchDialogOptions} from './workbench-dialog.options'; +export {WorkbenchDialogService} from './workbench-dialog.service'; diff --git a/projects/scion/workbench/src/lib/dialog/workbench-dialog.component.html b/projects/scion/workbench/src/lib/dialog/workbench-dialog.component.html new file mode 100644 index 000000000..4d1c29aa9 --- /dev/null +++ b/projects/scion/workbench/src/lib/dialog/workbench-dialog.component.html @@ -0,0 +1,21 @@ +
+
+
+ {{title}} +
+ +
+ + + + +
diff --git a/projects/scion/workbench/src/lib/dialog/workbench-dialog.component.scss b/projects/scion/workbench/src/lib/dialog/workbench-dialog.component.scss new file mode 100644 index 000000000..8b1239c4e --- /dev/null +++ b/projects/scion/workbench/src/lib/dialog/workbench-dialog.component.scss @@ -0,0 +1,115 @@ +@mixin show-ellipsis-on-overflow { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} + +:host { + --ɵdialog-transform-translate-x: 0; + --ɵdialog-transform-translate-y: 0; + --ɵdialog-min-height: initial; + --ɵdialog-height: initial; + --ɵdialog-max-height: initial; + --ɵdialog-min-width: initial; + --ɵdialog-width: initial; + --ɵdialog-max-width: initial; + --ɵdialog-padding: var(--sci-workbench-dialog-padding); + + display: flex; + flex-direction: column; + align-items: center; + position: relative; // positioning context for the dialog pane + // Enable pointer events because disabled on CDK overlay (div.cdk-overlay-pane.wb-dialog-glass-pane). + pointer-events: auto; + + &[data-viewid] { + // The overlay of view-modal dialogs covers the touch area of adjacent part dividers since rendered on top of the workbench layout. + // To improve usability, we shrink the overlay by a few pixels to make it easier for the user to grab the divider. + margin: 2px; + } + + > section.dialog-pane { + display: flex; + flex-direction: column; + position: absolute; + top: 3%; + gap: calc(1.25 * var(--ɵdialog-padding)); + border: 1px solid var(--sci-color-border); + border-radius: var(--sci-corner); + color: var(--sci-color-text); + background-color: var(--sci-color-background-elevation); + box-shadow: var(--sci-elevation) var(--sci-static-color-black); + transform: translateX(calc(1px * var(--ɵdialog-transform-translate-x))) translateY(calc(1px * var(--ɵdialog-transform-translate-y))); + min-height: var(--ɵdialog-min-height); + height: var(--ɵdialog-height); + max-height: var(--ɵdialog-max-height); + min-width: var(--ɵdialog-min-width); + width: var(--ɵdialog-width); + max-width: var(--ɵdialog-max-width); + + > header { + flex: none; + display: flex; + place-content: flex-end; + gap: var(--ɵdialog-padding); + background-color: var(--sci-workbench-dialog-header-background-color); + border-top-left-radius: inherit; + border-top-right-radius: inherit; + border-bottom: 1px solid var(--sci-workbench-dialog-header-divider-color); + height: var(--sci-workbench-dialog-header-height); + padding-inline: var(--ɵdialog-padding); + + > div.title { + flex: auto; + display: flex; + align-items: center; + position: relative; // positioning context for the title span + + > span { + position: absolute; // out of document flow to not contribute to the dialog width + left: 0; + right: 0; + @include show-ellipsis-on-overflow; + font-family: var(--sci-workbench-dialog-title-font-family); + font-size: var(--sci-workbench-dialog-title-font-size); + font-weight: var(--sci-workbench-dialog-title-font-weight); + text-align: var(--sci-workbench-dialog-title-align); + } + } + + > button.close { + all: unset; + flex: none; + cursor: pointer; + align-self: center; + + &:not(:hover) { + opacity: .75; + } + } + } + + > sci-viewport { + flex: auto; + + &::part(content) { + padding: var(--ɵdialog-padding); + } + } + + &.blinking { + animation-duration: 50ms; + animation-iteration-count: infinite; + animation-name: blink-animation; + + @keyframes blink-animation { + from { + transform: translateX(calc(calc(1px * var(--ɵdialog-transform-translate-x)) - 2px)) translateY(calc(calc(1px * var(--ɵdialog-transform-translate-y)) - 1px)); + } + to { + transform: translateX(calc(calc(1px * var(--ɵdialog-transform-translate-x)) + 2px)) translateY(calc(calc(1px * var(--ɵdialog-transform-translate-y)) + 1px)); + } + } + } + } +} diff --git a/projects/scion/workbench/src/lib/dialog/workbench-dialog.component.ts b/projects/scion/workbench/src/lib/dialog/workbench-dialog.component.ts new file mode 100644 index 000000000..5815c9639 --- /dev/null +++ b/projects/scion/workbench/src/lib/dialog/workbench-dialog.component.ts @@ -0,0 +1,244 @@ +/* + * Copyright (c) 2018-2023 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, DestroyRef, ElementRef, HostBinding, HostListener, inject, NgZone, OnInit, ViewChild} from '@angular/core'; + +import {EMPTY, fromEvent, Subject, switchMap, timer} from 'rxjs'; +import {takeUntil} from 'rxjs/operators'; +import {WorkbenchLayoutService} from '../layout/workbench-layout.service'; +import {A11yModule, CdkTrapFocus} from '@angular/cdk/a11y'; +import {AsyncPipe, DOCUMENT, NgComponentOutlet, NgIf} from '@angular/common'; +import {CoerceObservablePipe} from '../common/coerce-observable.pipe'; +import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; +import {ɵWorkbenchDialog} from './ɵworkbench-dialog'; +import {MoveDelta, MoveDirective} from '../message-box/move.directive'; +import {SciViewportComponent} from '@scion/components/viewport'; +import {animate, AnimationMetadata, style, transition, trigger} from '@angular/animations'; +import {subscribeInside} from '@scion/toolkit/operators'; +import {WorkbenchDialogRegistry} from './workbench-dialog.registry'; + +/** + * Renders the workbench dialog. + * + * This component is added to a CDK overlay that is aligned with the modality area defined by the dialog. + * The host element `wb-dialog` acts as the glass pane for the dialog, with the actual dialog rendered in the center of the glass pane. + * The dialog component is added to a viewport and a focus trap is installed. Upon creation, this component focuses the first focusable + * element and then tracks the focus in order to restore it when the dialog is re-attached. + */ +@Component({ + selector: 'wb-dialog', + templateUrl: './workbench-dialog.component.html', + styleUrls: ['./workbench-dialog.component.scss'], + standalone: true, + imports: [ + NgIf, + AsyncPipe, + NgComponentOutlet, + A11yModule, + MoveDirective, + CoerceObservablePipe, + SciViewportComponent, + ], + animations: [ + trigger('enter', provideEnterAnimation()), + ], +}) +export class WorkbenchDialogComponent implements OnInit, AfterViewInit { + + private readonly _cancelBlinkTimer$ = new Subject(); + private readonly _document = inject(DOCUMENT); + private _activeElement: HTMLElement | undefined; + + @ViewChild(CdkTrapFocus, {static: true}) + private _cdkTrapFocus!: CdkTrapFocus; + + @ViewChild('dialog_pane', {static: true}) + private _dialogPane!: ElementRef; + + @HostBinding('style.--ɵdialog-transform-translate-x') + protected transformTranslateX = 0; + + @HostBinding('style.--ɵdialog-transform-translate-y') + protected transformTranslateY = 0; + + @HostBinding('style.--ɵdialog-min-height') + protected get minHeight(): string | undefined { + return this.dialog.size.minHeight; + } + + @HostBinding('style.--ɵdialog-height') + protected get height(): string | undefined { + return this.dialog.size.height; + } + + @HostBinding('style.--ɵdialog-max-height') + protected get maxHeight(): string | undefined { + return this.dialog.size.maxHeight; + } + + @HostBinding('style.--ɵdialog-min-width') + protected get minWidth(): string | undefined { + return this.dialog.size.minWidth; + } + + @HostBinding('style.--ɵdialog-width') + protected get width(): string | undefined { + return this.dialog.size.width; + } + + @HostBinding('style.--ɵdialog-max-width') + protected get maxWidth(): string | undefined { + return this.dialog.size.maxWidth; + } + + @HostBinding('style.--ɵdialog-padding') + protected get padding(): string | undefined { + return this.dialog.padding; + } + + @HostBinding('attr.class') + public get cssClasses(): string { + return this.dialog.cssClass; + } + + @HostBinding('attr.data-viewid') + protected get viewId(): string | undefined { + return this.dialog.context.view?.id; + } + + public blinking = false; + + constructor(public dialog: ɵWorkbenchDialog, + private _zone: NgZone, + private _workbenchLayoutService: WorkbenchLayoutService, + private _workbenchDialogRegistry: WorkbenchDialogRegistry, + private _destroyRef: DestroyRef) { + this.setDialogOffset(); + } + + public ngOnInit(): void { + this.trackFocus(); + this.preventFocusIfBlocked(); + } + + public ngAfterViewInit(): void { + this.focus(); + } + + /** + * Focuses the last focused element, if any, or the first focusable element otherwise. + */ + public focus(): void { + if (this._activeElement) { + this._activeElement.focus(); + } + else if (!this._cdkTrapFocus.focusTrap.focusFirstTabbableElement()) { + this._dialogPane.nativeElement.focus(); + } + } + + private setDialogOffset(): void { + const stackPosition = this.dialog.getPositionInDialogStack(); + this.transformTranslateX = stackPosition * 10; + this.transformTranslateY = stackPosition * 10; + } + + /** + * Tracks the focus of the dialog. + */ + private trackFocus(): void { + fromEvent(this._dialogPane.nativeElement, 'focusin') + .pipe( + subscribeInside(continueFn => this._zone.runOutsideAngular(continueFn)), + takeUntilDestroyed(this._destroyRef), + ) + .subscribe(() => { + this._activeElement = this._document.activeElement instanceof HTMLElement ? this._document.activeElement : undefined; + }); + } + + /** + * Prevent dialog from gaining focus via sequential keyboard navigation when another dialog overlays it. + */ + private preventFocusIfBlocked(): void { + this.dialog.blocked$ + .pipe( + switchMap(blocked => blocked ? fromEvent(this._dialogPane.nativeElement, 'focusin') : EMPTY), + takeUntilDestroyed(this._destroyRef), + ) + .subscribe(() => { + this._workbenchDialogRegistry.top({viewId: this.dialog.context.view?.id})!.focus(); + }); + } + + /** + * Makes the dialog blink for some short time. + */ + private blink(): void { + this._cancelBlinkTimer$.next(); + this.blinking = true; + + timer(300) + .pipe( + takeUntil(this._cancelBlinkTimer$), + takeUntilDestroyed(this._destroyRef), + ) + .subscribe(() => { + this.blinking = false; + }); + } + + @HostListener('keydown.escape', ['$event']) + protected onEscape(event: Event): void { + if (this.dialog.closable) { + this.dialog.close(); + event.stopPropagation(); + } + } + + @HostListener('mousedown', ['$event']) + protected onGlassPaneClick(event: MouseEvent): void { + event.preventDefault(); // to not lose focus + this.blink(); + } + + protected onMoveStart(): void { + this._workbenchLayoutService.notifyDragStarting(); + } + + protected onMove(delta: MoveDelta): void { + this.transformTranslateX += delta.deltaX; + this.transformTranslateY += delta.deltaY; + } + + protected onMoveEnd(): void { + this._workbenchLayoutService.notifyDragEnding(); + } + + protected onCloseClick(): void { + this.dialog.close(); + } + + protected onCloseMouseDown(event: Event): void { + event.stopPropagation(); // Prevent dragging with the close button. + } +} + +/** + * Returns animation metadata to slide-in a new dialog. + */ +function provideEnterAnimation(): AnimationMetadata[] { + return [ + transition(':enter', [ + style({opacity: 0, bottom: '100%', top: 'unset'}), + animate('.1s ease-out', style({opacity: 1, bottom: '*'})), + ]), + ]; +} diff --git a/projects/scion/workbench/src/lib/dialog/workbench-dialog.options.ts b/projects/scion/workbench/src/lib/dialog/workbench-dialog.options.ts new file mode 100644 index 000000000..033f03c35 --- /dev/null +++ b/projects/scion/workbench/src/lib/dialog/workbench-dialog.options.ts @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2018-2023 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 {Injector} from '@angular/core'; + +/** + * Controls how to open a dialog. + */ +export interface WorkbenchDialogOptions { + + /** + * Optional data to pass to the dialog component. Inputs are available as input properties in the dialog component. + * + * ```ts + * @Input() + * public myInputName: string; + * ``` + */ + inputs?: {[name: string]: unknown}; + + /** + * Controls which area of the application to block by the dialog. + * + * - **Application-modal:** + * Use to block the workbench, or the browser's viewport if configured in {@link WorkbenchModuleConfig.dialog.modalityScope}. + * + * - **View-modal:** + * Use to block only the contextual view of the dialog, allowing the user to interact with other views. + * This is the default if opening the dialog in the context of a view. + */ + modality?: 'application' | 'view'; + + /** + * Sets the injector for the instantiation of the dialog component, giving control over the objects available + * for injection into the dialog component. If not specified, uses the application's root injector, or the view's + * injector if opened in the context of a view. + * + * ```ts + * Injector.create({ + * parent: ..., + * providers: [ + * {provide: , useValue: } + * ], + * }) + * ``` + */ + injector?: Injector; + + /** + * Specifies CSS class(es) to be added to the dialog, useful in end-to-end tests for locating the dialog. + */ + cssClass?: string | string[]; + + /** + * Controls whether to animate the opening of the dialog. Defaults to `false`. + */ + animate?: boolean; + + /** + * Specifies the context in which to open the dialog. + */ + context?: { + /** + * Allows controlling which view to block when opening a view-modal dialog. + * + * By default, if opening the dialog in the context of a view, that view is used as the contextual view. + */ + viewId?: string; + }; +} diff --git a/projects/scion/workbench/src/lib/dialog/workbench-dialog.registry.ts b/projects/scion/workbench/src/lib/dialog/workbench-dialog.registry.ts new file mode 100644 index 000000000..5a5b4b057 --- /dev/null +++ b/projects/scion/workbench/src/lib/dialog/workbench-dialog.registry.ts @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2018-2023 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, OnDestroy} from '@angular/core'; +import {ɵWorkbenchDialog} from './ɵworkbench-dialog'; +import {BehaviorSubject, Observable} from 'rxjs'; +import {map} from 'rxjs/operators'; + +/** + * Registry for {@link ɵWorkbenchDialog} objects. + */ +@Injectable({providedIn: 'root'}) +export class WorkbenchDialogRegistry implements OnDestroy { + + private _dialogs$ = new BehaviorSubject<ɵWorkbenchDialog[]>([]); + + public register(dialog: ɵWorkbenchDialog): void { + this._dialogs$.next(this._dialogs$.value.concat(dialog)); + } + + public unregister(dialog: ɵWorkbenchDialog): void { + this._dialogs$.next(this._dialogs$.value.filter(candidate => candidate !== dialog)); + } + + public indexOf(dialog: ɵWorkbenchDialog): number { + const index = this.dialogs({viewId: dialog.context.view?.id}).indexOf(dialog); + if (index === -1) { + throw Error('[NullDialogError] Dialog not found'); + } + return index; + } + + /** + * Returns currently opened dialogs, sorted by the time they were opened, based on the specified filter. + */ + public dialogs(filter?: {viewId?: string} | ((dialog: ɵWorkbenchDialog) => boolean)): ɵWorkbenchDialog[] { + const filterFn = typeof filter === 'function' ? filter : (dialog: ɵWorkbenchDialog) => !filter?.viewId || dialog.context.view?.id === filter.viewId; + return this._dialogs$.value.filter(filterFn); + } + + /** + * Observes the topmost dialog in the given context. + * + * If a view context is specified, the topmost dialog that overlays that view is returned. + * This can be either a view-modal or an application-modal dialog. Otherwise, returns + * the topmost application-modal dialog. + */ + public top$(context?: {viewId?: string}): Observable<ɵWorkbenchDialog | undefined> { + return this._dialogs$.pipe(map(() => this.top(context))); + } + + /** + * Returns the topmost dialog in the given context. + * + * If a view context is specified, the topmost dialog that overlays that view is returned. + * This can be either a view-modal or an application-modal dialog. Otherwise, returns + * the topmost application-modal dialog. + */ + public top(context?: {viewId?: string}): ɵWorkbenchDialog | undefined { + return this.dialogs(dialog => !dialog.context.view || dialog.context.view.id === context?.viewId).at(-1); + } + + public ngOnDestroy(): void { + this._dialogs$.value.forEach(dialog => dialog.destroy()); + this._dialogs$.next([]); + } +} diff --git a/projects/scion/workbench/src/lib/dialog/workbench-dialog.service.ts b/projects/scion/workbench/src/lib/dialog/workbench-dialog.service.ts new file mode 100644 index 000000000..e7c21dbbe --- /dev/null +++ b/projects/scion/workbench/src/lib/dialog/workbench-dialog.service.ts @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2018-2023 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} from '@angular/core'; +import {WorkbenchDialogOptions} from './workbench-dialog.options'; +import {ComponentType} from '@angular/cdk/portal'; +import {ɵWorkbenchDialogService} from './ɵworkbench-dialog.service'; + +/** + * Enables the display of a component in a modal dialog. + * + * A dialog is a visual element for focused interaction with the user, such as prompting the user for input or confirming actions. + * Displayed on top of other content, a dialog blocks interaction with other parts of the application. The user can move or resize + * a dialog. + * + * ## Modality + * A dialog can be view-modal or application-modal. + * + * A view-modal dialog blocks only a specific view, allowing the user to interact with other views. An application-modal dialog blocks + * the workbench, or the browser's viewport if configured in {@link WorkbenchModuleConfig.dialog.modalityScope}. + * + * ## Dialog Stack + * Multiple dialogs are stacked, and only the topmost dialog in each modality stack can be interacted with. + * + * ## Dialog Component + * The dialog component can inject the {@link WorkbenchDialog} handle to interact with the dialog, such as setting the title or closing the dialog. + * Inputs passed to the dialog are available as input properties in the dialog component. + */ +@Injectable({providedIn: 'root', useExisting: ɵWorkbenchDialogService}) +export abstract class WorkbenchDialogService { + + /** + * Opens a dialog with the specified component and options. + * + * By default, the calling context determines the modality of the dialog. If the dialog is opened from a view, only this view is blocked. + * To open the dialog with a different modality, specify the modality in {@link WorkbenchDialogOptions.modality}. + * + * Data can be passed to the component as inputs via {@link WorkbenchDialogOptions.inputs} property or by providing a custom injector + * via {@link WorkbenchDialogOptions.injector} property. Dialog inputs are available as input properties in the dialog component. + * + * @param component - Specifies the component to display in the dialog. + * @param options - Controls how to open a dialog. + * @returns Promise that resolves to the dialog result, if any, or that rejects if the dialog couldn't be opened or was closed with an error. + */ + public abstract open(component: ComponentType, options?: WorkbenchDialogOptions): Promise; +} diff --git a/projects/scion/workbench/src/lib/dialog/workbench-dialog.ts b/projects/scion/workbench/src/lib/dialog/workbench-dialog.ts new file mode 100644 index 000000000..ed0a4301d --- /dev/null +++ b/projects/scion/workbench/src/lib/dialog/workbench-dialog.ts @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2018-2023 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'; + +/** + * Handle to interact with a dialog opened via {@link WorkbenchDialogService}. + * + * The dialog component can inject this handle to interact with the dialog, such as setting the title or closing the dialog. + * + * Dialog inputs are available as input properties in the dialog component. + */ +export abstract class WorkbenchDialog { + + /** + * Sets the title of the dialog; can be a string literal or an Observable. + */ + public abstract title: string | Observable | undefined; + + /** + * Specifies the preferred dialog size. + */ + public abstract readonly size: WorkbenchDialogSize; + + /** + * Specifies the padding of the dialog. + * By default, uses the padding as specified in `--sci-workbench-dialog-padding` CSS variable. + */ + public abstract padding: string | undefined; + + /** + * Specifies if to display a close button in the dialog header. Defaults to `true`. + */ + public abstract closable: boolean; + + /** + * Specifies CSS class(es) to be added to the dialog, useful in end-to-end tests for locating the dialog. + */ + public abstract cssClass: string | string[]; + + /** + * Closes the dialog. Optionally, pass a result to the dialog opener. + */ + public abstract close(result?: R): void; + + /** + * Closes the dialog returning the given error to the dialog opener. + */ + public abstract closeWithError(error: Error | string): void; +} + +/** + * Represents the preferred dialog size. + */ +export interface WorkbenchDialogSize { + /** + * Specifies the minimum height of the dialog. + */ + minHeight?: string; + /** + * Specifies the height of the dialog, displaying a vertical scrollbar if its content overflows. + * If not specified, the dialog adapts its height to its context height, respecting any `minHeight' or `maxHeight' constraint. + */ + height?: string; + /** + * Specifies the maximum height of the dialog. + */ + maxHeight?: string; + /** + * Specifies the minimum width of the dialog. + */ + minWidth?: string; + /** + * Specifies the width of the dialog, displaying a horizontal scrollbar if its content overflows. + * If not specified, the dialog adapts its width to its context width, respecting any `minWidth' or `maxWidth' constraint. + */ + width?: string; + /** + * Specifies the maximum width of the dialog. + */ + maxWidth?: string; +} diff --git "a/projects/scion/workbench/src/lib/dialog/\311\265workbench-dialog.service.ts" "b/projects/scion/workbench/src/lib/dialog/\311\265workbench-dialog.service.ts" new file mode 100644 index 000000000..50524efb9 --- /dev/null +++ "b/projects/scion/workbench/src/lib/dialog/\311\265workbench-dialog.service.ts" @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2018-2023 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 {inject, Injectable, Injector, NgZone, Optional, runInInjectionContext} from '@angular/core'; +import {WorkbenchDialogOptions} from './workbench-dialog.options'; +import {ɵWorkbenchDialog} from './ɵworkbench-dialog'; +import {ɵWorkbenchView} from '../view/ɵworkbench-view.model'; +import {WorkbenchViewRegistry} from '../view/workbench-view.registry'; +import {firstValueFrom} from 'rxjs'; +import {WorkbenchDialogRegistry} from './workbench-dialog.registry'; +import {filter} from 'rxjs/operators'; +import {ComponentType} from '@angular/cdk/portal'; +import {WorkbenchDialogService} from './workbench-dialog.service'; +import {DOCUMENT} from '@angular/common'; + +/** @inheritDoc */ +@Injectable({providedIn: 'root'}) +export class ɵWorkbenchDialogService implements WorkbenchDialogService { + + private readonly _document = inject(DOCUMENT); + + constructor(private _viewRegistry: WorkbenchViewRegistry, + private _dialogRegistry: WorkbenchDialogRegistry, + private _zone: NgZone, + private _injector: Injector, + @Optional() private _view?: ɵWorkbenchView) { + } + + /** @inheritDoc */ + public async open(component: ComponentType, options?: WorkbenchDialogOptions): Promise { + // Ensure to run in Angular zone to display the dialog even when called from outside the Angular zone. + if (!NgZone.isInAngularZone()) { + return this._zone.run(() => this.open(component, options)); + } + + // Resolve view that opened the dialog, if any. + const contextualView = this.resolveContextualView(options); + + // Delay the opening of a view-modal dialog until all application-modal dialogs are closed. + // Otherwise, the view-modal dialog would overlap already opened application-modal dialogs. + if (contextualView) { + await this.waitUntilApplicationModalDialogsClosed(); + } + + const injector = options?.injector ?? this._injector; + const dialog = runInInjectionContext(injector, () => new ɵWorkbenchDialog(component, options ?? {}, {view: contextualView})); + this._dialogRegistry.register(dialog); + + // Capture focused element to restore focus when closing the dialog. + const previouslyFocusedElement = this._document.activeElement instanceof HTMLElement ? this._document.activeElement : undefined; + try { + return await dialog.open(); + } + finally { + this._dialogRegistry.unregister(dialog); + + // Restore focus to previously focused element when closing the last dialog in the current context. + if (previouslyFocusedElement && !this._dialogRegistry.top({viewId: contextualView?.id})) { + previouslyFocusedElement.focus(); + } + } + } + + /** + * Resolves the contextual view to stick the dialog to. + */ + private resolveContextualView(options?: WorkbenchDialogOptions): ɵWorkbenchView | undefined { + if (options?.modality === 'application') { + return undefined; + } + if (options?.context?.viewId) { + return this._viewRegistry.get(options.context.viewId); + } + else if (this._view) { + return this._view; + } + return undefined; + } + + /** + * Returns a Promise that resolves when all application modal-dialogs are closed. If none are opened, the Promise resolves immediately. + */ + private async waitUntilApplicationModalDialogsClosed(): Promise { + await firstValueFrom(this._dialogRegistry.top$().pipe(filter(top => !top))); + } +} diff --git "a/projects/scion/workbench/src/lib/dialog/\311\265workbench-dialog.ts" "b/projects/scion/workbench/src/lib/dialog/\311\265workbench-dialog.ts" new file mode 100644 index 000000000..58cc99817 --- /dev/null +++ "b/projects/scion/workbench/src/lib/dialog/\311\265workbench-dialog.ts" @@ -0,0 +1,303 @@ +/* + * Copyright (c) 2018-2023 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 {BehaviorSubject, combineLatest, EMPTY, firstValueFrom, map, merge, Observable, of, switchMap} from 'rxjs'; +import {ComponentRef, inject, Injector, NgZone} from '@angular/core'; +import {WorkbenchDialog, WorkbenchDialogSize} from './workbench-dialog'; +import {WorkbenchDialogOptions} from './workbench-dialog.options'; +import {ComponentPortal, ComponentType} from '@angular/cdk/portal'; +import {Overlay, OverlayRef} from '@angular/cdk/overlay'; +import {WorkbenchDialogComponent} from './workbench-dialog.component'; +import {ɵWorkbenchView} from '../view/ɵworkbench-view.model'; +import {WorkbenchDialogRegistry} from './workbench-dialog.registry'; +import {ɵDestroyRef} from '../common/ɵdestroy-ref'; +import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; +import {setStyle} from '../common/dom.util'; +import {fromDimension$} from '@scion/toolkit/observable'; +import {subscribeInside} from '@scion/toolkit/operators'; +import {ViewDragService} from '../view-dnd/view-drag.service'; +import {WORKBENCH_ELEMENT_REF} from '../content-projection/view-container.reference'; +import {Arrays} from '@scion/toolkit/util'; +import {WorkbenchModuleConfig} from '../workbench-module-config'; +import {filter} from 'rxjs/operators'; + +/** @inheritDoc */ +export class ɵWorkbenchDialog implements WorkbenchDialog { + + private readonly _overlayRef: OverlayRef; + private readonly _portal: ComponentPortal; + private readonly _workbenchDialogRegistry = inject(WorkbenchDialogRegistry); + private readonly _zone = inject(NgZone); + private readonly _workbenchModuleConfig = inject(WorkbenchModuleConfig); + private readonly _destroyRef = new ɵDestroyRef(); + private _componentRef: ComponentRef | undefined; + private _cssClass: string; + + public readonly size: WorkbenchDialogSize = {}; + public title: string | Observable | undefined; + public padding: string | undefined; + public closable = true; + + /** + * Indicates whether this dialog is attached to the DOM. + */ + private readonly _attached$: Observable; + + /** + * Indicates whether this dialog is blocked by other dialog(s) that overlay this dialog. + */ + public readonly blocked$ = new BehaviorSubject(false); + + /** + * Contains the result to be passed to the dialog opener. + */ + private _result: R | ɵDialogErrorResult | undefined; + + constructor(public component: ComponentType, public _options: WorkbenchDialogOptions, public context: {view?: ɵWorkbenchView}) { + this._overlayRef = this.createOverlay(); + this._portal = this.createPortal(); + this._cssClass = Arrays.coerce(this._options.cssClass).join(' '); + this._attached$ = this.monitorHostElementAttached$(); + + this.hideOnHostElementDetachOrViewDrag(); + this.stickToHostElement(); + this.blockWhenNotOnTop(); + this.restoreFocusOnAttach(); + this.restoreFocusOnUnblock(); + } + + public async open(): Promise { + // Wait for the overlay to be initially positioned to have a smooth slide-in animation. + if (this.animate) { + await firstValueFrom(fromDimension$(this._overlayRef.hostElement)); + } + + // Attach the dialog portal to the overlay. + this._componentRef = this._overlayRef.attach(this._portal); + + // Ensure to destroy this handle on browser back/forward navigation. + this._componentRef.onDestroy(() => this.destroy()); + + // Trigger a manual change detection cycle to avoid 'ExpressionChangedAfterItHasBeenCheckedError' + // when the dialog sets dialog-specific properties such as title or size during construction. + this._componentRef.changeDetectorRef.detectChanges(); + + // Wait for the dialog to close, resolving to its result or rejecting if closed with an error. + return new Promise((resolve, reject) => { + this._destroyRef.onDestroy(() => { + if (this._result instanceof ɵDialogErrorResult) { + reject(this._result.error); + } + else { + resolve(this._result); + } + }); + }); + } + + /** @inheritDoc */ + public close(result?: R): void { + this._result = result; + this.destroy(); + } + + /** @inheritDoc */ + public closeWithError(error: Error | string): void { + this._result = new ɵDialogErrorResult(error); + this.destroy(); + } + + /** + * Inputs passed to the dialog. + */ + public get inputs(): {[name: string]: unknown} | undefined { + return this._options.inputs; + } + + /** + * Indicates if to animate the dialog. + */ + public get animate(): boolean { + return this._options.animate ?? false; + } + + /** + * Focuses the dialog. + */ + public focus(): void { + if (!this.blocked$.value) { + this._componentRef?.instance.focus(); + } + } + + /** + * Returns the position of the dialog in the dialog stack. + */ + public getPositionInDialogStack(): number { + return this._workbenchDialogRegistry.indexOf(this); + } + + public set cssClass(cssClass: string | string[]) { + this._cssClass = new Array().concat(this._options.cssClass ?? []).concat(cssClass).join(' '); + } + + public get cssClass(): string { + return this._cssClass; + } + + private createPortal(): ComponentPortal { + return new ComponentPortal(WorkbenchDialogComponent, null, Injector.create({ + parent: inject(Injector), + providers: [ + {provide: ɵWorkbenchDialog, useValue: this}, + {provide: WorkbenchDialog, useExisting: ɵWorkbenchDialog}, + ], + })); + } + + /** + * Creates a dedicated overlay per dialog to place it on top of previously created overlays, such as dialogs, popups, dropdowns, etc. + */ + private createOverlay(): OverlayRef { + const overlay = inject(Overlay); + return overlay.create({ + disposeOnNavigation: true, // dispose dialog on browser back/forward navigation + panelClass: ['wb-dialog-glass-pane'], + positionStrategy: overlay.position().global(), + scrollStrategy: overlay.scrollStrategies.noop(), + }); + } + + /** + * Restores focus when re-attaching this dialog. + */ + private restoreFocusOnAttach(): void { + this._attached$ + .pipe( + filter(Boolean), + takeUntilDestroyed(), + ) + .subscribe(() => { + this.focus(); + }); + } + + /** + * Restores focus when unblocking this dialog. + */ + private restoreFocusOnUnblock(): void { + this.blocked$ + .pipe( + filter(blocked => !blocked), + takeUntilDestroyed(), + ) + .subscribe(() => { + this.focus(); + }); + } + + /** + * Monitors attachment of the host element. + */ + private monitorHostElementAttached$(): Observable { + if (this.context.view) { + return this.context.view.portal.attached$; + } + if (this._workbenchModuleConfig.dialog?.modalityScope === 'viewport') { + return of(true); + } + return inject(WORKBENCH_ELEMENT_REF).ref$.pipe(map(ref => !!ref)); + } + + /** + * Hides this dialog when either its host element is detached or during view drag and drop operation. + */ + private hideOnHostElementDetachOrViewDrag(): void { + const viewDragService = inject(ViewDragService); + const viewDrag$ = merge( + viewDragService.viewDragStart$.pipe(map(() => true)), + viewDragService.viewDragEnd$.pipe(map(() => false)), + of(false), // to initialize `combineLatest` + ); + + combineLatest([this._attached$, viewDrag$]) + .pipe( + subscribeInside(fn => this._zone.runOutsideAngular(fn)), + takeUntilDestroyed(this._destroyRef), + ) + .subscribe(([attached, dragging]) => { + const hideDialog = !attached || dragging; + + // Hide via `visibility: hidden` instead of `display: none` in order to preserve the dimension of the dialog. + setStyle(this._overlayRef.overlayElement, {visibility: hideDialog ? 'hidden' : null}); + }); + } + + /** + * Aligns this dialog with the boundaries of the host element. + */ + private stickToHostElement(): void { + if (this._options.modality === 'application' && this._workbenchModuleConfig.dialog?.modalityScope === 'viewport') { + setStyle(this._overlayRef.hostElement, {inset: 0}); + } + else { + const workbenchViewElement$ = (view: ɵWorkbenchView): Observable => of(view.portal.componentRef.location.nativeElement); + const workbenchRootElement$ = (): Observable => inject(WORKBENCH_ELEMENT_REF).ref$.pipe(map(ref => ref?.element.nativeElement)); + const hostElement$ = this.context.view ? workbenchViewElement$(this.context.view) : workbenchRootElement$(); + + hostElement$ + .pipe( + switchMap(hostElement => hostElement ? fromDimension$(hostElement) : EMPTY), + map(({element: hostElement}) => hostElement.getBoundingClientRect()), + subscribeInside(fn => this._zone.runOutsideAngular(fn)), + takeUntilDestroyed(this._destroyRef), + ) + .subscribe(({top, left, width, height}) => { + setStyle(this._overlayRef.hostElement, { + top: `${top}px`, + left: `${left}px`, + width: `${width}px`, + height: `${height}px`, + }); + }); + } + } + + /** + * Blocks this dialog if not the topmost dialog in its context. + */ + private blockWhenNotOnTop(): void { + this._workbenchDialogRegistry.top$({viewId: this.context.view?.id}) + .pipe( + map(top => top !== this), + takeUntilDestroyed(this._destroyRef), + ) + .subscribe(this.blocked$); + } + + /** + * Destroys this dialog and associated resources. + */ + public destroy(): void { + if (!this._destroyRef.destroyed) { + this._destroyRef.destroy(); + this._overlayRef.dispose(); + } + } +} + +/** + * Wrapper to identify an erroneous result. + */ +class ɵDialogErrorResult { + + constructor(public error: string | Error) { + } +} diff --git a/projects/scion/workbench/src/lib/message-box/message-box.spec.ts b/projects/scion/workbench/src/lib/message-box/message-box.spec.ts index e564586e4..1d4565abd 100644 --- a/projects/scion/workbench/src/lib/message-box/message-box.spec.ts +++ b/projects/scion/workbench/src/lib/message-box/message-box.spec.ts @@ -39,7 +39,7 @@ describe('MessageBox', () => { TestBed.inject(MessageBoxService).open({content: 'message', cssClass: 'testee'}).then(); // Expect message box to show - expect(fixture.debugElement.query(By.css('wb-message-box.testee'))).toBeDefined(); + expect(fixture.debugElement.query(By.css('wb-message-box.testee'))).not.toBeNull(); }); it('should display view-modal message box', async () => { @@ -64,7 +64,7 @@ describe('MessageBox', () => { viewDebugElement.injector.get(MessageBoxService).open({content: 'Message from View', cssClass: 'testee'}).then(); // Expect message box to show - expect(fixture.debugElement.query(By.css('wb-message-box.testee'))).toBeDefined(); + expect(fixture.debugElement.query(By.css('wb-message-box.testee'))).not.toBeNull(); }); /**************************************************************************************************** diff --git a/projects/scion/workbench/src/lib/part/view-tab/view-tab.component.html b/projects/scion/workbench/src/lib/part/view-tab/view-tab.component.html index 8e79b0ade..291cf138a 100644 --- a/projects/scion/workbench/src/lib/part/view-tab/view-tab.component.html +++ b/projects/scion/workbench/src/lib/part/view-tab/view-tab.component.html @@ -12,7 +12,7 @@
-