diff --git a/README.md b/README.md index 4cfe4ac56..52fbc74fa 100644 --- a/README.md +++ b/README.md @@ -16,12 +16,12 @@ SCION Workbench enables the creation of Angular web applications that require a - [**Getting Started**][link-getting-started]\ Follow these steps to install the SCION Workbench in your project and start with a basic introduction to the SCION Workbench. -#### Workbench Demo Applications +#### Workbench Sample Applications -- [**SCION Workbench Testing App**][link-testing-app]\ - Visit our technical testing application to explore the workbench and experiment with its features. +- [**Playground Application**][link-playground-app]\ + Visit our playground application to explore the workbench and experiment with its features. -- [**SCION Workbench Getting Started App**][link-getting-started-app]\ +- [**Getting Started Application**][link-getting-started-app]\ Open the application developed in the [Getting Started][link-getting-started] guide. #### Documentation @@ -72,7 +72,7 @@ SCION Workbench enables the creation of Angular web applications that require a [link-getting-started]: /docs/site/getting-started.md [link-howto]: /docs/site/howto/how-to.md [link-demo-app]: https://schweizerischebundesbahnen.github.io/scion-workbench-demo/#/(view.24:person/64//view.22:person/32//view.5:person/79//view.3:person/15//view.2:person/38//view.1:person/66//activity:person-list)?viewgrid=eyJpZCI6MSwic2FzaDEiOlsidmlld3BhcnQuMSIsInZpZXcuMSIsInZpZXcuMiIsInZpZXcuMSJdLCJzYXNoMiI6eyJpZCI6Miwic2FzaDEiOlsidmlld3BhcnQuMiIsInZpZXcuMyIsInZpZXcuMyJdLCJzYXNoMiI6eyJpZCI6Mywic2FzaDEiOlsidmlld3BhcnQuNCIsInZpZXcuMjQiLCJ2aWV3LjI0Il0sInNhc2gyIjpbInZpZXdwYXJ0LjMiLCJ2aWV3LjIyIiwidmlldy41Iiwidmlldy4yMiJdLCJzcGxpdHRlciI6MC41MTk0Mzg0NDQ5MjQ0MDY2LCJoc3BsaXQiOmZhbHNlfSwic3BsaXR0ZXIiOjAuNTU5NDI0MzI2ODMzNzk3NSwiaHNwbGl0Ijp0cnVlfSwic3BsaXR0ZXIiOjAuMzIyNjI3NzM3MjI2Mjc3MywiaHNwbGl0IjpmYWxzZX0%3D -[link-testing-app]: https://scion-workbench-testing-app.vercel.app +[link-playground-app]: https://scion-workbench-testing-app.vercel.app [link-getting-started-app]: https://scion-workbench-getting-started.vercel.app [link-features]: /docs/site/features.md [link-announcements]: /docs/site/announcements.md diff --git a/apps/workbench-client-testing-app/src/app/app.component.html b/apps/workbench-client-testing-app/src/app/app.component.html index 687ba960f..1fb07fe1d 100644 --- a/apps/workbench-client-testing-app/src/app/app.component.html +++ b/apps/workbench-client-testing-app/src/app/app.component.html @@ -1,6 +1,6 @@ - +
has-focus diff --git a/apps/workbench-client-testing-app/src/app/css-class/css-class.component.html b/apps/workbench-client-testing-app/src/app/css-class/css-class.component.html new file mode 100644 index 000000000..1579b5c12 --- /dev/null +++ b/apps/workbench-client-testing-app/src/app/css-class/css-class.component.html @@ -0,0 +1 @@ + diff --git a/apps/workbench-client-testing-app/src/app/css-class/css-class.component.scss b/apps/workbench-client-testing-app/src/app/css-class/css-class.component.scss new file mode 100644 index 000000000..85980ba98 --- /dev/null +++ b/apps/workbench-client-testing-app/src/app/css-class/css-class.component.scss @@ -0,0 +1,10 @@ +@use '@scion/components.internal/design' as sci-design; + +:host { + display: inline-grid; + + > input { + @include sci-design.style-input-field(); + min-width: 0; + } +} diff --git a/apps/workbench-client-testing-app/src/app/css-class/css-class.component.ts b/apps/workbench-client-testing-app/src/app/css-class/css-class.component.ts new file mode 100644 index 000000000..2156983d7 --- /dev/null +++ b/apps/workbench-client-testing-app/src/app/css-class/css-class.component.ts @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2018-2024 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, forwardRef} from '@angular/core'; +import {ControlValueAccessor, NG_VALUE_ACCESSOR, NonNullableFormBuilder, ReactiveFormsModule} from '@angular/forms'; +import {noop} from 'rxjs'; +import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; +import {Arrays} from '@scion/toolkit/util'; + +@Component({ + selector: 'app-css-class', + templateUrl: './css-class.component.html', + styleUrls: ['./css-class.component.scss'], + standalone: true, + imports: [ + ReactiveFormsModule, + ], + providers: [ + {provide: NG_VALUE_ACCESSOR, multi: true, useExisting: forwardRef(() => CssClassComponent)}, + ], +}) +export class CssClassComponent implements ControlValueAccessor { + + private _cvaChangeFn: (cssClasses: string | string[] | undefined) => void = noop; + private _cvaTouchedFn: () => void = noop; + + protected formControl = this._formBuilder.control(''); + + constructor(private _formBuilder: NonNullableFormBuilder) { + this.formControl.valueChanges + .pipe(takeUntilDestroyed()) + .subscribe(() => { + this._cvaChangeFn(this.parse(this.formControl.value)); + this._cvaTouchedFn(); + }); + } + + private parse(stringified: string): string[] | string | undefined { + const cssClasses = stringified.split(/\s+/).filter(Boolean); + switch (cssClasses.length) { + case 0: + return undefined; + case 1: + return cssClasses[0]; + default: + return cssClasses; + } + } + + private stringify(cssClasses: string | string[] | undefined | null): string { + return Arrays.coerce(cssClasses).join(' '); + } + + /** + * Method implemented as part of `ControlValueAccessor` to work with Angular forms API + * @docs-private + */ + public writeValue(cssClasses: string | string[] | undefined | null): void { + this.formControl.setValue(this.stringify(cssClasses), {emitEvent: false}); + } + + /** + * Method implemented as part of `ControlValueAccessor` to work with Angular forms API + * @docs-private + */ + public registerOnChange(fn: any): void { + this._cvaChangeFn = fn; + } + + /** + * Method implemented as part of `ControlValueAccessor` to work with Angular forms API + * @docs-private + */ + public registerOnTouched(fn: any): void { + this._cvaTouchedFn = fn; + } +} diff --git a/apps/workbench-client-testing-app/src/app/dialog-opener-page/dialog-opener-page.component.html b/apps/workbench-client-testing-app/src/app/dialog-opener-page/dialog-opener-page.component.html index b673cc6f9..d3333418e 100644 --- a/apps/workbench-client-testing-app/src/app/dialog-opener-page/dialog-opener-page.component.html +++ b/apps/workbench-client-testing-app/src/app/dialog-opener-page/dialog-opener-page.component.html @@ -27,7 +27,7 @@ - +
diff --git a/apps/workbench-client-testing-app/src/app/dialog-opener-page/dialog-opener-page.component.ts b/apps/workbench-client-testing-app/src/app/dialog-opener-page/dialog-opener-page.component.ts index c07f99fae..b8d310759 100644 --- a/apps/workbench-client-testing-app/src/app/dialog-opener-page/dialog-opener-page.component.ts +++ b/apps/workbench-client-testing-app/src/app/dialog-opener-page/dialog-opener-page.component.ts @@ -10,13 +10,14 @@ import {Component} from '@angular/core'; import {FormGroup, NonNullableFormBuilder, ReactiveFormsModule, Validators} from '@angular/forms'; -import {WorkbenchDialogService, WorkbenchView} from '@scion/workbench-client'; +import {WorkbenchDialogService, WorkbenchView, ViewId} from '@scion/workbench-client'; import {stringifyError} from '../common/stringify-error.util'; import {SciFormFieldComponent} from '@scion/components.internal/form-field'; import {KeyValueEntry, SciKeyValueFieldComponent} from '@scion/components.internal/key-value-field'; import {SciCheckboxComponent} from '@scion/components.internal/checkbox'; import {startWith} from 'rxjs/operators'; import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; +import {CssClassComponent} from '../css-class/css-class.component'; @Component({ selector: 'app-dialog-opener-page', @@ -28,6 +29,7 @@ import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; SciFormFieldComponent, SciKeyValueFieldComponent, SciCheckboxComponent, + CssClassComponent, ], }) export default class DialogOpenerPageComponent { @@ -46,9 +48,9 @@ export default class DialogOpenerPageComponent { options: this._formBuilder.group({ params: this._formBuilder.array>([]), modality: this._formBuilder.control<'application' | 'view' | ''>(''), - contextualViewId: this._formBuilder.control(''), + contextualViewId: this._formBuilder.control(''), animate: this._formBuilder.control(undefined), - cssClass: this._formBuilder.control(''), + cssClass: this._formBuilder.control(undefined), }), }); @@ -76,7 +78,7 @@ export default class DialogOpenerPageComponent { context: { viewId: this.form.controls.options.controls.contextualViewId.value || undefined, }, - cssClass: this.form.controls.options.controls.cssClass.value.split(/\s+/).filter(Boolean), + cssClass: this.form.controls.options.controls.cssClass.value, }) .then(result => this.returnValue = result) .catch(error => this.dialogError = stringifyError(error) || 'Dialog was closed with an error'); diff --git a/apps/workbench-client-testing-app/src/app/message-box-opener-page/message-box-opener-page.component.html b/apps/workbench-client-testing-app/src/app/message-box-opener-page/message-box-opener-page.component.html index 1eda144db..7c72c3ca4 100644 --- a/apps/workbench-client-testing-app/src/app/message-box-opener-page/message-box-opener-page.component.html +++ b/apps/workbench-client-testing-app/src/app/message-box-opener-page/message-box-opener-page.component.html @@ -44,7 +44,7 @@ - + diff --git a/apps/workbench-client-testing-app/src/app/message-box-opener-page/message-box-opener-page.component.ts b/apps/workbench-client-testing-app/src/app/message-box-opener-page/message-box-opener-page.component.ts index 36dbd6f54..cfb84019c 100644 --- a/apps/workbench-client-testing-app/src/app/message-box-opener-page/message-box-opener-page.component.ts +++ b/apps/workbench-client-testing-app/src/app/message-box-opener-page/message-box-opener-page.component.ts @@ -17,6 +17,7 @@ import {SciFormFieldComponent} from '@scion/components.internal/form-field'; import {NgIf} from '@angular/common'; import {stringifyError} from '../common/stringify-error.util'; import {SciCheckboxComponent} from '@scion/components.internal/checkbox'; +import {CssClassComponent} from '../css-class/css-class.component'; @Component({ selector: 'app-message-box-opener-page', @@ -29,6 +30,7 @@ import {SciCheckboxComponent} from '@scion/components.internal/checkbox'; SciFormFieldComponent, SciKeyValueFieldComponent, SciCheckboxComponent, + CssClassComponent, ], }) export default class MessageBoxOpenerPageComponent { @@ -42,7 +44,7 @@ export default class MessageBoxOpenerPageComponent { severity: this._formBuilder.control<'info' | 'warn' | 'error' | ''>(''), modality: this._formBuilder.control<'application' | 'view' | ''>(''), contentSelectable: this._formBuilder.control(true), - cssClass: this._formBuilder.control(''), + cssClass: this._formBuilder.control(undefined), viewContext: this._formBuilder.control(true), }); @@ -76,7 +78,7 @@ export default class MessageBoxOpenerPageComponent { severity: this.form.controls.severity.value || undefined, modality: this.form.controls.modality.value || undefined, contentSelectable: this.form.controls.contentSelectable.value || undefined, - cssClass: this.form.controls.cssClass.value.split(/\s+/).filter(Boolean), + cssClass: this.form.controls.cssClass.value, }, qualifier ?? undefined) .then(closeAction => this.closeAction = closeAction) .catch(error => this.openError = stringifyError(error)); diff --git a/apps/workbench-client-testing-app/src/app/notification-opener-page/notification-opener-page.component.html b/apps/workbench-client-testing-app/src/app/notification-opener-page/notification-opener-page.component.html index bddf49792..c423c3df6 100644 --- a/apps/workbench-client-testing-app/src/app/notification-opener-page/notification-opener-page.component.html +++ b/apps/workbench-client-testing-app/src/app/notification-opener-page/notification-opener-page.component.html @@ -43,7 +43,7 @@ - + diff --git a/apps/workbench-client-testing-app/src/app/notification-opener-page/notification-opener-page.component.ts b/apps/workbench-client-testing-app/src/app/notification-opener-page/notification-opener-page.component.ts index e451c5bbd..8fede1d1d 100644 --- a/apps/workbench-client-testing-app/src/app/notification-opener-page/notification-opener-page.component.ts +++ b/apps/workbench-client-testing-app/src/app/notification-opener-page/notification-opener-page.component.ts @@ -15,6 +15,7 @@ import {NgIf} from '@angular/common'; 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 {CssClassComponent} from '../css-class/css-class.component'; @Component({ selector: 'app-notification-opener-page', @@ -26,6 +27,7 @@ import {SciFormFieldComponent} from '@scion/components.internal/form-field'; ReactiveFormsModule, SciFormFieldComponent, SciKeyValueFieldComponent, + CssClassComponent, ], }) export default class NotificationOpenerPageComponent { @@ -38,7 +40,7 @@ export default class NotificationOpenerPageComponent { severity: this._formBuilder.control<'info' | 'warn' | 'error' | ''>(''), duration: this._formBuilder.control<'short' | 'medium' | 'long' | 'infinite' | number | ''>(''), group: this._formBuilder.control(''), - cssClass: this._formBuilder.control(''), + cssClass: this._formBuilder.control(undefined), }); public notificationOpenError: string | undefined; @@ -61,7 +63,7 @@ export default class NotificationOpenerPageComponent { severity: this.form.controls.severity.value || undefined, duration: this.parseDurationFromUI(), group: this.form.controls.group.value || undefined, - cssClass: this.form.controls.cssClass.value.split(/\s+/).filter(Boolean), + cssClass: this.form.controls.cssClass.value, }, qualifier ?? undefined) .catch(error => this.notificationOpenError = stringifyError(error) || 'Workbench Notification could not be opened'); } diff --git a/apps/workbench-client-testing-app/src/app/popup-opener-page/popup-opener-page.component.html b/apps/workbench-client-testing-app/src/app/popup-opener-page/popup-opener-page.component.html index afbab410c..a59132970 100644 --- a/apps/workbench-client-testing-app/src/app/popup-opener-page/popup-opener-page.component.html +++ b/apps/workbench-client-testing-app/src/app/popup-opener-page/popup-opener-page.component.html @@ -23,7 +23,7 @@ - + diff --git a/apps/workbench-client-testing-app/src/app/popup-opener-page/popup-opener-page.component.ts b/apps/workbench-client-testing-app/src/app/popup-opener-page/popup-opener-page.component.ts index 331a95818..1d4a57639 100644 --- a/apps/workbench-client-testing-app/src/app/popup-opener-page/popup-opener-page.component.ts +++ b/apps/workbench-client-testing-app/src/app/popup-opener-page/popup-opener-page.component.ts @@ -10,7 +10,7 @@ import {Component, ElementRef, ViewChild} from '@angular/core'; import {FormGroup, NonNullableFormBuilder, ReactiveFormsModule, Validators} from '@angular/forms'; -import {CloseStrategy, PopupOrigin, WorkbenchPopupService, WorkbenchView} from '@scion/workbench-client'; +import {CloseStrategy, PopupOrigin, ViewId, WorkbenchPopupService, WorkbenchView} from '@scion/workbench-client'; import {Observable} from 'rxjs'; import {map, startWith} from 'rxjs/operators'; import {undefinedIfEmpty} from '../common/undefined-if-empty.util'; @@ -22,6 +22,7 @@ import {KeyValueEntry, SciKeyValueFieldComponent} from '@scion/components.intern import {SciCheckboxComponent} from '@scion/components.internal/checkbox'; import {SciAccordionComponent, SciAccordionItemDirective} from '@scion/components.internal/accordion'; import {parseTypedString} from '../common/parse-typed-value.util'; +import {CssClassComponent} from '../css-class/css-class.component'; @Component({ selector: 'app-popup-opener-page', @@ -37,6 +38,7 @@ import {parseTypedString} from '../common/parse-typed-value.util'; SciAccordionItemDirective, SciCheckboxComponent, PopupPositionLabelPipe, + CssClassComponent, ], }) export default class PopupOpenerPageComponent { @@ -62,9 +64,9 @@ export default class PopupOpenerPageComponent { width: this._formBuilder.control(undefined), height: this._formBuilder.control(undefined), }), - contextualViewId: this._formBuilder.control(''), + contextualViewId: this._formBuilder.control(''), align: this._formBuilder.control<'east' | 'west' | 'north' | 'south' | ''>(''), - cssClass: this._formBuilder.control(''), + cssClass: this._formBuilder.control(undefined), closeStrategy: this._formBuilder.group({ onFocusLost: this._formBuilder.control(true), onEscape: this._formBuilder.control(true), @@ -99,7 +101,7 @@ export default class PopupOpenerPageComponent { onFocusLost: this.form.controls.closeStrategy.controls.onFocusLost.value ?? undefined, onEscape: this.form.controls.closeStrategy.controls.onEscape.value ?? undefined, }), - cssClass: this.form.controls.cssClass.value.split(/\s+/).filter(Boolean), + cssClass: this.form.controls.cssClass.value, context: { viewId: parseTypedString(this.form.controls.contextualViewId.value || undefined), }, diff --git a/apps/workbench-client-testing-app/src/app/register-workbench-capability-page/register-workbench-capability-page.component.html b/apps/workbench-client-testing-app/src/app/register-workbench-capability-page/register-workbench-capability-page.component.html index c46d17b03..cf1d28cf0 100644 --- a/apps/workbench-client-testing-app/src/app/register-workbench-capability-page/register-workbench-capability-page.component.html +++ b/apps/workbench-client-testing-app/src/app/register-workbench-capability-page/register-workbench-capability-page.component.html @@ -33,131 +33,131 @@ @if (form.controls.type.value === WorkbenchCapabilities.View) { - + - + - + - + - + - + - + } @if (form.controls.type.value === WorkbenchCapabilities.Popup) { - + - + - + - + - + - + - + - + - + - + } @if (form.controls.type.value === WorkbenchCapabilities.Dialog) { - + - + - + - + - + - + - + - + - + - + - + - + - + - + } diff --git a/apps/workbench-client-testing-app/src/app/register-workbench-capability-page/register-workbench-capability-page.component.ts b/apps/workbench-client-testing-app/src/app/register-workbench-capability-page/register-workbench-capability-page.component.ts index 19ee03407..64d578b9a 100644 --- a/apps/workbench-client-testing-app/src/app/register-workbench-capability-page/register-workbench-capability-page.component.ts +++ b/apps/workbench-client-testing-app/src/app/register-workbench-capability-page/register-workbench-capability-page.component.ts @@ -21,6 +21,7 @@ import {stringifyError} from '../common/stringify-error.util'; import {SciFormFieldComponent} from '@scion/components.internal/form-field'; import {SciCheckboxComponent} from '@scion/components.internal/checkbox'; import {parseTypedString} from '../common/parse-typed-value.util'; +import {CssClassComponent} from '../css-class/css-class.component'; /** * Allows registering workbench capabilities. @@ -37,6 +38,7 @@ import {parseTypedString} from '../common/parse-typed-value.util'; SciKeyValueFieldComponent, SciCheckboxComponent, SciViewportComponent, + CssClassComponent, ], }) export default class RegisterWorkbenchCapabilityPageComponent { @@ -54,7 +56,7 @@ export default class RegisterWorkbenchCapabilityPageComponent { heading: this._formBuilder.control(''), closable: this._formBuilder.control(null), showSplash: this._formBuilder.control(null), - cssClass: this._formBuilder.control(''), + cssClass: this._formBuilder.control(undefined), pinToStartPage: this._formBuilder.control(false), }), popupProperties: this._formBuilder.group({ @@ -69,7 +71,7 @@ export default class RegisterWorkbenchCapabilityPageComponent { }), showSplash: this._formBuilder.control(null), pinToStartPage: this._formBuilder.control(false), - cssClass: this._formBuilder.control(''), + cssClass: this._formBuilder.control(undefined), }), dialogProperties: this._formBuilder.group({ path: this._formBuilder.control(''), @@ -87,7 +89,7 @@ export default class RegisterWorkbenchCapabilityPageComponent { padding: this._formBuilder.control(null), showSplash: this._formBuilder.control(null), pinToStartPage: this._formBuilder.control(false), - cssClass: this._formBuilder.control(''), + cssClass: this._formBuilder.control(undefined), }), }); @@ -111,7 +113,7 @@ export default class RegisterWorkbenchCapabilityPageComponent { case WorkbenchCapabilities.Dialog: return this.readDialogCapabilityFromUI(); default: - throw Error('[IllegalArgumentError] Capability expected to be a workbench capability, but was not.'); + throw Error('Capability expected to be a workbench capability, but was not.'); } })(); @@ -144,7 +146,7 @@ export default class RegisterWorkbenchCapabilityPageComponent { path: parseTypedString(this.form.controls.viewProperties.controls.path.value), // allow `undefined` to test capability validation title: this.form.controls.viewProperties.controls.title.value || undefined, heading: this.form.controls.viewProperties.controls.heading.value || undefined, - cssClass: this.form.controls.viewProperties.controls.cssClass.value.split(/\s+/).filter(Boolean), + cssClass: this.form.controls.viewProperties.controls.cssClass.value, closable: this.form.controls.viewProperties.controls.closable.value ?? undefined, showSplash: this.form.controls.viewProperties.controls.showSplash.value ?? undefined, pinToStartPage: this.form.controls.viewProperties.controls.pinToStartPage.value, @@ -175,7 +177,7 @@ export default class RegisterWorkbenchCapabilityPageComponent { }), showSplash: this.form.controls.popupProperties.controls.showSplash.value ?? undefined, pinToStartPage: this.form.controls.popupProperties.controls.pinToStartPage.value, - cssClass: this.form.controls.popupProperties.controls.cssClass.value.split(/\s+/).filter(Boolean), + cssClass: this.form.controls.popupProperties.controls.cssClass.value, }, }; } @@ -207,7 +209,7 @@ export default class RegisterWorkbenchCapabilityPageComponent { padding: this.form.controls.dialogProperties.controls.padding.value ?? undefined, showSplash: this.form.controls.dialogProperties.controls.showSplash.value ?? undefined, pinToStartPage: this.form.controls.dialogProperties.controls.pinToStartPage.value, - cssClass: this.form.controls.dialogProperties.controls.cssClass.value.split(/\s+/).filter(Boolean), + cssClass: this.form.controls.dialogProperties.controls.cssClass.value, }, }; } diff --git a/apps/workbench-client-testing-app/src/app/router-page/router-page.component.html b/apps/workbench-client-testing-app/src/app/router-page/router-page.component.html index e9562eb6d..529ad4fa1 100644 --- a/apps/workbench-client-testing-app/src/app/router-page/router-page.component.html +++ b/apps/workbench-client-testing-app/src/app/router-page/router-page.component.html @@ -39,7 +39,7 @@ - + diff --git a/apps/workbench-client-testing-app/src/app/router-page/router-page.component.ts b/apps/workbench-client-testing-app/src/app/router-page/router-page.component.ts index 33d5b7c0f..d4af729f8 100644 --- a/apps/workbench-client-testing-app/src/app/router-page/router-page.component.ts +++ b/apps/workbench-client-testing-app/src/app/router-page/router-page.component.ts @@ -17,6 +17,7 @@ import {NgIf} from '@angular/common'; import {SciCheckboxComponent} from '@scion/components.internal/checkbox'; import {SciFormFieldComponent} from '@scion/components.internal/form-field'; import {parseTypedObject} from '../common/parse-typed-value.util'; +import {CssClassComponent} from '../css-class/css-class.component'; @Component({ selector: 'app-router-page', @@ -29,6 +30,7 @@ import {parseTypedObject} from '../common/parse-typed-value.util'; SciFormFieldComponent, SciKeyValueFieldComponent, SciCheckboxComponent, + CssClassComponent, ], }) export default class RouterPageComponent { @@ -40,7 +42,7 @@ export default class RouterPageComponent { insertionIndex: this._formBuilder.control(''), activate: this._formBuilder.control(undefined), close: this._formBuilder.control(undefined), - cssClass: this._formBuilder.control(undefined), + cssClass: this._formBuilder.control(undefined), }); public navigateError: string | undefined; @@ -62,7 +64,7 @@ export default class RouterPageComponent { target: this.form.controls.target.value || undefined, blankInsertionIndex: coerceInsertionIndex(this.form.controls.insertionIndex.value), params: params || undefined, - cssClass: this.form.controls.cssClass.value?.split(/\s+/).filter(Boolean), + cssClass: this.form.controls.cssClass.value, }; await this._router.navigate(qualifier, extras) .then(success => success ? Promise.resolve() : Promise.reject('Navigation failed')) diff --git a/apps/workbench-client-testing-app/src/app/test-pages/angular-zone-test-page/angular-zone-test-page.component.ts b/apps/workbench-client-testing-app/src/app/test-pages/angular-zone-test-page/angular-zone-test-page.component.ts index a5336a784..0ab3bae84 100644 --- a/apps/workbench-client-testing-app/src/app/test-pages/angular-zone-test-page/angular-zone-test-page.component.ts +++ b/apps/workbench-client-testing-app/src/app/test-pages/angular-zone-test-page/angular-zone-test-page.component.ts @@ -45,7 +45,7 @@ export default class AngularZoneTestPageComponent { } private async testWorkbenchViewCapability(model: TestCaseModel): Promise { - const workbenchViewTestee = this._zone.runOutsideAngular(() => new ɵWorkbenchView('VIEW_ID')); + const workbenchViewTestee = this._zone.runOutsideAngular(() => new ɵWorkbenchView('view.999')); // Register two view capabilities const viewCapabilityId1 = await Beans.get(ManifestService).registerCapability({ @@ -79,7 +79,7 @@ export default class AngularZoneTestPageComponent { } private async testWorkbenchViewParams(model: TestCaseModel): Promise { - const workbenchViewTestee = this._zone.runOutsideAngular(() => new ɵWorkbenchView('VIEW_ID')); + const workbenchViewTestee = this._zone.runOutsideAngular(() => new ɵWorkbenchView('view.999')); // Subscribe to params workbenchViewTestee.params$ @@ -97,7 +97,7 @@ export default class AngularZoneTestPageComponent { } private async testWorkbenchViewActive(model: TestCaseModel): Promise { - const workbenchViewTestee = this._zone.runOutsideAngular(() => new ɵWorkbenchView('VIEW_ID')); + const workbenchViewTestee = this._zone.runOutsideAngular(() => new ɵWorkbenchView('view.999')); // Subscribe to active state workbenchViewTestee.active$ diff --git a/apps/workbench-client-testing-app/src/app/test-pages/bulk-navigation-test-page/bulk-navigation-test-page.component.html b/apps/workbench-client-testing-app/src/app/test-pages/bulk-navigation-test-page/bulk-navigation-test-page.component.html index 2958909b1..dac957f23 100644 --- a/apps/workbench-client-testing-app/src/app/test-pages/bulk-navigation-test-page/bulk-navigation-test-page.component.html +++ b/apps/workbench-client-testing-app/src/app/test-pages/bulk-navigation-test-page/bulk-navigation-test-page.component.html @@ -3,8 +3,8 @@ - - + + diff --git a/apps/workbench-client-testing-app/src/app/test-pages/bulk-navigation-test-page/bulk-navigation-test-page.component.ts b/apps/workbench-client-testing-app/src/app/test-pages/bulk-navigation-test-page/bulk-navigation-test-page.component.ts index 9f66f70ea..7a9e3c375 100644 --- a/apps/workbench-client-testing-app/src/app/test-pages/bulk-navigation-test-page/bulk-navigation-test-page.component.ts +++ b/apps/workbench-client-testing-app/src/app/test-pages/bulk-navigation-test-page/bulk-navigation-test-page.component.ts @@ -13,6 +13,7 @@ import {WorkbenchRouter} from '@scion/workbench-client'; import {NonNullableFormBuilder, ReactiveFormsModule, Validators} from '@angular/forms'; import {APP_IDENTITY} from '@scion/microfrontend-platform'; import {SciFormFieldComponent} from '@scion/components.internal/form-field'; +import {CssClassComponent} from '../../css-class/css-class.component'; @Component({ selector: 'app-bulk-navigation-test-page', @@ -22,13 +23,14 @@ import {SciFormFieldComponent} from '@scion/components.internal/form-field'; imports: [ SciFormFieldComponent, ReactiveFormsModule, + CssClassComponent, ], }) export default class BulkNavigationTestPageComponent { public form = this._formBuilder.group({ viewCount: this._formBuilder.control(1, Validators.required), - cssClass: this._formBuilder.control('', Validators.required), + cssClass: this._formBuilder.control(undefined, Validators.required), }); constructor(private _formBuilder: NonNullableFormBuilder, diff --git a/apps/workbench-getting-started-app/src/app/app.config.ts b/apps/workbench-getting-started-app/src/app/app.config.ts index 649785ebe..9701d1b37 100644 --- a/apps/workbench-getting-started-app/src/app/app.config.ts +++ b/apps/workbench-getting-started-app/src/app/app.config.ts @@ -23,11 +23,12 @@ export const appConfig: ApplicationConfig = { layout: (factory: WorkbenchLayoutFactory) => factory .addPart(MAIN_AREA) .addPart('left', {relativeTo: MAIN_AREA, align: 'left', ratio: .25}) - .addView('todos', {partId: 'left', activateView: true}), + .addView('todos', {partId: 'left', activateView: true}) + .navigateView('todos', ['todos']), }), provideRouter([ {path: '', loadComponent: () => import('./welcome/welcome.component')}, - {path: '', outlet: 'todos', loadComponent: () => import('./todos/todos.component')}, + {path: 'todos', loadComponent: () => import('./todos/todos.component')}, {path: 'todos/:id', loadComponent: () => import('./todo/todo.component')}, ], withHashLocation()), provideAnimations(), diff --git a/apps/workbench-getting-started-app/src/app/todo.service.ts b/apps/workbench-getting-started-app/src/app/todo.service.ts index aac5b15f9..ec2a50ba1 100644 --- a/apps/workbench-getting-started-app/src/app/todo.service.ts +++ b/apps/workbench-getting-started-app/src/app/todo.service.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2018-2023 Swiss Federal Railways + * Copyright (c) 2018-2024 Swiss Federal Railways * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 diff --git a/apps/workbench-getting-started-app/src/app/todos/todos.component.html b/apps/workbench-getting-started-app/src/app/todos/todos.component.html index c03e41d73..41042bee4 100644 --- a/apps/workbench-getting-started-app/src/app/todos/todos.component.html +++ b/apps/workbench-getting-started-app/src/app/todos/todos.component.html @@ -1,5 +1,5 @@
  1. - {{todo.task}} + {{ todo.task }}
diff --git a/apps/workbench-testing-app/src/app/app.component.html b/apps/workbench-testing-app/src/app/app.component.html index 1cfc844c5..4367cd2ed 100644 --- a/apps/workbench-testing-app/src/app/app.component.html +++ b/apps/workbench-testing-app/src/app/app.component.html @@ -1,4 +1,4 @@
- +
diff --git a/apps/workbench-testing-app/src/app/app.routes.ts b/apps/workbench-testing-app/src/app/app.routes.ts index d79ad07db..773641cb7 100644 --- a/apps/workbench-testing-app/src/app/app.routes.ts +++ b/apps/workbench-testing-app/src/app/app.routes.ts @@ -10,16 +10,17 @@ import {Routes} from '@angular/router'; import {WorkbenchComponent} from './workbench/workbench.component'; -import {WorkbenchRouteData} from '@scion/workbench'; import {topLevelTestPageRoutes} from './test-pages/routes'; +import {canMatchWorkbenchView, WorkbenchRouteData} from '@scion/workbench'; export const routes: Routes = [ { path: '', component: WorkbenchComponent, + canMatch: [canMatchWorkbenchView(false)], children: [ { - path: '', // default workbench page + path: '', loadComponent: () => import('./start-page/start-page.component'), }, ], @@ -28,6 +29,30 @@ export const routes: Routes = [ path: 'workbench-page', redirectTo: '', }, + { + path: '', + canMatch: [canMatchWorkbenchView('test-router')], + loadComponent: () => import('./router-page/router-page.component'), + data: { + [WorkbenchRouteData.title]: 'Workbench Router', + [WorkbenchRouteData.heading]: 'Workbench E2E Testpage', + [WorkbenchRouteData.cssClass]: 'e2e-test-router', + path: '', + navigationHint: 'test-router', + }, + }, + { + path: '', + canMatch: [canMatchWorkbenchView('test-view')], + loadComponent: () => import('./view-page/view-page.component'), + data: { + [WorkbenchRouteData.title]: 'Workbench View', + [WorkbenchRouteData.heading]: 'Workbench E2E Testpage', + [WorkbenchRouteData.cssClass]: 'e2e-test-view', + path: '', + navigationHint: 'test-view', + }, + }, { path: 'start-page', loadComponent: () => import('./start-page/start-page.component'), @@ -36,17 +61,38 @@ export const routes: Routes = [ { path: 'test-router', loadComponent: () => import('./router-page/router-page.component'), - data: {[WorkbenchRouteData.title]: 'Workbench Router', [WorkbenchRouteData.heading]: 'Workbench E2E Testpage', [WorkbenchRouteData.cssClass]: 'e2e-test-router', pinToStartPage: true}, + data: { + [WorkbenchRouteData.title]: 'Workbench Router', + [WorkbenchRouteData.heading]: 'Workbench E2E Testpage', + [WorkbenchRouteData.cssClass]: 'e2e-test-router', + pinToStartPage: true, + path: 'test-router', + navigationHint: '', + }, }, { path: 'test-view', + canMatch: [canMatchWorkbenchView('test-view')], loadComponent: () => import('./view-page/view-page.component'), - data: {[WorkbenchRouteData.title]: 'Workbench View', [WorkbenchRouteData.heading]: 'Workbench E2E Testpage', [WorkbenchRouteData.cssClass]: 'e2e-test-view', pinToStartPage: true}, + data: { + [WorkbenchRouteData.title]: 'Workbench View', + [WorkbenchRouteData.heading]: 'Workbench E2E Testpage', + [WorkbenchRouteData.cssClass]: 'e2e-test-view', + path: 'test-view', + navigationHint: 'test-view', + }, }, { - path: 'test-perspective', - loadComponent: () => import('./perspective-page/perspective-page.component'), - data: {[WorkbenchRouteData.title]: 'Workbench Perspective', [WorkbenchRouteData.heading]: 'Workbench E2E Testpage', [WorkbenchRouteData.cssClass]: 'e2e-test-perspective', pinToStartPage: true}, + path: 'test-view', + loadComponent: () => import('./view-page/view-page.component'), + data: { + [WorkbenchRouteData.title]: 'Workbench View', + [WorkbenchRouteData.heading]: 'Workbench E2E Testpage', + [WorkbenchRouteData.cssClass]: 'e2e-test-view', + pinToStartPage: true, + path: 'test-view', + navigationHint: '', + }, }, { path: 'test-layout', diff --git a/apps/workbench-testing-app/src/app/css-class/css-class.component.html b/apps/workbench-testing-app/src/app/css-class/css-class.component.html new file mode 100644 index 000000000..1579b5c12 --- /dev/null +++ b/apps/workbench-testing-app/src/app/css-class/css-class.component.html @@ -0,0 +1 @@ + diff --git a/apps/workbench-testing-app/src/app/css-class/css-class.component.scss b/apps/workbench-testing-app/src/app/css-class/css-class.component.scss new file mode 100644 index 000000000..85980ba98 --- /dev/null +++ b/apps/workbench-testing-app/src/app/css-class/css-class.component.scss @@ -0,0 +1,10 @@ +@use '@scion/components.internal/design' as sci-design; + +:host { + display: inline-grid; + + > input { + @include sci-design.style-input-field(); + min-width: 0; + } +} diff --git a/apps/workbench-testing-app/src/app/css-class/css-class.component.ts b/apps/workbench-testing-app/src/app/css-class/css-class.component.ts new file mode 100644 index 000000000..2156983d7 --- /dev/null +++ b/apps/workbench-testing-app/src/app/css-class/css-class.component.ts @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2018-2024 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, forwardRef} from '@angular/core'; +import {ControlValueAccessor, NG_VALUE_ACCESSOR, NonNullableFormBuilder, ReactiveFormsModule} from '@angular/forms'; +import {noop} from 'rxjs'; +import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; +import {Arrays} from '@scion/toolkit/util'; + +@Component({ + selector: 'app-css-class', + templateUrl: './css-class.component.html', + styleUrls: ['./css-class.component.scss'], + standalone: true, + imports: [ + ReactiveFormsModule, + ], + providers: [ + {provide: NG_VALUE_ACCESSOR, multi: true, useExisting: forwardRef(() => CssClassComponent)}, + ], +}) +export class CssClassComponent implements ControlValueAccessor { + + private _cvaChangeFn: (cssClasses: string | string[] | undefined) => void = noop; + private _cvaTouchedFn: () => void = noop; + + protected formControl = this._formBuilder.control(''); + + constructor(private _formBuilder: NonNullableFormBuilder) { + this.formControl.valueChanges + .pipe(takeUntilDestroyed()) + .subscribe(() => { + this._cvaChangeFn(this.parse(this.formControl.value)); + this._cvaTouchedFn(); + }); + } + + private parse(stringified: string): string[] | string | undefined { + const cssClasses = stringified.split(/\s+/).filter(Boolean); + switch (cssClasses.length) { + case 0: + return undefined; + case 1: + return cssClasses[0]; + default: + return cssClasses; + } + } + + private stringify(cssClasses: string | string[] | undefined | null): string { + return Arrays.coerce(cssClasses).join(' '); + } + + /** + * Method implemented as part of `ControlValueAccessor` to work with Angular forms API + * @docs-private + */ + public writeValue(cssClasses: string | string[] | undefined | null): void { + this.formControl.setValue(this.stringify(cssClasses), {emitEvent: false}); + } + + /** + * Method implemented as part of `ControlValueAccessor` to work with Angular forms API + * @docs-private + */ + public registerOnChange(fn: any): void { + this._cvaChangeFn = fn; + } + + /** + * Method implemented as part of `ControlValueAccessor` to work with Angular forms API + * @docs-private + */ + public registerOnTouched(fn: any): void { + this._cvaTouchedFn = fn; + } +} 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 index 6d20acd0c..5a50bbf64 100644 --- 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 @@ -33,7 +33,7 @@
- + 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 index 2cde0d9b4..36c8b819a 100644 --- 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 @@ -10,7 +10,7 @@ import {ApplicationRef, Component, Type} from '@angular/core'; import {FormGroup, NonNullableFormBuilder, ReactiveFormsModule, Validators} from '@angular/forms'; -import {WorkbenchDialogService} from '@scion/workbench'; +import {ViewId, WorkbenchDialogService} from '@scion/workbench'; import {startWith} from 'rxjs/operators'; import {NgIf} from '@angular/common'; import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; @@ -23,6 +23,7 @@ import BlankTestPageComponent from '../test-pages/blank-test-page/blank-test-pag import FocusTestPageComponent from '../test-pages/focus-test-page/focus-test-page.component'; import PopupOpenerPageComponent from '../popup-opener-page/popup-opener-page.component'; import InputFieldTestPageComponent from '../test-pages/input-field-test-page/input-field-test-page.component'; +import {CssClassComponent} from '../css-class/css-class.component'; @Component({ selector: 'app-dialog-opener-page', @@ -35,6 +36,7 @@ import InputFieldTestPageComponent from '../test-pages/input-field-test-page/inp SciFormFieldComponent, SciKeyValueFieldComponent, SciCheckboxComponent, + CssClassComponent, ], }) export default class DialogOpenerPageComponent { @@ -44,8 +46,8 @@ export default class DialogOpenerPageComponent { options: this._formBuilder.group({ inputs: this._formBuilder.array>([]), modality: this._formBuilder.control<'application' | 'view' | ''>(''), - contextualViewId: this._formBuilder.control(''), - cssClass: this._formBuilder.control(''), + contextualViewId: this._formBuilder.control(''), + cssClass: this._formBuilder.control(undefined), animate: this._formBuilder.control(undefined), }), count: this._formBuilder.control(''), @@ -79,7 +81,7 @@ export default class DialogOpenerPageComponent { 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) || []), + cssClass: [`index-${index}`].concat(this.form.controls.options.controls.cssClass.value ?? []), animate: this.form.controls.options.controls.animate.value, context: { viewId: this.form.controls.options.controls.contextualViewId.value || undefined, diff --git a/apps/workbench-testing-app/src/app/header/header.component.ts b/apps/workbench-testing-app/src/app/header/header.component.ts index 4f957410c..72f66fb1a 100644 --- a/apps/workbench-testing-app/src/app/header/header.component.ts +++ b/apps/workbench-testing-app/src/app/header/header.component.ts @@ -189,6 +189,11 @@ export class HeaderComponent { private contributeSettingsMenuItems(): MenuItem[] { return [ + new MenuItem({ + text: 'Reset forms on submit', + checked: this._settingsService.isEnabled('resetFormsOnSubmit'), + onAction: () => this._settingsService.toggle('resetFormsOnSubmit'), + }), new MenuItem({ text: 'Log Angular change detection cycles', cssClass: 'e2e-log-angular-change-detection-cycles', diff --git a/apps/workbench-testing-app/src/app/layout-page/activate-view-page/activate-view-page.component.html b/apps/workbench-testing-app/src/app/layout-page/activate-view-page/activate-view-page.component.html deleted file mode 100644 index e608d70a1..000000000 --- a/apps/workbench-testing-app/src/app/layout-page/activate-view-page/activate-view-page.component.html +++ /dev/null @@ -1,27 +0,0 @@ -
-
- - - -
- -
-
Options
- - - - -
- - -
- - - Success - - - - {{navigateError}} - diff --git a/apps/workbench-testing-app/src/app/layout-page/activate-view-page/activate-view-page.component.ts b/apps/workbench-testing-app/src/app/layout-page/activate-view-page/activate-view-page.component.ts deleted file mode 100644 index 8db2fee99..000000000 --- a/apps/workbench-testing-app/src/app/layout-page/activate-view-page/activate-view-page.component.ts +++ /dev/null @@ -1,61 +0,0 @@ -/* - * 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} from '@angular/core'; -import {NonNullableFormBuilder, ReactiveFormsModule, Validators} from '@angular/forms'; -import {WorkbenchRouter, WorkbenchService} from '@scion/workbench'; -import {AsyncPipe, NgFor, NgIf} from '@angular/common'; -import {stringifyError} from '../../common/stringify-error.util'; -import {SciCheckboxComponent} from '@scion/components.internal/checkbox'; -import {SciFormFieldComponent} from '@scion/components.internal/form-field'; - -@Component({ - selector: 'app-activate-view-page', - templateUrl: './activate-view-page.component.html', - styleUrls: ['./activate-view-page.component.scss'], - standalone: true, - imports: [ - NgIf, - NgFor, - AsyncPipe, - ReactiveFormsModule, - SciFormFieldComponent, - SciCheckboxComponent, - ], -}) -export default class ActivateViewPageComponent { - - public form = this._formBuilder.group({ - viewId: this._formBuilder.control('', Validators.required), - options: this._formBuilder.group({ - activatePart: this._formBuilder.control(undefined), - }), - }); - public navigateError: string | false | undefined; - - constructor(private _formBuilder: NonNullableFormBuilder, - private _wbRouter: WorkbenchRouter, - public workbenchService: WorkbenchService) { - } - - public onNavigate(): void { - this.navigateError = undefined; - - this._wbRouter - .ɵnavigate(layout => layout.activateView(this.form.controls.viewId.value, { - activatePart: this.form.controls.options.controls.activatePart.value, - })) - .then(() => { - this.navigateError = false; - this.form.reset(); - }) - .catch(error => this.navigateError = stringifyError(error)); - } -} diff --git a/apps/workbench-testing-app/src/app/layout-page/add-part-page/add-part-page.component.html b/apps/workbench-testing-app/src/app/layout-page/add-part-page/add-part-page.component.html deleted file mode 100644 index 3271d2bc3..000000000 --- a/apps/workbench-testing-app/src/app/layout-page/add-part-page/add-part-page.component.html +++ /dev/null @@ -1,47 +0,0 @@ -
-
- - - - - - - -
- -
-
Relative To
- - - - - - - - - - - - - - - -
- - -
- - - Success - - - - {{navigateError}} - diff --git a/apps/workbench-testing-app/src/app/layout-page/add-part-page/add-part-page.component.scss b/apps/workbench-testing-app/src/app/layout-page/add-part-page/add-part-page.component.scss deleted file mode 100644 index 05e8cc1f9..000000000 --- a/apps/workbench-testing-app/src/app/layout-page/add-part-page/add-part-page.component.scss +++ /dev/null @@ -1,42 +0,0 @@ -:host { - display: flex; - flex-direction: column; - gap: 1em; - - > form { - flex: none; - display: flex; - flex-direction: column; - gap: 1em; - - > section { - flex: none; - display: flex; - flex-direction: column; - gap: .5em; - border: 1px solid var(--sci-color-border); - border-radius: var(--sci-corner); - padding: 1em; - - > header { - margin-bottom: 1em; - font-weight: bold; - } - } - } - - > output.navigate-success { - flex: none; - display: none; - } - - > output.navigate-error { - flex: none; - 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/layout-page/add-part-page/add-part-page.component.ts b/apps/workbench-testing-app/src/app/layout-page/add-part-page/add-part-page.component.ts deleted file mode 100644 index 1eda67ac9..000000000 --- a/apps/workbench-testing-app/src/app/layout-page/add-part-page/add-part-page.component.ts +++ /dev/null @@ -1,69 +0,0 @@ -/* - * 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} from '@angular/core'; -import {NonNullableFormBuilder, ReactiveFormsModule, Validators} from '@angular/forms'; -import {WorkbenchRouter, WorkbenchService} from '@scion/workbench'; -import {AsyncPipe, NgFor, NgIf} from '@angular/common'; -import {stringifyError} from '../../common/stringify-error.util'; -import {SciCheckboxComponent} from '@scion/components.internal/checkbox'; -import {SciFormFieldComponent} from '@scion/components.internal/form-field'; - -@Component({ - selector: 'app-add-part-page', - templateUrl: './add-part-page.component.html', - styleUrls: ['./add-part-page.component.scss'], - standalone: true, - imports: [ - NgIf, - NgFor, - AsyncPipe, - ReactiveFormsModule, - SciFormFieldComponent, - SciCheckboxComponent, - ], -}) -export default class AddPartPageComponent { - - public form = this._formBuilder.group({ - partId: this._formBuilder.control('', Validators.required), - relativeTo: this._formBuilder.group({ - partId: this._formBuilder.control(undefined), - align: this._formBuilder.control<'left' | 'right' | 'top' | 'bottom' | undefined>(undefined, Validators.required), - ratio: this._formBuilder.control(undefined), - }), - activate: this._formBuilder.control(undefined), - }); - - public navigateError: string | false | undefined; - - constructor(private _formBuilder: NonNullableFormBuilder, - private _wbRouter: WorkbenchRouter, - public workbenchService: WorkbenchService) { - } - - public onNavigate(): void { - this.navigateError = undefined; - - this._wbRouter - .ɵnavigate(layout => layout.addPart(this.form.controls.partId.value!, { - relativeTo: this.form.controls.relativeTo.controls.partId.value || undefined, - align: this.form.controls.relativeTo.controls.align.value!, - ratio: this.form.controls.relativeTo.controls.ratio.value, - }, - {activate: this.form.controls.activate.value}, - )) - .then(() => { - this.navigateError = false; - this.form.reset(); - }) - .catch(error => this.navigateError = stringifyError(error)); - } -} diff --git a/apps/workbench-testing-app/src/app/layout-page/add-view-page/add-view-page.component.html b/apps/workbench-testing-app/src/app/layout-page/add-view-page/add-view-page.component.html deleted file mode 100644 index 2136a6d36..000000000 --- a/apps/workbench-testing-app/src/app/layout-page/add-view-page/add-view-page.component.html +++ /dev/null @@ -1,42 +0,0 @@ -
-
- - - -
- -
-
Options
- - - - - - - - - - - - - - - - - - - -
- - -
- - - Success - - - - {{navigateError}} - diff --git a/apps/workbench-testing-app/src/app/layout-page/add-view-page/add-view-page.component.scss b/apps/workbench-testing-app/src/app/layout-page/add-view-page/add-view-page.component.scss deleted file mode 100644 index 05e8cc1f9..000000000 --- a/apps/workbench-testing-app/src/app/layout-page/add-view-page/add-view-page.component.scss +++ /dev/null @@ -1,42 +0,0 @@ -:host { - display: flex; - flex-direction: column; - gap: 1em; - - > form { - flex: none; - display: flex; - flex-direction: column; - gap: 1em; - - > section { - flex: none; - display: flex; - flex-direction: column; - gap: .5em; - border: 1px solid var(--sci-color-border); - border-radius: var(--sci-corner); - padding: 1em; - - > header { - margin-bottom: 1em; - font-weight: bold; - } - } - } - - > output.navigate-success { - flex: none; - display: none; - } - - > output.navigate-error { - flex: none; - 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/layout-page/add-view-page/add-view-page.component.ts b/apps/workbench-testing-app/src/app/layout-page/add-view-page/add-view-page.component.ts deleted file mode 100644 index 64bc924bb..000000000 --- a/apps/workbench-testing-app/src/app/layout-page/add-view-page/add-view-page.component.ts +++ /dev/null @@ -1,67 +0,0 @@ -/* - * 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} from '@angular/core'; -import {NonNullableFormBuilder, ReactiveFormsModule, Validators} from '@angular/forms'; -import {WorkbenchRouter, WorkbenchService} from '@scion/workbench'; -import {AsyncPipe, NgFor, NgIf} from '@angular/common'; -import {stringifyError} from '../../common/stringify-error.util'; -import {SciCheckboxComponent} from '@scion/components.internal/checkbox'; -import {SciFormFieldComponent} from '@scion/components.internal/form-field'; - -@Component({ - selector: 'app-add-view-page', - templateUrl: './add-view-page.component.html', - styleUrls: ['./add-view-page.component.scss'], - standalone: true, - imports: [ - NgIf, - NgFor, - AsyncPipe, - ReactiveFormsModule, - SciFormFieldComponent, - SciCheckboxComponent, - ], -}) -export default class AddViewPageComponent { - - public form = this._formBuilder.group({ - viewId: this._formBuilder.control('', Validators.required), - options: this._formBuilder.group({ - partId: this._formBuilder.control('', Validators.required), - position: this._formBuilder.control(undefined), - activateView: this._formBuilder.control(undefined), - activatePart: this._formBuilder.control(undefined), - }), - }); - public navigateError: string | false | undefined; - - constructor(private _formBuilder: NonNullableFormBuilder, - private _wbRouter: WorkbenchRouter, - public workbenchService: WorkbenchService) { - } - - public onNavigate(): void { - this.navigateError = undefined; - - this._wbRouter - .ɵnavigate(layout => layout.addView(this.form.controls.viewId.value, { - partId: this.form.controls.options.controls.partId.value, - position: this.form.controls.options.controls.position.value ?? undefined, - activateView: this.form.controls.options.controls.activateView.value, - activatePart: this.form.controls.options.controls.activatePart.value, - })) - .then(() => { - this.navigateError = false; - this.form.reset(); - }) - .catch(error => this.navigateError = stringifyError(error)); - } -} diff --git a/apps/workbench-testing-app/src/app/layout-page/create-perspective-page/create-perspective-page.component.html b/apps/workbench-testing-app/src/app/layout-page/create-perspective-page/create-perspective-page.component.html new file mode 100644 index 000000000..b4e93f787 --- /dev/null +++ b/apps/workbench-testing-app/src/app/layout-page/create-perspective-page/create-perspective-page.component.html @@ -0,0 +1,38 @@ +
+
+ + + + + + + + + +
+ +
+
Parts
+ +
+ +
+
Views
+ +
+ +
+
View Navigations
+ +
+ + +
+ +@if (registerError === false) { + Success +} + +@if (registerError) { + {{registerError}} +} diff --git a/apps/workbench-testing-app/src/app/perspective-page/perspective-page.component.scss b/apps/workbench-testing-app/src/app/layout-page/create-perspective-page/create-perspective-page.component.scss similarity index 98% rename from apps/workbench-testing-app/src/app/perspective-page/perspective-page.component.scss rename to apps/workbench-testing-app/src/app/layout-page/create-perspective-page/create-perspective-page.component.scss index 46a42ce96..242c1f2f0 100644 --- a/apps/workbench-testing-app/src/app/perspective-page/perspective-page.component.scss +++ b/apps/workbench-testing-app/src/app/layout-page/create-perspective-page/create-perspective-page.component.scss @@ -2,7 +2,6 @@ display: flex; flex-direction: column; gap: 1em; - padding: 1em; > form { flex: none; diff --git a/apps/workbench-testing-app/src/app/layout-page/create-perspective-page/create-perspective-page.component.ts b/apps/workbench-testing-app/src/app/layout-page/create-perspective-page/create-perspective-page.component.ts new file mode 100644 index 000000000..d005fa094 --- /dev/null +++ b/apps/workbench-testing-app/src/app/layout-page/create-perspective-page/create-perspective-page.component.ts @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2018-2024 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} from '@angular/core'; +import {AddPartsComponent, PartDescriptor} from '../tables/add-parts/add-parts.component'; +import {AddViewsComponent, ViewDescriptor} from '../tables/add-views/add-views.component'; +import {NavigateViewsComponent, NavigationDescriptor} from '../tables/navigate-views/navigate-views.component'; +import {FormGroup, NonNullableFormBuilder, ReactiveFormsModule, Validators} from '@angular/forms'; +import {SettingsService} from '../../settings.service'; +import {WorkbenchLayout, WorkbenchLayoutFactory, WorkbenchLayoutFn, WorkbenchService} from '@scion/workbench'; +import {stringifyError} from '../../common/stringify-error.util'; +import {SciFormFieldComponent} from '@scion/components.internal/form-field'; +import {SciCheckboxComponent} from '@scion/components.internal/checkbox'; +import {KeyValueEntry, SciKeyValueFieldComponent} from '@scion/components.internal/key-value-field'; +import {Observable} from 'rxjs'; +import {mapArray} from '@scion/toolkit/operators'; +import {AsyncPipe} from '@angular/common'; + +@Component({ + selector: 'app-create-perspective-page', + templateUrl: './create-perspective-page.component.html', + styleUrls: ['./create-perspective-page.component.scss'], + standalone: true, + imports: [ + AddPartsComponent, + AddViewsComponent, + NavigateViewsComponent, + ReactiveFormsModule, + SciFormFieldComponent, + SciCheckboxComponent, + SciKeyValueFieldComponent, + AsyncPipe, + ], +}) +export default class CreatePerspectivePageComponent { + + protected form = this._formBuilder.group({ + id: this._formBuilder.control('', Validators.required), + transient: this._formBuilder.control(undefined), + data: this._formBuilder.array>([]), + parts: this._formBuilder.control([], Validators.required), + views: this._formBuilder.control([]), + viewNavigations: this._formBuilder.control([]), + }); + + protected registerError: string | false | undefined; + protected partProposals$: Observable; + protected viewProposals$: Observable; + + constructor(private _formBuilder: NonNullableFormBuilder, + private _settingsService: SettingsService, + private _workbenchService: WorkbenchService) { + this.partProposals$ = this.form.controls.parts.valueChanges + .pipe(mapArray(part => part.id)); + this.viewProposals$ = this.form.controls.views.valueChanges + .pipe(mapArray(view => view.id)); + } + + protected async onRegister(): Promise { + this.registerError = undefined; + try { + await this._workbenchService.registerPerspective({ + id: this.form.controls.id.value, + transient: this.form.controls.transient.value || undefined, + data: SciKeyValueFieldComponent.toDictionary(this.form.controls.data) ?? undefined, + layout: this.createLayout(), + }); + this.registerError = false; + this.resetForm(); + } + catch (error) { + this.registerError = stringifyError(error); + } + } + + private createLayout(): WorkbenchLayoutFn { + // Capture form values, since the `layout` function is evaluated independently of the form life-cycle + const [initialPart, ...parts] = this.form.controls.parts.value; + const views = this.form.controls.views.value; + const viewNavigations = this.form.controls.viewNavigations.value; + + return (factory: WorkbenchLayoutFactory): WorkbenchLayout => { + // Add initial part. + let layout = factory.addPart(initialPart.id, { + activate: initialPart.options?.activate, + }); + + // Add other parts. + for (const part of parts) { + layout = layout.addPart(part.id, { + relativeTo: part.relativeTo!.relativeTo, + align: part.relativeTo!.align!, + ratio: part.relativeTo!.ratio, + }, {activate: part.options?.activate}); + } + + // Add views. + for (const view of views) { + layout = layout.addView(view.id, { + partId: view.options.partId, + position: view.options.position, + activateView: view.options.activateView, + activatePart: view.options.activatePart, + cssClass: view.options.cssClass, + }); + } + + // Add navigations. + for (const viewNavigation of viewNavigations) { + layout = layout.navigateView(viewNavigation.id, viewNavigation.commands, { + hint: viewNavigation.extras?.hint, + state: viewNavigation.extras?.state, + cssClass: viewNavigation.extras?.cssClass, + }); + } + return layout; + }; + } + + private resetForm(): void { + if (this._settingsService.isEnabled('resetFormsOnSubmit')) { + this.form.reset(); + this.form.setControl('data', this._formBuilder.array>([])); + } + } +} diff --git a/apps/workbench-testing-app/src/app/layout-page/layout-page.component.html b/apps/workbench-testing-app/src/app/layout-page/layout-page.component.html index 8a5ace030..f7e2e58ae 100644 --- a/apps/workbench-testing-app/src/app/layout-page/layout-page.component.html +++ b/apps/workbench-testing-app/src/app/layout-page/layout-page.component.html @@ -1,17 +1,11 @@ - - + + - - - - - + + - - - diff --git a/apps/workbench-testing-app/src/app/layout-page/layout-page.component.ts b/apps/workbench-testing-app/src/app/layout-page/layout-page.component.ts index 8510cf8bf..2db924b6c 100644 --- a/apps/workbench-testing-app/src/app/layout-page/layout-page.component.ts +++ b/apps/workbench-testing-app/src/app/layout-page/layout-page.component.ts @@ -9,12 +9,10 @@ */ import {Component} from '@angular/core'; -import AddPartPageComponent from './add-part-page/add-part-page.component'; -import AddViewPageComponent from './add-view-page/add-view-page.component'; -import ActivateViewPageComponent from './activate-view-page/activate-view-page.component'; import RegisterPartActionPageComponent from './register-part-action-page/register-part-action-page.component'; -import RegisterRoutePageComponent from './register-route-page/register-route-page.component'; import {SciTabbarComponent, SciTabDirective} from '@scion/components.internal/tabbar'; +import ModifyLayoutPageComponent from './modify-layout-page/modify-layout-page.component'; +import CreatePerspectivePageComponent from './create-perspective-page/create-perspective-page.component'; @Component({ selector: 'app-layout-page', @@ -24,11 +22,9 @@ import {SciTabbarComponent, SciTabDirective} from '@scion/components.internal/ta imports: [ SciTabbarComponent, SciTabDirective, - AddPartPageComponent, - AddViewPageComponent, - ActivateViewPageComponent, + ModifyLayoutPageComponent, + CreatePerspectivePageComponent, RegisterPartActionPageComponent, - RegisterRoutePageComponent, ], }) export default class LayoutPageComponent { diff --git a/apps/workbench-testing-app/src/app/layout-page/modify-layout-page/modify-layout-page.component.html b/apps/workbench-testing-app/src/app/layout-page/modify-layout-page/modify-layout-page.component.html new file mode 100644 index 000000000..151eec0b5 --- /dev/null +++ b/apps/workbench-testing-app/src/app/layout-page/modify-layout-page/modify-layout-page.component.html @@ -0,0 +1,22 @@ +
+
+
Parts
+ +
+ +
+
Views
+ +
+ +
+
View Navigations
+ +
+ + +
+ +@if (modifyError) { + {{modifyError}} +} diff --git a/apps/workbench-testing-app/src/app/layout-page/activate-view-page/activate-view-page.component.scss b/apps/workbench-testing-app/src/app/layout-page/modify-layout-page/modify-layout-page.component.scss similarity index 87% rename from apps/workbench-testing-app/src/app/layout-page/activate-view-page/activate-view-page.component.scss rename to apps/workbench-testing-app/src/app/layout-page/modify-layout-page/modify-layout-page.component.scss index 05e8cc1f9..6a312ca72 100644 --- a/apps/workbench-testing-app/src/app/layout-page/activate-view-page/activate-view-page.component.scss +++ b/apps/workbench-testing-app/src/app/layout-page/modify-layout-page/modify-layout-page.component.scss @@ -25,12 +25,7 @@ } } - > output.navigate-success { - flex: none; - display: none; - } - - > output.navigate-error { + > output.modify-error { flex: none; border: 1px solid var(--sci-color-negative); background-color: var(--sci-color-background-negative); diff --git a/apps/workbench-testing-app/src/app/layout-page/modify-layout-page/modify-layout-page.component.ts b/apps/workbench-testing-app/src/app/layout-page/modify-layout-page/modify-layout-page.component.ts new file mode 100644 index 000000000..c630bab97 --- /dev/null +++ b/apps/workbench-testing-app/src/app/layout-page/modify-layout-page/modify-layout-page.component.ts @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2018-2024 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} from '@angular/core'; +import {AddPartsComponent, PartDescriptor} from '../tables/add-parts/add-parts.component'; +import {AddViewsComponent, ViewDescriptor} from '../tables/add-views/add-views.component'; +import {NavigateViewsComponent, NavigationDescriptor} from '../tables/navigate-views/navigate-views.component'; +import {NonNullableFormBuilder, ReactiveFormsModule} from '@angular/forms'; +import {SettingsService} from '../../settings.service'; +import {WorkbenchRouter, WorkbenchService} from '@scion/workbench'; +import {stringifyError} from '../../common/stringify-error.util'; +import {combineLatestWith, Observable} from 'rxjs'; +import {mapArray} from '@scion/toolkit/operators'; +import {map, startWith} from 'rxjs/operators'; +import {AsyncPipe} from '@angular/common'; + +@Component({ + selector: 'app-modify-layout-page', + templateUrl: './modify-layout-page.component.html', + styleUrls: ['./modify-layout-page.component.scss'], + standalone: true, + imports: [ + AddPartsComponent, + AddViewsComponent, + NavigateViewsComponent, + ReactiveFormsModule, + AsyncPipe, + ], +}) +export default class ModifyLayoutPageComponent { + + protected form = this._formBuilder.group({ + parts: this._formBuilder.control([]), + views: this._formBuilder.control([]), + viewNavigations: this._formBuilder.control([]), + }); + + protected modifyError: string | false | undefined; + protected partProposals$: Observable; + protected viewProposals$: Observable; + + constructor(workbenchService: WorkbenchService, + private _formBuilder: NonNullableFormBuilder, + private _settingsService: SettingsService, + private _workbenchRouter: WorkbenchRouter) { + this.partProposals$ = workbenchService.parts$ + .pipe( + combineLatestWith(this.form.controls.parts.valueChanges.pipe(startWith([]))), + map(([a, b]) => [...a, ...b]), + mapArray(part => part.id), + ); + this.viewProposals$ = workbenchService.views$ + .pipe( + combineLatestWith(this.form.controls.views.valueChanges.pipe(startWith([]))), + map(([a, b]) => [...a, ...b]), + mapArray(view => view.id), + ); + } + + protected async onModify(): Promise { + this.modifyError = undefined; + this.navigate() + .then(success => success ? Promise.resolve() : Promise.reject('Modification failed')) + .then(() => this.resetForm()) + .catch(error => this.modifyError = stringifyError(error)); + } + + private navigate(): Promise { + return this._workbenchRouter.ɵnavigate(layout => { + // Add parts. + for (const part of this.form.controls.parts.value) { + layout = layout.addPart(part.id, { + relativeTo: part.relativeTo!.relativeTo, + align: part.relativeTo!.align!, + ratio: part.relativeTo!.ratio!, + }, {activate: part.options?.activate}); + } + + // Add views. + for (const view of this.form.controls.views.value) { + layout = layout.addView(view.id, { + partId: view.options.partId, + position: view.options.position, + activateView: view.options.activateView, + activatePart: view.options.activatePart, + cssClass: view.options.cssClass, + }); + } + + // Add navigations. + for (const viewNavigation of this.form.controls.viewNavigations.value) { + layout = layout.navigateView(viewNavigation.id, viewNavigation.commands, { + hint: viewNavigation.extras?.hint, + state: viewNavigation.extras?.state, + cssClass: viewNavigation.extras?.cssClass, + }); + } + + return layout; + }); + } + + private resetForm(): void { + if (this._settingsService.isEnabled('resetFormsOnSubmit')) { + this.form.reset(); + } + } +} diff --git a/apps/workbench-testing-app/src/app/layout-page/register-part-action-page/register-part-action-page.component.html b/apps/workbench-testing-app/src/app/layout-page/register-part-action-page/register-part-action-page.component.html index 7aaa11c47..1bd982d0e 100644 --- a/apps/workbench-testing-app/src/app/layout-page/register-part-action-page/register-part-action-page.component.html +++ b/apps/workbench-testing-app/src/app/layout-page/register-part-action-page/register-part-action-page.component.html @@ -13,11 +13,11 @@ - + -
+
CanMatch
diff --git a/apps/workbench-testing-app/src/app/layout-page/register-part-action-page/register-part-action-page.component.ts b/apps/workbench-testing-app/src/app/layout-page/register-part-action-page/register-part-action-page.component.ts index 617d84f9f..79eae6dfb 100644 --- a/apps/workbench-testing-app/src/app/layout-page/register-part-action-page/register-part-action-page.component.ts +++ b/apps/workbench-testing-app/src/app/layout-page/register-part-action-page/register-part-action-page.component.ts @@ -17,6 +17,8 @@ import {undefinedIfEmpty} from '../../common/undefined-if-empty.util'; import {stringifyError} from '../../common/stringify-error.util'; import {SciFormFieldComponent} from '@scion/components.internal/form-field'; import {Arrays} from '@scion/toolkit/util'; +import {SettingsService} from '../../settings.service'; +import {CssClassComponent} from '../../css-class/css-class.component'; @Component({ selector: 'app-register-part-action-page', @@ -29,6 +31,7 @@ import {Arrays} from '@scion/toolkit/util'; AsyncPipe, ReactiveFormsModule, SciFormFieldComponent, + CssClassComponent, ], }) export default class RegisterPartActionPageComponent { @@ -36,7 +39,7 @@ export default class RegisterPartActionPageComponent { public form = this._formBuilder.group({ content: this._formBuilder.control('', {validators: Validators.required}), align: this._formBuilder.control<'start' | 'end' | ''>(''), - cssClass: this._formBuilder.control(''), + cssClass: this._formBuilder.control(undefined), canMatch: this._formBuilder.group({ view: this._formBuilder.control(''), part: this._formBuilder.control(''), @@ -45,7 +48,9 @@ export default class RegisterPartActionPageComponent { }); public registerError: string | false | undefined; - constructor(private _formBuilder: NonNullableFormBuilder, public workbenchService: WorkbenchService) { + constructor(private _formBuilder: NonNullableFormBuilder, + private _settingsService: SettingsService, + public workbenchService: WorkbenchService) { } public onRegister(): void { @@ -74,15 +79,21 @@ export default class RegisterPartActionPageComponent { } return true; }), - cssClass: this.form.controls.cssClass.value.split(/\s+/).filter(Boolean), + cssClass: this.form.controls.cssClass.value, }); this.registerError = false; - this.form.reset(); + this.resetForm(); } catch (error: unknown) { this.registerError = stringifyError(error); } } + + private resetForm(): void { + if (this._settingsService.isEnabled('resetFormsOnSubmit')) { + this.form.reset(); + } + } } @Component({ diff --git a/apps/workbench-testing-app/src/app/layout-page/register-route-page/register-route-page.component.html b/apps/workbench-testing-app/src/app/layout-page/register-route-page/register-route-page.component.html deleted file mode 100644 index e8fe2fc01..000000000 --- a/apps/workbench-testing-app/src/app/layout-page/register-route-page/register-route-page.component.html +++ /dev/null @@ -1,34 +0,0 @@ -
-
- - - - - - - - - - - - - - -
- -
-
Route Data
- - - - - - - - -
- - -
diff --git a/apps/workbench-testing-app/src/app/layout-page/register-route-page/register-route-page.component.scss b/apps/workbench-testing-app/src/app/layout-page/register-route-page/register-route-page.component.scss deleted file mode 100644 index 48a512dc4..000000000 --- a/apps/workbench-testing-app/src/app/layout-page/register-route-page/register-route-page.component.scss +++ /dev/null @@ -1,26 +0,0 @@ -:host { - display: grid; - padding: 1em; - - > form { - flex: none; - display: flex; - flex-direction: column; - gap: 1em; - - > section { - flex: none; - display: flex; - flex-direction: column; - gap: .5em; - border: 1px solid var(--sci-color-border); - border-radius: var(--sci-corner); - padding: 1em; - - > header { - margin-bottom: 1em; - font-weight: bold; - } - } - } -} diff --git a/apps/workbench-testing-app/src/app/layout-page/register-route-page/register-route-page.component.ts b/apps/workbench-testing-app/src/app/layout-page/register-route-page/register-route-page.component.ts deleted file mode 100644 index 51ff9dab5..000000000 --- a/apps/workbench-testing-app/src/app/layout-page/register-route-page/register-route-page.component.ts +++ /dev/null @@ -1,83 +0,0 @@ -/* - * 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, Type} from '@angular/core'; -import {NonNullableFormBuilder, ReactiveFormsModule, Validators} from '@angular/forms'; -import {WorkbenchRouteData} from '@scion/workbench'; -import {KeyValuePipe, NgFor, NgIf} from '@angular/common'; -import {DefaultExport, Router, Routes} from '@angular/router'; -import {SciFormFieldComponent} from '@scion/components.internal/form-field'; - -@Component({ - selector: 'app-register-route-page', - templateUrl: './register-route-page.component.html', - styleUrls: ['./register-route-page.component.scss'], - standalone: true, - imports: [ - NgIf, - NgFor, - ReactiveFormsModule, - KeyValuePipe, - SciFormFieldComponent, - ], -}) -export default class RegisterRoutePageComponent { - - public readonly componentRefs = new Map Promise>>>() - .set('view-page', () => import('../../view-page/view-page.component')) - .set('router-page', () => import('../../router-page/router-page.component')); - - public form = this._formBuilder.group({ - path: this._formBuilder.control(''), - component: this._formBuilder.control<'view-page' | 'router-page' | ''>('', Validators.required), - outlet: this._formBuilder.control(''), - routeData: this._formBuilder.group({ - title: this._formBuilder.control(''), - cssClass: this._formBuilder.control(''), - }), - }); - - constructor(private _formBuilder: NonNullableFormBuilder, private _router: Router) { - } - - public onRegister(): void { - this.replaceRouterConfig([ - ...this._router.config, - { - path: this.form.controls.path.value, - outlet: this.form.controls.outlet.value || undefined, - loadComponent: this.componentRefs.get(this.form.controls.component.value), - data: { - [WorkbenchRouteData.title]: this.form.controls.routeData.controls.title.value || undefined, - [WorkbenchRouteData.heading]: 'Workbench E2E Testpage', - [WorkbenchRouteData.cssClass]: this.form.controls.routeData.controls.cssClass.value.split(/\s+/).filter(Boolean), - }, - }, - ]); - - // Perform navigation to apply the route config change. - this._router.navigate([], {skipLocationChange: true, onSameUrlNavigation: 'reload'}).then(); - this.form.reset(); - } - - /** - * Replaces the router configuration to install or uninstall routes at runtime. - * - * Same implementation as in {@link WorkbenchAuxiliaryRoutesRegistrator}. - */ - private replaceRouterConfig(config: Routes): void { - // Note: - // - Do not use Router.resetConfig(...) which would destroy any currently routed component because copying all routes - // - Do not assign the router a new Routes object (Router.config = ...) to allow resolution of routes added during `NavigationStart` (since Angular 7.x) - // (because Angular uses a reference to the Routes object during route navigation) - const newRoutes: Routes = [...config]; - this._router.config.splice(0, this._router.config.length, ...newRoutes); - } -} diff --git a/apps/workbench-testing-app/src/app/layout-page/tables/add-parts/add-parts.component.html b/apps/workbench-testing-app/src/app/layout-page/tables/add-parts/add-parts.component.html new file mode 100644 index 000000000..c1b0f405c --- /dev/null +++ b/apps/workbench-testing-app/src/app/layout-page/tables/add-parts/add-parts.component.html @@ -0,0 +1,38 @@ +
+ Part ID + RelativeTo + Align + Ratio + Activate + + + @for (partFormGroup of form.controls.parts.controls; track $index) { + + + + + + + + + + + + + } +
+ + + + + + + @for (part of partProposals; track part) { + + } + diff --git a/apps/workbench-testing-app/src/app/layout-page/tables/add-parts/add-parts.component.scss b/apps/workbench-testing-app/src/app/layout-page/tables/add-parts/add-parts.component.scss new file mode 100644 index 000000000..bd37309e2 --- /dev/null +++ b/apps/workbench-testing-app/src/app/layout-page/tables/add-parts/add-parts.component.scss @@ -0,0 +1,24 @@ +@use '@scion/components.internal/design' as sci-design; + +:host { + display: grid; + + > form { + display: grid; + grid-template-columns: 7.5em repeat(3, 1fr) min-content auto; + gap: .5em .75em; + align-items: center; + + > span.checkbox { + text-align: center; + } + + > sci-checkbox { + justify-self: center; + } + + > input, select { + @include sci-design.style-input-field(); + } + } +} diff --git a/apps/workbench-testing-app/src/app/layout-page/tables/add-parts/add-parts.component.ts b/apps/workbench-testing-app/src/app/layout-page/tables/add-parts/add-parts.component.ts new file mode 100644 index 000000000..6fe68b96d --- /dev/null +++ b/apps/workbench-testing-app/src/app/layout-page/tables/add-parts/add-parts.component.ts @@ -0,0 +1,160 @@ +/* + * Copyright (c) 2018-2024 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, forwardRef, Input} from '@angular/core'; +import {AbstractControl, ControlValueAccessor, FormControl, FormGroup, NG_VALIDATORS, NG_VALUE_ACCESSOR, NonNullableFormBuilder, ReactiveFormsModule, ValidationErrors, Validator, Validators} from '@angular/forms'; +import {SciFormFieldComponent} from '@scion/components.internal/form-field'; +import {noop} from 'rxjs'; +import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; +import {SciCheckboxComponent} from '@scion/components.internal/checkbox'; +import {MAIN_AREA} from '@scion/workbench'; +import {SciMaterialIconDirective} from '@scion/components.internal/material-icon'; +import {UUID} from '@scion/toolkit/uuid'; + +@Component({ + selector: 'app-add-parts', + templateUrl: './add-parts.component.html', + styleUrls: ['./add-parts.component.scss'], + standalone: true, + imports: [ + ReactiveFormsModule, + SciFormFieldComponent, + SciCheckboxComponent, + SciMaterialIconDirective, + ], + providers: [ + {provide: NG_VALUE_ACCESSOR, multi: true, useExisting: forwardRef(() => AddPartsComponent)}, + {provide: NG_VALIDATORS, multi: true, useExisting: forwardRef(() => AddPartsComponent)}, + ], +}) +export class AddPartsComponent implements ControlValueAccessor, Validator { + + private _cvaChangeFn: (value: PartDescriptor[]) => void = noop; + private _cvaTouchedFn: () => void = noop; + + @Input() + public requiresInitialPart = false; + + @Input({transform: arrayAttribute}) + public partProposals: string[] = []; + + protected form = this._formBuilder.group({ + parts: this._formBuilder.array; + relativeTo: FormGroup<{ + relativeTo: FormControl; + align: FormControl<'left' | 'right' | 'top' | 'bottom' | undefined>; + ratio: FormControl; + }>; + options: FormGroup<{ + activate: FormControl; + }>; + }>>([]), + }); + + protected MAIN_AREA = MAIN_AREA; + protected relativeToList = `relative-to-list-${UUID.randomUUID()}`; + protected idList = `id-list-${UUID.randomUUID()}`; + + constructor(private _formBuilder: NonNullableFormBuilder) { + this.form.valueChanges + .pipe(takeUntilDestroyed()) + .subscribe(() => { + this._cvaChangeFn(this.form.controls.parts.controls.map(partFormGroup => ({ + id: partFormGroup.controls.id.value, + relativeTo: { + relativeTo: partFormGroup.controls.relativeTo.controls.relativeTo.value, + align: partFormGroup.controls.relativeTo.controls.align.value, + ratio: partFormGroup.controls.relativeTo.controls.ratio.value, + }, + options: { + activate: partFormGroup.controls.options.controls.activate.value, + }, + }))); + this._cvaTouchedFn(); + }); + } + + protected onAddPart(): void { + this.addPart({ + id: '', + relativeTo: {}, + }); + } + + protected onRemovePart(index: number): void { + this.form.controls.parts.removeAt(index); + } + + private addPart(part: PartDescriptor, options?: {emitEvent?: boolean}): void { + const isInitialPart = this.requiresInitialPart && this.form.controls.parts.length === 0; + this.form.controls.parts.push( + this._formBuilder.group({ + id: this._formBuilder.control(part.id, Validators.required), + relativeTo: this._formBuilder.group({ + relativeTo: this._formBuilder.control({value: isInitialPart ? undefined : part.relativeTo.relativeTo, disabled: isInitialPart}), + align: this._formBuilder.control<'left' | 'right' | 'top' | 'bottom' | undefined>({value: isInitialPart ? undefined : part.relativeTo.align, disabled: isInitialPart}, isInitialPart ? Validators.nullValidator : Validators.required), + ratio: this._formBuilder.control({value: isInitialPart ? undefined : part.relativeTo.ratio, disabled: isInitialPart}), + }), + options: this._formBuilder.group({ + activate: part.options?.activate, + }), + }), {emitEvent: options?.emitEvent ?? true}); + } + + /** + * Method implemented as part of `ControlValueAccessor` to work with Angular forms API + * @docs-private + */ + public writeValue(parts: PartDescriptor[] | undefined | null): void { + this.form.controls.parts.clear({emitEvent: false}); + parts?.forEach(part => this.addPart(part, {emitEvent: false})); + } + + /** + * Method implemented as part of `ControlValueAccessor` to work with Angular forms API + * @docs-private + */ + public registerOnChange(fn: any): void { + this._cvaChangeFn = fn; + } + + /** + * Method implemented as part of `ControlValueAccessor` to work with Angular forms API + * @docs-private + */ + public registerOnTouched(fn: any): void { + this._cvaTouchedFn = fn; + } + + /** + * Method implemented as part of `Validator` to work with Angular forms API + * @docs-private + */ + public validate(control: AbstractControl): ValidationErrors | null { + return this.form.controls.parts.valid ? null : {valid: false}; + } +} + +export interface PartDescriptor { + id: string | MAIN_AREA; + relativeTo: { + relativeTo?: string; + align?: 'left' | 'right' | 'top' | 'bottom'; + ratio?: number; + }; + options?: { + activate?: boolean; + }; +} + +function arrayAttribute(proposals: string[] | null | undefined): string[] { + return proposals ?? []; +} diff --git a/apps/workbench-testing-app/src/app/layout-page/tables/add-views/add-views.component.html b/apps/workbench-testing-app/src/app/layout-page/tables/add-views/add-views.component.html new file mode 100644 index 000000000..c78462b76 --- /dev/null +++ b/apps/workbench-testing-app/src/app/layout-page/tables/add-views/add-views.component.html @@ -0,0 +1,32 @@ +
+ View ID + Part ID + Position + CSS Class(es) + Activate View + Activate Part + + + @for (viewFormGroup of form.controls.views.controls; track $index) { + + + + + + + + + + + + + + + } + + + + @for (part of partProposals; track part) { + + } + diff --git a/apps/workbench-testing-app/src/app/layout-page/tables/add-views/add-views.component.scss b/apps/workbench-testing-app/src/app/layout-page/tables/add-views/add-views.component.scss new file mode 100644 index 000000000..09725d00b --- /dev/null +++ b/apps/workbench-testing-app/src/app/layout-page/tables/add-views/add-views.component.scss @@ -0,0 +1,24 @@ +@use '@scion/components.internal/design' as sci-design; + +:host { + display: grid; + + > form { + display: grid; + grid-template-columns: 7.5em 1fr 5em 10em min-content min-content auto; + gap: .5em .75em; + align-items: center; + + > span.checkbox { + text-align: center; + } + + > sci-checkbox { + justify-self: center; + } + + > input { + @include sci-design.style-input-field(); + } + } +} diff --git a/apps/workbench-testing-app/src/app/perspective-page/perspective-page-views/perspective-page-views.component.ts b/apps/workbench-testing-app/src/app/layout-page/tables/add-views/add-views.component.ts similarity index 50% rename from apps/workbench-testing-app/src/app/perspective-page/perspective-page-views/perspective-page-views.component.ts rename to apps/workbench-testing-app/src/app/layout-page/tables/add-views/add-views.component.ts index b422fd118..9caf41bef 100644 --- a/apps/workbench-testing-app/src/app/perspective-page/perspective-page-views/perspective-page-views.component.ts +++ b/apps/workbench-testing-app/src/app/layout-page/tables/add-views/add-views.component.ts @@ -9,89 +9,96 @@ */ import {Component, forwardRef, Input} from '@angular/core'; -import {CommonModule} from '@angular/common'; import {noop} from 'rxjs'; import {AbstractControl, ControlValueAccessor, FormControl, FormGroup, NG_VALIDATORS, NG_VALUE_ACCESSOR, NonNullableFormBuilder, ReactiveFormsModule, ValidationErrors, Validator, Validators} from '@angular/forms'; import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; import {SciCheckboxComponent} from '@scion/components.internal/checkbox'; import {SciFormFieldComponent} from '@scion/components.internal/form-field'; -import {PerspectivePagePartEntry} from '../perspective-page-parts/perspective-page-parts.component'; import {SciMaterialIconDirective} from '@scion/components.internal/material-icon'; +import {CssClassComponent} from '../../../css-class/css-class.component'; +import {UUID} from '@scion/toolkit/uuid'; @Component({ - selector: 'app-perspective-page-views', - templateUrl: './perspective-page-views.component.html', - styleUrls: ['./perspective-page-views.component.scss'], + selector: 'app-add-views', + templateUrl: './add-views.component.html', + styleUrls: ['./add-views.component.scss'], standalone: true, imports: [ - CommonModule, ReactiveFormsModule, SciCheckboxComponent, SciFormFieldComponent, SciMaterialIconDirective, + CssClassComponent, ], providers: [ - {provide: NG_VALUE_ACCESSOR, multi: true, useExisting: forwardRef(() => PerspectivePageViewsComponent)}, - {provide: NG_VALIDATORS, multi: true, useExisting: forwardRef(() => PerspectivePageViewsComponent)}, + {provide: NG_VALUE_ACCESSOR, multi: true, useExisting: forwardRef(() => AddViewsComponent)}, + {provide: NG_VALIDATORS, multi: true, useExisting: forwardRef(() => AddViewsComponent)}, ], }) -export class PerspectivePageViewsComponent implements ControlValueAccessor, Validator { +export class AddViewsComponent implements ControlValueAccessor, Validator { - private _cvaChangeFn: (value: PerspectivePageViewEntry[]) => void = noop; + private _cvaChangeFn: (value: ViewDescriptor[]) => void = noop; private _cvaTouchedFn: () => void = noop; - @Input() - public partEntries: PerspectivePagePartEntry[] = []; + @Input({transform: arrayAttribute}) + public partProposals: string[] = []; - public form = this._formBuilder.group({ + protected form = this._formBuilder.group({ views: this._formBuilder.array; - partId: FormControl; - position: FormControl; - activateView: FormControl; - activatePart: FormControl; + options: FormGroup<{ + partId: FormControl; + position: FormControl; + cssClass: FormControl; + activateView: FormControl; + activatePart: FormControl; + }>; }>>([]), }); + protected partList = `part-list-${UUID.randomUUID()}`; constructor(private _formBuilder: NonNullableFormBuilder) { this.form.valueChanges .pipe(takeUntilDestroyed()) .subscribe(() => { - const views: PerspectivePageViewEntry[] = this.form.controls.views.controls.map(viewFormGroup => ({ + this._cvaChangeFn(this.form.controls.views.controls.map(viewFormGroup => ({ id: viewFormGroup.controls.id.value, - partId: viewFormGroup.controls.partId.value, - position: viewFormGroup.controls.position.value, - activateView: viewFormGroup.controls.activateView.value, - activatePart: viewFormGroup.controls.activatePart.value, - })); - this._cvaChangeFn(views); + options: { + partId: viewFormGroup.controls.options.controls.partId.value, + position: viewFormGroup.controls.options.controls.position.value, + cssClass: viewFormGroup.controls.options.controls.cssClass.value, + activateView: viewFormGroup.controls.options.controls.activateView.value, + activatePart: viewFormGroup.controls.options.controls.activatePart.value, + }, + }))); this._cvaTouchedFn(); }); } protected onAddView(): void { - this.addViewEntry({ + this.addView({ id: '', - partId: '', + options: { + partId: '', + }, }); } - protected onClearViews(): void { - this.form.controls.views.clear(); - } - protected onRemoveView(index: number): void { this.form.controls.views.removeAt(index); } - private addViewEntry(view: PerspectivePageViewEntry, options?: {emitEvent?: boolean}): void { + private addView(view: ViewDescriptor, options?: {emitEvent?: boolean}): void { this.form.controls.views.push( this._formBuilder.group({ id: this._formBuilder.control(view.id, Validators.required), - partId: this._formBuilder.control(view.partId, Validators.required), - position: this._formBuilder.control(view.position), - activateView: this._formBuilder.control(view.activateView), - activatePart: this._formBuilder.control(view.activatePart), + options: this._formBuilder.group({ + partId: this._formBuilder.control(view.options.partId, Validators.required), + position: this._formBuilder.control(view.options.position), + cssClass: this._formBuilder.control(view.options.cssClass), + activateView: this._formBuilder.control(view.options.activateView), + activatePart: this._formBuilder.control(view.options.activatePart), + }), }), {emitEvent: options?.emitEvent ?? true}); } @@ -99,9 +106,9 @@ export class PerspectivePageViewsComponent implements ControlValueAccessor, Vali * Method implemented as part of `ControlValueAccessor` to work with Angular forms API * @docs-private */ - public writeValue(value: PerspectivePageViewEntry[] | undefined | null): void { + public writeValue(views: ViewDescriptor[] | undefined | null): void { this.form.controls.views.clear({emitEvent: false}); - value?.forEach(view => this.addViewEntry(view, {emitEvent: false})); + views?.forEach(view => this.addView(view, {emitEvent: false})); } /** @@ -129,10 +136,17 @@ export class PerspectivePageViewsComponent implements ControlValueAccessor, Vali } } -export type PerspectivePageViewEntry = { +export interface ViewDescriptor { id: string; - partId: string; - position?: number; - activateView?: boolean; - activatePart?: boolean; -}; + options: { + partId: string; + position?: number | 'start' | 'end' | 'before-active-view' | 'after-active-view'; + activateView?: boolean; + activatePart?: boolean; + cssClass?: string | string[]; + }; +} + +function arrayAttribute(proposals: string[] | null | undefined): string[] { + return proposals ?? []; +} diff --git a/apps/workbench-testing-app/src/app/layout-page/tables/navigate-views/navigate-views.component.html b/apps/workbench-testing-app/src/app/layout-page/tables/navigate-views/navigate-views.component.html new file mode 100644 index 000000000..9d53212ae --- /dev/null +++ b/apps/workbench-testing-app/src/app/layout-page/tables/navigate-views/navigate-views.component.html @@ -0,0 +1,29 @@ +
+ View ID + Path + Hint + State + CSS Class(es) + + + @for (navigationFormGroup of form.controls.navigations.controls; track $index) { + + + + + + + + + + + + + } + + + + @for (view of viewProposals; track view) { + + } + diff --git a/apps/workbench-testing-app/src/app/layout-page/tables/navigate-views/navigate-views.component.scss b/apps/workbench-testing-app/src/app/layout-page/tables/navigate-views/navigate-views.component.scss new file mode 100644 index 000000000..67b063682 --- /dev/null +++ b/apps/workbench-testing-app/src/app/layout-page/tables/navigate-views/navigate-views.component.scss @@ -0,0 +1,16 @@ +@use '@scion/components.internal/design' as sci-design; + +:host { + display: grid; + + > form { + display: grid; + grid-template-columns: 7.5em 1fr 10em 14em 10em auto; + gap: .5em .75em; + align-items: center; + + > input { + @include sci-design.style-input-field(); + } + } +} diff --git a/apps/workbench-testing-app/src/app/layout-page/tables/navigate-views/navigate-views.component.ts b/apps/workbench-testing-app/src/app/layout-page/tables/navigate-views/navigate-views.component.ts new file mode 100644 index 000000000..276c75fce --- /dev/null +++ b/apps/workbench-testing-app/src/app/layout-page/tables/navigate-views/navigate-views.component.ts @@ -0,0 +1,151 @@ +/* + * 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, forwardRef, Input} from '@angular/core'; +import {noop} from 'rxjs'; +import {AbstractControl, ControlValueAccessor, FormControl, FormGroup, NG_VALIDATORS, NG_VALUE_ACCESSOR, NonNullableFormBuilder, ReactiveFormsModule, ValidationErrors, Validator, Validators} from '@angular/forms'; +import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; +import {SciCheckboxComponent} from '@scion/components.internal/checkbox'; +import {SciFormFieldComponent} from '@scion/components.internal/form-field'; +import {SciMaterialIconDirective} from '@scion/components.internal/material-icon'; +import {Commands, ViewState} from '@scion/workbench'; +import {RouterCommandsComponent} from '../../../router-commands/router-commands.component'; +import {NavigationStateComponent} from '../../../navigation-state/navigation-state.component'; +import {CssClassComponent} from '../../../css-class/css-class.component'; +import {UUID} from '@scion/toolkit/uuid'; + +@Component({ + selector: 'app-navigate-views', + templateUrl: './navigate-views.component.html', + styleUrls: ['./navigate-views.component.scss'], + standalone: true, + imports: [ + ReactiveFormsModule, + SciCheckboxComponent, + SciFormFieldComponent, + SciMaterialIconDirective, + RouterCommandsComponent, + NavigationStateComponent, + CssClassComponent, + ], + providers: [ + {provide: NG_VALUE_ACCESSOR, multi: true, useExisting: forwardRef(() => NavigateViewsComponent)}, + {provide: NG_VALIDATORS, multi: true, useExisting: forwardRef(() => NavigateViewsComponent)}, + ], +}) +export class NavigateViewsComponent implements ControlValueAccessor, Validator { + + private _cvaChangeFn: (value: NavigationDescriptor[]) => void = noop; + private _cvaTouchedFn: () => void = noop; + + @Input({transform: arrayAttribute}) + public viewProposals: string[] = []; + + protected form = this._formBuilder.group({ + navigations: this._formBuilder.array; + commands: FormControl; + extras: FormGroup<{ + hint: FormControl; + state: FormControl; + cssClass: FormControl; + }>; + }>>([]), + }); + protected viewList = `view-list-${UUID.randomUUID()}`; + + constructor(private _formBuilder: NonNullableFormBuilder) { + this.form.valueChanges + .pipe(takeUntilDestroyed()) + .subscribe(() => { + this._cvaChangeFn(this.form.controls.navigations.controls.map(navigationFormGroup => ({ + id: navigationFormGroup.controls.id.value, + commands: navigationFormGroup.controls.commands.value, + extras: ({ + hint: navigationFormGroup.controls.extras.controls.hint.value || undefined, + state: navigationFormGroup.controls.extras.controls.state.value, + cssClass: navigationFormGroup.controls.extras.controls.cssClass.value, + }), + }))); + this._cvaTouchedFn(); + }); + } + + protected onAddNavigation(): void { + this.addNavigation({ + id: '', + commands: [], + }); + } + + protected onRemoveNavigation(index: number): void { + this.form.controls.navigations.removeAt(index); + } + + private addNavigation(navigation: NavigationDescriptor, options?: {emitEvent?: boolean}): void { + this.form.controls.navigations.push( + this._formBuilder.group({ + id: this._formBuilder.control(navigation.id, Validators.required), + commands: this._formBuilder.control(navigation.commands), + extras: this._formBuilder.group({ + hint: this._formBuilder.control(navigation.extras?.hint), + state: this._formBuilder.control(navigation.extras?.state), + cssClass: this._formBuilder.control(undefined), + }), + }), {emitEvent: options?.emitEvent ?? true}); + } + + /** + * Method implemented as part of `ControlValueAccessor` to work with Angular forms API + * @docs-private + */ + public writeValue(navigations: NavigationDescriptor[] | undefined | null): void { + this.form.controls.navigations.clear({emitEvent: false}); + navigations?.forEach(navigation => this.addNavigation(navigation, {emitEvent: false})); + } + + /** + * Method implemented as part of `ControlValueAccessor` to work with Angular forms API + * @docs-private + */ + public registerOnChange(fn: any): void { + this._cvaChangeFn = fn; + } + + /** + * Method implemented as part of `ControlValueAccessor` to work with Angular forms API + * @docs-private + */ + public registerOnTouched(fn: any): void { + this._cvaTouchedFn = fn; + } + + /** + * Method implemented as part of `Validator` to work with Angular forms API + * @docs-private + */ + public validate(control: AbstractControl): ValidationErrors | null { + return this.form.controls.navigations.valid ? null : {valid: false}; + } +} + +export interface NavigationDescriptor { + id: string; + commands: Commands; + extras?: { + hint?: string; + state?: ViewState; + cssClass?: string | string[]; + }; +} + +function arrayAttribute(proposals: string[] | null | undefined): string[] { + return proposals ?? []; +} diff --git a/apps/workbench-testing-app/src/app/menu/menu-item.ts b/apps/workbench-testing-app/src/app/menu/menu-item.ts index eb431edc6..bc9f7a497 100644 --- a/apps/workbench-testing-app/src/app/menu/menu-item.ts +++ b/apps/workbench-testing-app/src/app/menu/menu-item.ts @@ -29,7 +29,7 @@ export class MenuItem { */ public checked?: boolean; /** - * Specifies CSS class(es) to be added to the menu item, useful in end-to-end tests for locating the menu item. + * Specifies CSS class(es) to add to the menu item, e.g., to locate the menu item in tests. */ public cssClass?: string | string[]; diff --git a/apps/workbench-testing-app/src/app/message-box-opener-page/message-box-opener-page.component.html b/apps/workbench-testing-app/src/app/message-box-opener-page/message-box-opener-page.component.html index a944492d9..298063e64 100644 --- a/apps/workbench-testing-app/src/app/message-box-opener-page/message-box-opener-page.component.html +++ b/apps/workbench-testing-app/src/app/message-box-opener-page/message-box-opener-page.component.html @@ -47,7 +47,7 @@
- +
diff --git a/apps/workbench-testing-app/src/app/message-box-opener-page/message-box-opener-page.component.ts b/apps/workbench-testing-app/src/app/message-box-opener-page/message-box-opener-page.component.ts index 205c4664b..e4ea65f7b 100644 --- a/apps/workbench-testing-app/src/app/message-box-opener-page/message-box-opener-page.component.ts +++ b/apps/workbench-testing-app/src/app/message-box-opener-page/message-box-opener-page.component.ts @@ -17,6 +17,7 @@ import {KeyValueEntry, SciKeyValueFieldComponent} from '@scion/components.intern import {SciFormFieldComponent} from '@scion/components.internal/form-field'; import {SciCheckboxComponent} from '@scion/components.internal/checkbox'; import {MessageBoxPageComponent} from '../message-box-page/message-box-page.component'; +import {CssClassComponent} from '../css-class/css-class.component'; @Component({ selector: 'app-message-box-opener-page', @@ -29,6 +30,7 @@ import {MessageBoxPageComponent} from '../message-box-page/message-box-page.comp SciFormFieldComponent, SciKeyValueFieldComponent, SciCheckboxComponent, + CssClassComponent, ], }) export default class MessageBoxOpenerPageComponent { @@ -43,7 +45,7 @@ export default class MessageBoxOpenerPageComponent { modality: this._formBuilder.control<'application' | 'view' | ''>(''), contentSelectable: this._formBuilder.control(false), inputs: this._formBuilder.array>([]), - cssClass: this._formBuilder.control(''), + cssClass: this._formBuilder.control(undefined), }), }); @@ -71,7 +73,7 @@ export default class MessageBoxOpenerPageComponent { modality: this.form.controls.options.controls.modality.value || undefined, contentSelectable: this.form.controls.options.controls.contentSelectable.value || undefined, inputs: SciKeyValueFieldComponent.toDictionary(this.form.controls.options.controls.inputs) ?? undefined, - cssClass: this.form.controls.options.controls.cssClass.value.split(/\s+/).filter(Boolean), + cssClass: this.form.controls.options.controls.cssClass.value, }; if (this.isUseComponent()) { diff --git a/apps/workbench-testing-app/src/app/navigation-state/navigation-state.component.html b/apps/workbench-testing-app/src/app/navigation-state/navigation-state.component.html new file mode 100644 index 000000000..37df287d0 --- /dev/null +++ b/apps/workbench-testing-app/src/app/navigation-state/navigation-state.component.html @@ -0,0 +1 @@ + diff --git a/apps/workbench-testing-app/src/app/navigation-state/navigation-state.component.scss b/apps/workbench-testing-app/src/app/navigation-state/navigation-state.component.scss new file mode 100644 index 000000000..85980ba98 --- /dev/null +++ b/apps/workbench-testing-app/src/app/navigation-state/navigation-state.component.scss @@ -0,0 +1,10 @@ +@use '@scion/components.internal/design' as sci-design; + +:host { + display: inline-grid; + + > input { + @include sci-design.style-input-field(); + min-width: 0; + } +} diff --git a/apps/workbench-testing-app/src/app/navigation-state/navigation-state.component.ts b/apps/workbench-testing-app/src/app/navigation-state/navigation-state.component.ts new file mode 100644 index 000000000..87cc7645e --- /dev/null +++ b/apps/workbench-testing-app/src/app/navigation-state/navigation-state.component.ts @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2018-2024 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, forwardRef} from '@angular/core'; +import {ControlValueAccessor, NG_VALUE_ACCESSOR, NonNullableFormBuilder, ReactiveFormsModule} from '@angular/forms'; +import {ViewState} from '@scion/workbench'; +import {noop} from 'rxjs'; +import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; + +@Component({ + selector: 'app-navigation-state', + templateUrl: './navigation-state.component.html', + styleUrls: ['./navigation-state.component.scss'], + standalone: true, + imports: [ + ReactiveFormsModule, + ], + providers: [ + {provide: NG_VALUE_ACCESSOR, multi: true, useExisting: forwardRef(() => NavigationStateComponent)}, + ], +}) +export class NavigationStateComponent implements ControlValueAccessor { + + private _cvaChangeFn: (state: ViewState | undefined) => void = noop; + private _cvaTouchedFn: () => void = noop; + + protected formControl = this._formBuilder.control(''); + + constructor(private _formBuilder: NonNullableFormBuilder) { + this.formControl.valueChanges + .pipe(takeUntilDestroyed()) + .subscribe(() => { + this._cvaChangeFn(this.parse(this.formControl.value)); + this._cvaTouchedFn(); + }); + } + + private parse(stringified: string): ViewState | undefined { + if (!stringified.length) { + return undefined; + } + const state: ViewState = {}; + for (const match of stringified.matchAll(/(?[^=;]+)=(?[^;]+)/g)) { + const {key, value} = match.groups!; + state[key] = value; + } + return state; + } + + private stringify(state: ViewState | null | undefined): string { + return Object.entries(state ?? {}).map(([key, value]) => `${key}=${value}`).join(';'); + } + + /** + * Method implemented as part of `ControlValueAccessor` to work with Angular forms API + * @docs-private + */ + public writeValue(state: ViewState | undefined | null): void { + this.formControl.setValue(this.stringify(state), {emitEvent: false}); + } + + /** + * Method implemented as part of `ControlValueAccessor` to work with Angular forms API + * @docs-private + */ + public registerOnChange(fn: any): void { + this._cvaChangeFn = fn; + } + + /** + * Method implemented as part of `ControlValueAccessor` to work with Angular forms API + * @docs-private + */ + public registerOnTouched(fn: any): void { + this._cvaTouchedFn = fn; + } +} diff --git a/apps/workbench-testing-app/src/app/notification-opener-page/notification-opener-page.component.html b/apps/workbench-testing-app/src/app/notification-opener-page/notification-opener-page.component.html index 5f5026033..29a73674f 100644 --- a/apps/workbench-testing-app/src/app/notification-opener-page/notification-opener-page.component.html +++ b/apps/workbench-testing-app/src/app/notification-opener-page/notification-opener-page.component.html @@ -42,7 +42,7 @@ - +
diff --git a/apps/workbench-testing-app/src/app/notification-opener-page/notification-opener-page.component.ts b/apps/workbench-testing-app/src/app/notification-opener-page/notification-opener-page.component.ts index 7e8902a4c..881a58930 100644 --- a/apps/workbench-testing-app/src/app/notification-opener-page/notification-opener-page.component.ts +++ b/apps/workbench-testing-app/src/app/notification-opener-page/notification-opener-page.component.ts @@ -16,6 +16,7 @@ import {SciCheckboxComponent} from '@scion/components.internal/checkbox'; import {SciFormFieldComponent} from '@scion/components.internal/form-field'; import {stringifyError} from '../common/stringify-error.util'; import {NotificationPageComponent} from '../notification-page/notification-page.component'; +import {CssClassComponent} from '../css-class/css-class.component'; @Component({ selector: 'app-notification-opener-page', @@ -27,6 +28,7 @@ import {NotificationPageComponent} from '../notification-page/notification-page. ReactiveFormsModule, SciFormFieldComponent, SciCheckboxComponent, + CssClassComponent, ], }) export default class NotificationOpenerPageComponent { @@ -42,7 +44,7 @@ export default class NotificationOpenerPageComponent { duration: this._formBuilder.control<'short' | 'medium' | 'long' | 'infinite' | '' | number>(''), group: this._formBuilder.control(''), useGroupInputReducer: this._formBuilder.control(false), - cssClass: this._formBuilder.control(''), + cssClass: this._formBuilder.control(undefined), }); constructor(private _formBuilder: NonNullableFormBuilder, private _notificationService: NotificationService) { @@ -59,7 +61,7 @@ export default class NotificationOpenerPageComponent { duration: this.parseDurationFromUI(), group: this.form.controls.group.value || undefined, groupInputReduceFn: this.isUseGroupInputReducer() ? concatInput : undefined, - cssClass: this.form.controls.cssClass.value.split(/\s+/).filter(Boolean), + cssClass: this.form.controls.cssClass.value, }); } catch (error) { diff --git a/apps/workbench-testing-app/src/app/notification-page/notification-page.component.html b/apps/workbench-testing-app/src/app/notification-page/notification-page.component.html index 4558845ef..cbc98094f 100644 --- a/apps/workbench-testing-app/src/app/notification-page/notification-page.component.html +++ b/apps/workbench-testing-app/src/app/notification-page/notification-page.component.html @@ -30,6 +30,6 @@ - +
diff --git a/apps/workbench-testing-app/src/app/notification-page/notification-page.component.ts b/apps/workbench-testing-app/src/app/notification-page/notification-page.component.ts index c32df828e..5ea97f46a 100644 --- a/apps/workbench-testing-app/src/app/notification-page/notification-page.component.ts +++ b/apps/workbench-testing-app/src/app/notification-page/notification-page.component.ts @@ -17,6 +17,7 @@ import {StringifyPipe} from '../common/stringify.pipe'; import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; import {filter} from 'rxjs/operators'; import {SciFormFieldComponent} from '@scion/components.internal/form-field'; +import {CssClassComponent} from '../css-class/css-class.component'; @Component({ selector: 'app-notification-page', @@ -29,6 +30,7 @@ import {SciFormFieldComponent} from '@scion/components.internal/form-field'; StringifyPipe, SciFormFieldComponent, SciViewportComponent, + CssClassComponent, ], }) export class NotificationPageComponent { @@ -37,7 +39,7 @@ export class NotificationPageComponent { title: this._formBuilder.control(''), severity: this._formBuilder.control<'info' | 'warn' | 'error' | undefined>(undefined), duration: this._formBuilder.control<'short' | 'medium' | 'long' | 'infinite' | number | undefined>(undefined), - cssClass: this._formBuilder.control(''), + cssClass: this._formBuilder.control(undefined), }); constructor(public notification: Notification>, private _formBuilder: NonNullableFormBuilder) { @@ -68,7 +70,7 @@ export class NotificationPageComponent { this.form.controls.cssClass.valueChanges .pipe(takeUntilDestroyed()) .subscribe(cssClass => { - this.notification.setCssClass(cssClass.split(/\s+/).filter(Boolean)); + this.notification.setCssClass(cssClass ?? []); }); } diff --git a/apps/workbench-testing-app/src/app/perspective-page/perspective-page-parts/perspective-page-parts.component.html b/apps/workbench-testing-app/src/app/perspective-page/perspective-page-parts/perspective-page-parts.component.html deleted file mode 100644 index 622f11c47..000000000 --- a/apps/workbench-testing-app/src/app/perspective-page/perspective-page-parts/perspective-page-parts.component.html +++ /dev/null @@ -1,46 +0,0 @@ -
- -
- Part ID - RelativeTo - Align - Ratio - Activate - -
- - - - -
- - - - - - - - - - - - - - - -
-
-
- - - - - - - - diff --git a/apps/workbench-testing-app/src/app/perspective-page/perspective-page-parts/perspective-page-parts.component.scss b/apps/workbench-testing-app/src/app/perspective-page/perspective-page-parts/perspective-page-parts.component.scss deleted file mode 100644 index 300f80dce..000000000 --- a/apps/workbench-testing-app/src/app/perspective-page/perspective-page-parts/perspective-page-parts.component.scss +++ /dev/null @@ -1,22 +0,0 @@ -:host { - > form { - display: flex; - flex-direction: column; - gap: 1em; - - div.parts { - display: grid; - grid-template-columns: repeat(4, 1fr) min-content auto; - gap: .5em .75em; - align-items: center; - - > span.checkbox { - text-align: center; - } - - > sci-checkbox { - justify-self: center; - } - } - } -} diff --git a/apps/workbench-testing-app/src/app/perspective-page/perspective-page-parts/perspective-page-parts.component.ts b/apps/workbench-testing-app/src/app/perspective-page/perspective-page-parts/perspective-page-parts.component.ts deleted file mode 100644 index 51ab2d63e..000000000 --- a/apps/workbench-testing-app/src/app/perspective-page/perspective-page-parts/perspective-page-parts.component.ts +++ /dev/null @@ -1,137 +0,0 @@ -/* - * 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, forwardRef} from '@angular/core'; -import {CommonModule} from '@angular/common'; -import {AbstractControl, ControlValueAccessor, FormControl, FormGroup, NG_VALIDATORS, NG_VALUE_ACCESSOR, NonNullableFormBuilder, ReactiveFormsModule, ValidationErrors, Validator, Validators} from '@angular/forms'; -import {SciFormFieldComponent} from '@scion/components.internal/form-field'; -import {noop} from 'rxjs'; -import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; -import {SciCheckboxComponent} from '@scion/components.internal/checkbox'; -import {MAIN_AREA} from '@scion/workbench'; -import {SciMaterialIconDirective} from '@scion/components.internal/material-icon'; - -@Component({ - selector: 'app-perspective-page-parts', - templateUrl: './perspective-page-parts.component.html', - styleUrls: ['./perspective-page-parts.component.scss'], - standalone: true, - imports: [ - CommonModule, - ReactiveFormsModule, - SciFormFieldComponent, - SciCheckboxComponent, - SciMaterialIconDirective - ], - providers: [ - {provide: NG_VALUE_ACCESSOR, multi: true, useExisting: forwardRef(() => PerspectivePagePartsComponent)}, - {provide: NG_VALIDATORS, multi: true, useExisting: forwardRef(() => PerspectivePagePartsComponent)}, - ], -}) -export class PerspectivePagePartsComponent implements ControlValueAccessor, Validator { - - private _cvaChangeFn: (value: PerspectivePagePartEntry[]) => void = noop; - private _cvaTouchedFn: () => void = noop; - - public form = this._formBuilder.group({ - parts: this._formBuilder.array; - relativeTo: FormControl; - align: FormControl<'left' | 'right' | 'top' | 'bottom' | undefined>; - ratio: FormControl; - activate: FormControl; - }>>([]), - }); - - public MAIN_AREA = MAIN_AREA; - - constructor(private _formBuilder: NonNullableFormBuilder) { - this.form.valueChanges - .pipe(takeUntilDestroyed()) - .subscribe(() => { - const parts: PerspectivePagePartEntry[] = this.form.controls.parts.controls.map(partFormGroup => ({ - id: partFormGroup.controls.id.value, - relativeTo: partFormGroup.controls.relativeTo.value, - align: partFormGroup.controls.align.value, - ratio: partFormGroup.controls.ratio.value, - activate: partFormGroup.controls.activate.value, - })); - this._cvaChangeFn(parts); - this._cvaTouchedFn(); - }); - } - - protected onAddPart(): void { - this.addPartEntry({ - id: '', - }); - } - - protected onClearParts(): void { - this.form.controls.parts.clear(); - } - - protected onRemovePart(index: number): void { - this.form.controls.parts.removeAt(index); - } - - private addPartEntry(part: PerspectivePagePartEntry, options?: {emitEvent?: boolean}): void { - const first = this.form.controls.parts.length === 0; - this.form.controls.parts.push( - this._formBuilder.group({ - id: this._formBuilder.control(part.id, Validators.required), - relativeTo: this._formBuilder.control({value: first ? undefined : part.relativeTo, disabled: first}), - align: this._formBuilder.control<'left' | 'right' | 'top' | 'bottom' | undefined>({value: first ? undefined : part.align, disabled: first}, first ? Validators.nullValidator : Validators.required), - ratio: this._formBuilder.control({value: first ? undefined : part.ratio, disabled: first}), - activate: part.activate, - }), {emitEvent: options?.emitEvent ?? true}); - } - - /** - * Method implemented as part of `ControlValueAccessor` to work with Angular forms API - * @docs-private - */ - public writeValue(value: PerspectivePagePartEntry[] | undefined | null): void { - this.form.controls.parts.clear({emitEvent: false}); - value?.forEach(part => this.addPartEntry(part, {emitEvent: false})); - } - - /** - * Method implemented as part of `ControlValueAccessor` to work with Angular forms API - * @docs-private - */ - public registerOnChange(fn: any): void { - this._cvaChangeFn = fn; - } - - /** - * Method implemented as part of `ControlValueAccessor` to work with Angular forms API - * @docs-private - */ - public registerOnTouched(fn: any): void { - this._cvaTouchedFn = fn; - } - - /** - * Method implemented as part of `Validator` to work with Angular forms API - * @docs-private - */ - public validate(control: AbstractControl): ValidationErrors | null { - return this.form.controls.parts.valid ? null : {valid: false}; - } -} - -export type PerspectivePagePartEntry = { - id: string | MAIN_AREA; - relativeTo?: string; - align?: 'left' | 'right' | 'top' | 'bottom'; - ratio?: number; - activate?: boolean; -}; diff --git a/apps/workbench-testing-app/src/app/perspective-page/perspective-page-views/perspective-page-views.component.html b/apps/workbench-testing-app/src/app/perspective-page/perspective-page-views/perspective-page-views.component.html deleted file mode 100644 index 2dd52047a..000000000 --- a/apps/workbench-testing-app/src/app/perspective-page/perspective-page-views/perspective-page-views.component.html +++ /dev/null @@ -1,37 +0,0 @@ -
- -
- View ID - Part ID - Position - Activate View - Activate Part - -
- - - - -
- - - - - - - - - - - - - - - -
-
-
- - - - diff --git a/apps/workbench-testing-app/src/app/perspective-page/perspective-page-views/perspective-page-views.component.scss b/apps/workbench-testing-app/src/app/perspective-page/perspective-page-views/perspective-page-views.component.scss deleted file mode 100644 index 07e3a3274..000000000 --- a/apps/workbench-testing-app/src/app/perspective-page/perspective-page-views/perspective-page-views.component.scss +++ /dev/null @@ -1,22 +0,0 @@ -:host { - > form { - display: flex; - flex-direction: column; - gap: 1em; - - div.views { - display: grid; - grid-template-columns: repeat(3, 1fr) min-content min-content auto; - gap: .5em .75em; - align-items: center; - - > span.checkbox { - text-align: center; - } - - > sci-checkbox { - justify-self: center; - } - } - } -} diff --git a/apps/workbench-testing-app/src/app/perspective-page/perspective-page.component.html b/apps/workbench-testing-app/src/app/perspective-page/perspective-page.component.html deleted file mode 100644 index a8c88d53a..000000000 --- a/apps/workbench-testing-app/src/app/perspective-page/perspective-page.component.html +++ /dev/null @@ -1,33 +0,0 @@ -
-
- - - - - - - - - -
- -
- -
- -
- -
- - -
- - - Success - - - - {{registerError}} - diff --git a/apps/workbench-testing-app/src/app/perspective-page/perspective-page.component.ts b/apps/workbench-testing-app/src/app/perspective-page/perspective-page.component.ts deleted file mode 100644 index 1eb218a5b..000000000 --- a/apps/workbench-testing-app/src/app/perspective-page/perspective-page.component.ts +++ /dev/null @@ -1,85 +0,0 @@ -/* - * 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} from '@angular/core'; -import {FormGroup, NonNullableFormBuilder, ReactiveFormsModule, Validators} from '@angular/forms'; -import {WorkbenchLayout, WorkbenchLayoutFactory, WorkbenchLayoutFn, WorkbenchService} from '@scion/workbench'; -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 {NgIf} from '@angular/common'; -import {SciCheckboxComponent} from '@scion/components.internal/checkbox'; -import {PerspectivePagePartEntry, PerspectivePagePartsComponent} from './perspective-page-parts/perspective-page-parts.component'; -import {PerspectivePageViewEntry, PerspectivePageViewsComponent} from './perspective-page-views/perspective-page-views.component'; - -@Component({ - selector: 'app-perspective-page', - templateUrl: './perspective-page.component.html', - styleUrls: ['./perspective-page.component.scss'], - standalone: true, - imports: [ - NgIf, - ReactiveFormsModule, - SciFormFieldComponent, - SciCheckboxComponent, - SciKeyValueFieldComponent, - PerspectivePagePartsComponent, - PerspectivePageViewsComponent, - ], -}) -export default class PerspectivePageComponent { - - public form = this._formBuilder.group({ - id: this._formBuilder.control('', Validators.required), - transient: this._formBuilder.control(undefined), - data: this._formBuilder.array>([]), - parts: this._formBuilder.control([], Validators.required), - views: this._formBuilder.control([]), - }); - public registerError: string | false | undefined; - - constructor(private _formBuilder: NonNullableFormBuilder, private _workbenchService: WorkbenchService) { - } - - public async onRegister(): Promise { - try { - await this._workbenchService.registerPerspective({ - id: this.form.controls.id.value, - transient: this.form.controls.transient.value || undefined, - data: SciKeyValueFieldComponent.toDictionary(this.form.controls.data) ?? undefined, - layout: this.createLayout(), - }); - this.registerError = false; - this.form.reset(); - this.form.setControl('data', this._formBuilder.array>([])); - } - catch (error) { - this.registerError = stringifyError(error); - } - } - - private createLayout(): WorkbenchLayoutFn { - // Capture form values, since the `layout` function is evaluated independently of the form life-cycle - const [initialPart, ...parts] = this.form.controls.parts.value; - const views = this.form.controls.views.value; - - return (factory: WorkbenchLayoutFactory): WorkbenchLayout => { - let layout = factory.addPart(initialPart.id, {activate: initialPart.activate}); - for (const part of parts) { - layout = layout.addPart(part.id, {relativeTo: part.relativeTo, align: part.align!, ratio: part.ratio}, {activate: part.activate}); - } - - for (const view of views) { - layout = layout.addView(view.id, {partId: view.partId, position: view.position, activateView: view.activateView, activatePart: view.activatePart}); - } - return layout; - }; - } -} diff --git a/apps/workbench-testing-app/src/app/popup-opener-page/popup-opener-page.component.html b/apps/workbench-testing-app/src/app/popup-opener-page/popup-opener-page.component.html index 15a632371..163160202 100644 --- a/apps/workbench-testing-app/src/app/popup-opener-page/popup-opener-page.component.html +++ b/apps/workbench-testing-app/src/app/popup-opener-page/popup-opener-page.component.html @@ -26,7 +26,7 @@ - + diff --git a/apps/workbench-testing-app/src/app/popup-opener-page/popup-opener-page.component.ts b/apps/workbench-testing-app/src/app/popup-opener-page/popup-opener-page.component.ts index e142a46d4..97162d2e8 100644 --- a/apps/workbench-testing-app/src/app/popup-opener-page/popup-opener-page.component.ts +++ b/apps/workbench-testing-app/src/app/popup-opener-page/popup-opener-page.component.ts @@ -10,7 +10,7 @@ import {Component, ElementRef, Type, ViewChild} from '@angular/core'; import {NonNullableFormBuilder, ReactiveFormsModule, Validators} from '@angular/forms'; -import {PopupService, PopupSize} from '@scion/workbench'; +import {PopupService, PopupSize, ViewId} from '@scion/workbench'; import {PopupPageComponent} from '../popup-page/popup-page.component'; import FocusTestPageComponent from '../test-pages/focus-test-page/focus-test-page.component'; import {map, startWith} from 'rxjs/operators'; @@ -28,6 +28,7 @@ import InputFieldTestPageComponent from '../test-pages/input-field-test-page/inp import DialogOpenerPageComponent from '../dialog-opener-page/dialog-opener-page.component'; import {Dictionaries} from '@scion/toolkit/util'; import {parseTypedString} from '../common/parse-typed-value.util'; +import {CssClassComponent} from '../css-class/css-class.component'; @Component({ selector: 'app-popup-opener-page', @@ -42,6 +43,7 @@ import {parseTypedString} from '../common/parse-typed-value.util'; SciAccordionItemDirective, SciCheckboxComponent, PopupPositionLabelPipe, + CssClassComponent, ], }) export default class PopupOpenerPageComponent { @@ -57,9 +59,9 @@ export default class PopupOpenerPageComponent { width: this._formBuilder.control(undefined), height: this._formBuilder.control(undefined), }), - contextualViewId: this._formBuilder.control(''), + contextualViewId: this._formBuilder.control(''), align: this._formBuilder.control<'east' | 'west' | 'north' | 'south' | ''>(''), - cssClass: this._formBuilder.control(''), + cssClass: this._formBuilder.control(undefined), input: this._formBuilder.control(''), closeStrategy: this._formBuilder.group({ onFocusLost: this._formBuilder.control(true), @@ -94,7 +96,7 @@ export default class PopupOpenerPageComponent { input: this.form.controls.input.value || undefined, anchor: this.form.controls.anchor.controls.position.value === 'element' ? this._openButton : this._popupOrigin$, align: this.form.controls.align.value || undefined, - cssClass: this.form.controls.cssClass.value.split(/\s+/).filter(Boolean), + cssClass: this.form.controls.cssClass.value, closeStrategy: { onFocusLost: this.form.controls.closeStrategy.controls.onFocusLost.value, onEscape: this.form.controls.closeStrategy.controls.onEscape.value, diff --git a/apps/workbench-testing-app/src/app/router-commands/router-commands.component.html b/apps/workbench-testing-app/src/app/router-commands/router-commands.component.html new file mode 100644 index 000000000..93c1a2d9c --- /dev/null +++ b/apps/workbench-testing-app/src/app/router-commands/router-commands.component.html @@ -0,0 +1,7 @@ + + + + @for (route of routes; track route) { + + } + diff --git a/apps/workbench-testing-app/src/app/router-commands/router-commands.component.scss b/apps/workbench-testing-app/src/app/router-commands/router-commands.component.scss new file mode 100644 index 000000000..85980ba98 --- /dev/null +++ b/apps/workbench-testing-app/src/app/router-commands/router-commands.component.scss @@ -0,0 +1,10 @@ +@use '@scion/components.internal/design' as sci-design; + +:host { + display: inline-grid; + + > input { + @include sci-design.style-input-field(); + min-width: 0; + } +} diff --git a/apps/workbench-testing-app/src/app/router-commands/router-commands.component.ts b/apps/workbench-testing-app/src/app/router-commands/router-commands.component.ts new file mode 100644 index 000000000..5389deb88 --- /dev/null +++ b/apps/workbench-testing-app/src/app/router-commands/router-commands.component.ts @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2018-2024 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, forwardRef} from '@angular/core'; +import {PRIMARY_OUTLET, Router, Routes} from '@angular/router'; +import {ControlValueAccessor, NG_VALUE_ACCESSOR, NonNullableFormBuilder, ReactiveFormsModule} from '@angular/forms'; +import {UUID} from '@scion/toolkit/uuid'; +import {Commands} from '@scion/workbench'; +import {noop} from 'rxjs'; +import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; + +@Component({ + selector: 'app-router-commands', + templateUrl: './router-commands.component.html', + styleUrls: ['./router-commands.component.scss'], + standalone: true, + imports: [ + ReactiveFormsModule, + ], + providers: [ + {provide: NG_VALUE_ACCESSOR, multi: true, useExisting: forwardRef(() => RouterCommandsComponent)}, + ], +}) +export class RouterCommandsComponent implements ControlValueAccessor { + + private _cvaChangeFn: (commands: Commands) => void = noop; + private _cvaTouchedFn: () => void = noop; + + protected routes: Routes; + protected routeList = `route-list-${UUID.randomUUID()}`; + protected formControl = this._formBuilder.control(''); + + constructor(private _router: Router, private _formBuilder: NonNullableFormBuilder) { + this.routes = this._router.config; + + this.formControl.valueChanges + .pipe(takeUntilDestroyed()) + .subscribe(() => { + this._cvaChangeFn(this.parse(this.formControl.value)); + this._cvaTouchedFn(); + }); + } + + private parse(value: string): Commands { + if (value === '') { + return []; + } + if (value === '/') { + return ['/']; + } + + const urlTree = this._router.parseUrl(value); + const segmentGroup = urlTree.root.children[PRIMARY_OUTLET]; + if (!segmentGroup) { + return []; // path syntax error + } + + const commands = new Array(); + segmentGroup.segments.forEach(segment => { + if (segment.path) { + commands.push(segment.path); + } + if (Object.keys(segment.parameters).length) { + commands.push(segment.parameters); + } + }); + + if (value.startsWith('/')) { + commands.unshift('/'); + } + + return commands; + } + + private stringify(commands: Commands | null | undefined): string { + if (!commands || !commands.length) { + return ''; + } + + const urlTree = this._router.createUrlTree(commands); + return urlTree.root.children[PRIMARY_OUTLET].segments.join('/'); + } + + /** + * Method implemented as part of `ControlValueAccessor` to work with Angular forms API + * @docs-private + */ + public writeValue(commands: Commands | undefined | null): void { + this.formControl.setValue(this.stringify(commands), {emitEvent: false}); + } + + /** + * Method implemented as part of `ControlValueAccessor` to work with Angular forms API + * @docs-private + */ + public registerOnChange(fn: any): void { + this._cvaChangeFn = fn; + } + + /** + * Method implemented as part of `ControlValueAccessor` to work with Angular forms API + * @docs-private + */ + public registerOnTouched(fn: any): void { + this._cvaTouchedFn = fn; + } +} diff --git a/apps/workbench-testing-app/src/app/router-page/router-page.component.html b/apps/workbench-testing-app/src/app/router-page/router-page.component.html index e31ab2eff..786ee4489 100644 --- a/apps/workbench-testing-app/src/app/router-page/router-page.component.html +++ b/apps/workbench-testing-app/src/app/router-page/router-page.component.html @@ -3,14 +3,7 @@
Routing Command
- - - - - - - - + @@ -29,6 +22,10 @@ + + + + @@ -61,7 +58,7 @@ - + @@ -71,11 +68,15 @@ - + + Navigate via Router Link + diff --git a/apps/workbench-testing-app/src/app/router-page/router-page.component.ts b/apps/workbench-testing-app/src/app/router-page/router-page.component.ts index 6a486f2fb..4ed29845f 100644 --- a/apps/workbench-testing-app/src/app/router-page/router-page.component.ts +++ b/apps/workbench-testing-app/src/app/router-page/router-page.component.ts @@ -11,15 +11,17 @@ import {Component, Injector} from '@angular/core'; import {FormGroup, NonNullableFormBuilder, ReactiveFormsModule} from '@angular/forms'; import {WorkbenchNavigationExtras, WorkbenchRouter, WorkbenchRouterLinkDirective, WorkbenchService, WorkbenchView} from '@scion/workbench'; -import {Params, PRIMARY_OUTLET, Router, Routes} from '@angular/router'; import {coerceNumberProperty} from '@angular/cdk/coercion'; -import {BehaviorSubject, Observable, share} from 'rxjs'; -import {map} from 'rxjs/operators'; import {AsyncPipe, NgFor, NgIf, NgTemplateOutlet} from '@angular/common'; 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 {SettingsService} from '../settings.service'; +import {stringifyError} from '../common/stringify-error.util'; +import {RouterCommandsComponent} from '../router-commands/router-commands.component'; +import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; import {parseTypedObject} from '../common/parse-typed-value.util'; +import {CssClassComponent} from '../css-class/css-class.component'; @Component({ selector: 'app-router-page', @@ -36,95 +38,82 @@ import {parseTypedObject} from '../common/parse-typed-value.util'; SciFormFieldComponent, SciKeyValueFieldComponent, SciCheckboxComponent, + RouterCommandsComponent, + CssClassComponent, ], }) export default class RouterPageComponent { - public form = this._formBuilder.group({ - path: this._formBuilder.control(''), - matrixParams: this._formBuilder.array>([]), + protected form = this._formBuilder.group({ + commands: this._formBuilder.control([]), state: this._formBuilder.array>([]), target: this._formBuilder.control(''), + hint: this._formBuilder.control(''), blankPartId: this._formBuilder.control(''), insertionIndex: this._formBuilder.control(''), queryParams: this._formBuilder.array>([]), activate: this._formBuilder.control(undefined), close: this._formBuilder.control(undefined), - cssClass: this._formBuilder.control(undefined), + cssClass: this._formBuilder.control(undefined), viewContext: this._formBuilder.control(true), }); - public navigateError: string | undefined; + protected navigateError: string | undefined; - public routerLinkCommands$: Observable; - public navigationExtras$: Observable; - public routes: Routes; - - public nullViewInjector: Injector; + protected nullViewInjector: Injector; + protected extras: WorkbenchNavigationExtras = {}; constructor(private _formBuilder: NonNullableFormBuilder, injector: Injector, - private _router: Router, private _wbRouter: WorkbenchRouter, - public workbenchService: WorkbenchService) { - this.routerLinkCommands$ = this.form.valueChanges - .pipe( - map(() => this.constructNavigationCommands()), - share({connector: () => new BehaviorSubject(this.constructNavigationCommands())}), - ); - - this.navigationExtras$ = this.form.valueChanges - .pipe( - map(() => this.constructNavigationExtras()), - share({connector: () => new BehaviorSubject(this.constructNavigationExtras())}), - ); - - this.routes = this._router.config - .filter(route => !route.outlet || route.outlet === PRIMARY_OUTLET) - .filter(route => !route.path?.startsWith('~')); // microfrontend route prefix - + private _settingsService: SettingsService, + protected workbenchService: WorkbenchService) { this.nullViewInjector = Injector.create({ parent: injector, providers: [ {provide: WorkbenchView, useValue: undefined}, ], }); + + this.form.valueChanges + .pipe(takeUntilDestroyed()) + .subscribe(() => { + this.extras = this.readExtrasFromUI(); + }); } - public onRouterNavigate(): void { + protected onRouterNavigate(): void { this.navigateError = undefined; - const commands: any[] = this.constructNavigationCommands(); - const extras: WorkbenchNavigationExtras = this.constructNavigationExtras(); - - this._wbRouter.navigate(commands, extras) + this._wbRouter.navigate(this.form.controls.commands.value, this.extras) .then(success => success ? Promise.resolve() : Promise.reject('Navigation failed')) - .catch(error => this.navigateError = error); + .then(() => this.resetForm()) + .catch(error => this.navigateError = stringifyError(error)); } - private constructNavigationCommands(): any[] { - const matrixParams: Params | null = SciKeyValueFieldComponent.toDictionary(this.form.controls.matrixParams); - const path = this.form.controls.path.value; - const commands: any[] = path === '' ? [] : path.split('/'); - - // When tokenizing the path into segments, an empty segment is created for the leading slash (if any). - if (path.startsWith('/')) { - commands[0] = '/'; - } - - return commands.concat(matrixParams ? matrixParams : []); + protected onRouterLinkNavigate(): void { + this.resetForm(); } - private constructNavigationExtras(): WorkbenchNavigationExtras { + private readExtrasFromUI(): WorkbenchNavigationExtras { return { queryParams: SciKeyValueFieldComponent.toDictionary(this.form.controls.queryParams), activate: this.form.controls.activate.value, close: this.form.controls.close.value, target: this.form.controls.target.value || undefined, + hint: this.form.controls.hint.value || undefined, blankPartId: this.form.controls.blankPartId.value || undefined, blankInsertionIndex: coerceInsertionIndex(this.form.controls.insertionIndex.value), state: parseTypedObject(SciKeyValueFieldComponent.toDictionary(this.form.controls.state)) ?? undefined, - cssClass: this.form.controls.cssClass.value?.split(/\s+/).filter(Boolean), + cssClass: this.form.controls.cssClass.value, }; } + + private resetForm(): void { + if (this._settingsService.isEnabled('resetFormsOnSubmit')) { + this.form.reset(); + this.form.setControl('queryParams', this._formBuilder.array>([])); + this.form.setControl('state', this._formBuilder.array>([])); + } + } } function coerceInsertionIndex(value: any): number | 'start' | 'end' | undefined { diff --git a/apps/workbench-testing-app/src/app/settings.service.ts b/apps/workbench-testing-app/src/app/settings.service.ts index e391cbca8..603526727 100644 --- a/apps/workbench-testing-app/src/app/settings.service.ts +++ b/apps/workbench-testing-app/src/app/settings.service.ts @@ -55,6 +55,10 @@ export class SettingsService { * Settings of the workbench testing application. */ const SETTINGS = { + resetFormsOnSubmit: { + default: true, + storageKey: 'scion.workbench.testing-app.settings.reset-forms-on-submit', + }, logAngularChangeDetectionCycles: { default: environment.logAngularChangeDetectionCycles, storageKey: 'scion.workbench.testing-app.settings.log-angular-change-detection-cycles', diff --git a/apps/workbench-testing-app/src/app/start-page/start-page.component.html b/apps/workbench-testing-app/src/app/start-page/start-page.component.html index d0193992c..cb4fb71ac 100644 --- a/apps/workbench-testing-app/src/app/start-page/start-page.component.html +++ b/apps/workbench-testing-app/src/app/start-page/start-page.component.html @@ -1,8 +1,8 @@ -Welcome to the internal test app of SCION Workbench and SCION Workbench Client. -We use this app to run our e2e tests and experiment with features. +Welcome to the SCION Workbench Playground. +We use this application to experiment with features and run our end-to-end tests. @@ -10,8 +10,9 @@
+ (click)="onViewOpen(route.path!, $event)" + [ngClass]="route.data![WorkbenchRouteData.cssClass]" + href=""> {{route.data![WorkbenchRouteData.title]}} {{route.data![WorkbenchRouteData.heading]}} diff --git a/apps/workbench-testing-app/src/app/start-page/start-page.component.ts b/apps/workbench-testing-app/src/app/start-page/start-page.component.ts index 94306878a..8442c2ac8 100644 --- a/apps/workbench-testing-app/src/app/start-page/start-page.component.ts +++ b/apps/workbench-testing-app/src/app/start-page/start-page.component.ts @@ -9,10 +9,10 @@ */ import {ChangeDetectionStrategy, ChangeDetectorRef, Component, HostListener, Optional, ViewChild} from '@angular/core'; -import {WorkbenchModuleConfig, WorkbenchRouteData, WorkbenchRouterLinkDirective, WorkbenchView} from '@scion/workbench'; +import {WorkbenchModuleConfig, WorkbenchRouteData, WorkbenchRouter, WorkbenchRouterLinkDirective, WorkbenchView} from '@scion/workbench'; import {Capability, IntentClient, ManifestService} from '@scion/microfrontend-platform'; import {Observable, of} from 'rxjs'; -import {WorkbenchCapabilities, WorkbenchPopupService, WorkbenchRouter, WorkbenchViewCapability} from '@scion/workbench-client'; +import {WorkbenchCapabilities, WorkbenchPopupService, WorkbenchRouter as WorkbenchClientRouter, WorkbenchViewCapability} from '@scion/workbench-client'; import {filterArray, sortArray} from '@scion/toolkit/operators'; import {NavigationEnd, PRIMARY_OUTLET, Route, Router, Routes} from '@angular/router'; import {filter} from 'rxjs/operators'; @@ -59,7 +59,8 @@ export default class StartPageComponent { public WorkbenchRouteData = WorkbenchRouteData; constructor(@Optional() private _view: WorkbenchView, // not available on entry point page - @Optional() private _workbenchClientRouter: WorkbenchRouter, // not available when starting the workbench standalone + @Optional() private _workbenchClientRouter: WorkbenchClientRouter, // not available when starting the workbench standalone + @Optional() private _workbenchRouter: WorkbenchRouter, // not available when starting the workbench standalone @Optional() private _workbenchPopupService: WorkbenchPopupService, // not available when starting the workbench standalone @Optional() private _intentClient: IntentClient, // not available when starting the workbench standalone @Optional() private _manifestService: ManifestService, // not available when starting the workbench standalone @@ -70,8 +71,8 @@ export default class StartPageComponent { // Read workbench views to be pinned to the start page. this.workbenchViewRoutes$ = of(router.config) .pipe(filterArray(route => { - if ((!route.outlet || route.outlet === PRIMARY_OUTLET) && route.data) { - return route.data['pinToStartPage'] === true; + if ((!route.outlet || route.outlet === PRIMARY_OUTLET)) { + return route.data?.['pinToStartPage'] === true; } return false; })); @@ -97,10 +98,18 @@ export default class StartPageComponent { this.installFilterFieldDisplayTextSynchronizer(); } + public onViewOpen(path: string, event: MouseEvent): void { + event.preventDefault(); // Prevent href navigation imposed by accessibility rules + this._workbenchRouter.navigate([path], { + target: event.ctrlKey ? 'blank' : this._view?.id ?? 'blank', + activate: !event.ctrlKey, + }).then(); + } + public onMicrofrontendViewOpen(viewCapability: WorkbenchViewCapability, event: MouseEvent): void { event.preventDefault(); // Prevent href navigation imposed by accessibility rules this._workbenchClientRouter.navigate(viewCapability.qualifier, { - target: event.ctrlKey ? 'blank' : this._view?.id, + target: event.ctrlKey ? 'blank' : this._view?.id ?? 'blank', activate: !event.ctrlKey, }).then(); } diff --git a/apps/workbench-testing-app/src/app/test-pages/angular-router-test-page/angular-router-test-page.component.html b/apps/workbench-testing-app/src/app/test-pages/angular-router-test-page/angular-router-test-page.component.html index b91b8ab04..99a9cd87b 100644 --- a/apps/workbench-testing-app/src/app/test-pages/angular-router-test-page/angular-router-test-page.component.html +++ b/apps/workbench-testing-app/src/app/test-pages/angular-router-test-page/angular-router-test-page.component.html @@ -1,7 +1,7 @@
- + diff --git a/apps/workbench-testing-app/src/app/test-pages/angular-router-test-page/angular-router-test-page.component.ts b/apps/workbench-testing-app/src/app/test-pages/angular-router-test-page/angular-router-test-page.component.ts index b6bc05ffe..87f5c287e 100644 --- a/apps/workbench-testing-app/src/app/test-pages/angular-router-test-page/angular-router-test-page.component.ts +++ b/apps/workbench-testing-app/src/app/test-pages/angular-router-test-page/angular-router-test-page.component.ts @@ -12,7 +12,9 @@ import {Component} from '@angular/core'; import {SciFormFieldComponent} from '@scion/components.internal/form-field'; import {Router} from '@angular/router'; import {NonNullableFormBuilder, ReactiveFormsModule, Validators} from '@angular/forms'; +import {RouterCommandsComponent} from '../../router-commands/router-commands.component'; import {stringifyError} from '../../common/stringify-error.util'; +import {SettingsService} from '../../settings.service'; import {Commands} from '@scion/workbench'; @Component({ @@ -22,32 +24,41 @@ import {Commands} from '@scion/workbench'; standalone: true, imports: [ SciFormFieldComponent, + RouterCommandsComponent, ReactiveFormsModule, ], }) export default class AngularRouterTestPageComponent { public form = this._formBuilder.group({ - path: this._formBuilder.control('', Validators.required), + commands: this._formBuilder.control([], Validators.required), outlet: this._formBuilder.control('', Validators.required), }); public navigateError: string | undefined; - constructor(private _router: Router, private _formBuilder: NonNullableFormBuilder) { + constructor(private _router: Router, + private _settingsService: SettingsService, + private _formBuilder: NonNullableFormBuilder) { } public onNavigate(): void { const commands: Commands = [{ outlets: { - [this.form.controls.outlet.value]: [this.form.controls.path.value], + [this.form.controls.outlet.value]: this.form.controls.commands.value, }, }]; this.navigateError = undefined; this._router.navigate(commands) .then(success => success ? Promise.resolve() : Promise.reject('Navigation failed')) - .then(() => this.form.reset()) + .then(() => this.resetForm()) .catch(error => this.navigateError = stringifyError(error)); } + + private resetForm(): void { + if (this._settingsService.isEnabled('resetFormsOnSubmit')) { + this.form.reset(); + } + } } diff --git a/apps/workbench-testing-app/src/app/test-pages/bulk-navigation-test-page/bulk-navigation-test-page.component.html b/apps/workbench-testing-app/src/app/test-pages/bulk-navigation-test-page/bulk-navigation-test-page.component.html index 2958909b1..dac957f23 100644 --- a/apps/workbench-testing-app/src/app/test-pages/bulk-navigation-test-page/bulk-navigation-test-page.component.html +++ b/apps/workbench-testing-app/src/app/test-pages/bulk-navigation-test-page/bulk-navigation-test-page.component.html @@ -3,8 +3,8 @@ - - + + diff --git a/apps/workbench-testing-app/src/app/test-pages/bulk-navigation-test-page/bulk-navigation-test-page.component.ts b/apps/workbench-testing-app/src/app/test-pages/bulk-navigation-test-page/bulk-navigation-test-page.component.ts index 7e4e84fca..ab1f5760e 100644 --- a/apps/workbench-testing-app/src/app/test-pages/bulk-navigation-test-page/bulk-navigation-test-page.component.ts +++ b/apps/workbench-testing-app/src/app/test-pages/bulk-navigation-test-page/bulk-navigation-test-page.component.ts @@ -12,6 +12,7 @@ import {Component} from '@angular/core'; import {WorkbenchRouter} from '@scion/workbench'; import {NonNullableFormBuilder, ReactiveFormsModule, Validators} from '@angular/forms'; import {SciFormFieldComponent} from '@scion/components.internal/form-field'; +import {CssClassComponent} from '../../css-class/css-class.component'; @Component({ selector: 'app-bulk-navigation-test-page', @@ -21,13 +22,14 @@ import {SciFormFieldComponent} from '@scion/components.internal/form-field'; imports: [ SciFormFieldComponent, ReactiveFormsModule, + CssClassComponent, ], }) export default class BulkNavigationTestPageComponent { public form = this._formBuilder.group({ viewCount: this._formBuilder.control(1, Validators.required), - cssClass: this._formBuilder.control('', Validators.required), + cssClass: this._formBuilder.control(undefined, Validators.required), }); constructor(private _formBuilder: NonNullableFormBuilder, private _router: WorkbenchRouter) { diff --git a/apps/workbench-testing-app/src/app/test-pages/view-move-dialog-test-page/view-move-dialog-test-page.component.ts b/apps/workbench-testing-app/src/app/test-pages/view-move-dialog-test-page/view-move-dialog-test-page.component.ts index 85246913e..84f6e03d1 100644 --- a/apps/workbench-testing-app/src/app/test-pages/view-move-dialog-test-page/view-move-dialog-test-page.component.ts +++ b/apps/workbench-testing-app/src/app/test-pages/view-move-dialog-test-page/view-move-dialog-test-page.component.ts @@ -38,7 +38,7 @@ export class ViewMoveDialogTestPageComponent { constructor(private _formBuilder: NonNullableFormBuilder, private _dialog: WorkbenchDialog) { this._dialog.title = 'Move view'; - this.requirePartIfMovingToPart(); + this.requirePartIfMovingToExistingWindow(); } public onOk(): void { @@ -61,7 +61,7 @@ export class ViewMoveDialogTestPageComponent { /** * Makes the part a required field if not moving the view to a new window. */ - private requirePartIfMovingToPart(): void { + private requirePartIfMovingToExistingWindow(): void { this.form.controls.workbenchId.valueChanges .pipe(takeUntilDestroyed()) .subscribe(target => { diff --git a/apps/workbench-testing-app/src/app/view-info-dialog/view-info-dialog.component.html b/apps/workbench-testing-app/src/app/view-info-dialog/view-info-dialog.component.html index 017b0457e..7cfdec7ad 100644 --- a/apps/workbench-testing-app/src/app/view-info-dialog/view-info-dialog.component.html +++ b/apps/workbench-testing-app/src/app/view-info-dialog/view-info-dialog.component.html @@ -3,6 +3,10 @@ {{view.id}} + + {{view.alternativeId}} + + {{view.part.id}} @@ -15,6 +19,10 @@ {{view.heading}} + + {{view.navigationHint}} + + {{view.urlSegments | appJoin:'/'}} 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 536c8f191..7d09c225e 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 @@ -3,17 +3,25 @@ {{view.id}} - - {{view.part.id}} + + {{view.alternativeId}} - - {{uuid}} + + {{view.part.id}} {{view.urlSegments | appJoin:'/'}} + + + {{view.navigationHint}} + + + + {{uuid}} +
@@ -54,7 +62,7 @@ - +
@@ -73,7 +81,7 @@ diff --git a/apps/workbench-testing-app/src/app/view-page/view-page.component.ts b/apps/workbench-testing-app/src/app/view-page/view-page.component.ts index 0b0a0dfab..a368a7441 100644 --- a/apps/workbench-testing-app/src/app/view-page/view-page.component.ts +++ b/apps/workbench-testing-app/src/app/view-page/view-page.component.ts @@ -25,6 +25,7 @@ import {SciCheckboxComponent} from '@scion/components.internal/checkbox'; import {SciFormFieldComponent} from '@scion/components.internal/form-field'; import {SciAccordionComponent, SciAccordionItemDirective} from '@scion/components.internal/accordion'; import {AppendParamDataTypePipe} from '../common/append-param-data-type.pipe'; +import {CssClassComponent} from '../css-class/css-class.component'; @Component({ selector: 'app-view-page', @@ -47,14 +48,18 @@ import {AppendParamDataTypePipe} from '../common/append-param-data-type.pipe'; NullIfEmptyPipe, JoinPipe, AppendParamDataTypePipe, + CssClassComponent, ], }) export default class ViewPageComponent { public uuid = UUID.randomUUID(); public partActions$: Observable; - public partActionsFormControl = this._formBuilder.control(''); - public cssClassFormControl = this._formBuilder.control(''); + + public formControls = { + partActions: this._formBuilder.control(''), + cssClass: this._formBuilder.control(''), + }; public WorkbenchRouteData = WorkbenchRouteData; @@ -66,7 +71,7 @@ export default class ViewPageComponent { throw Error('[LifecycleError] Component constructed before the workbench startup completed!'); // Do not remove as required by `startup.e2e-spec.ts` in [#1] } - this.partActions$ = this.partActionsFormControl.valueChanges + this.partActions$ = this.formControls.partActions.valueChanges .pipe( map(() => this.parsePartActions()), startWith(this.parsePartActions()), @@ -77,12 +82,12 @@ export default class ViewPageComponent { } private parsePartActions(): WorkbenchPartActionDescriptor[] { - if (!this.partActionsFormControl.value) { + if (!this.formControls.partActions.value) { return []; } try { - return Arrays.coerce(JSON.parse(this.partActionsFormControl.value)); + return Arrays.coerce(JSON.parse(this.formControls.partActions.value)); } catch { return []; @@ -103,10 +108,10 @@ export default class ViewPageComponent { } private installCssClassUpdater(): void { - this.cssClassFormControl.valueChanges + this.formControls.cssClass.valueChanges .pipe(takeUntilDestroyed()) .subscribe(cssClasses => { - this.view.cssClass = cssClasses.split(/\s+/).filter(Boolean); + this.view.cssClass = cssClasses; }); } } diff --git a/apps/workbench-testing-app/src/app/workbench.perspectives.ts b/apps/workbench-testing-app/src/app/workbench.perspectives.ts index 9040e7014..544967bf8 100644 --- a/apps/workbench-testing-app/src/app/workbench.perspectives.ts +++ b/apps/workbench-testing-app/src/app/workbench.perspectives.ts @@ -8,8 +8,8 @@ * SPDX-License-Identifier: EPL-2.0 */ -import {ROUTES} from '@angular/router'; -import {MAIN_AREA, WorkbenchLayout, WorkbenchLayoutFactory, WorkbenchPerspectiveDefinition, WorkbenchRouteData} from '@scion/workbench'; +import {Routes, ROUTES} from '@angular/router'; +import {canMatchWorkbenchView, MAIN_AREA, WorkbenchLayout, WorkbenchLayoutFactory, WorkbenchPerspectiveDefinition, WorkbenchRouteData} from '@scion/workbench'; import {WorkbenchStartupQueryParams} from './workbench/workbench-startup-query-params'; import {EnvironmentProviders, makeEnvironmentProviders} from '@angular/core'; @@ -67,21 +67,21 @@ export const Perspectives = { provide: ROUTES, multi: true, useValue: [ - {path: '', outlet: 'navigator', loadComponent: () => import('./view-page/view-page.component'), data: {[WorkbenchRouteData.title]: 'Navigator'}}, - {path: '', outlet: 'package-explorer', loadComponent: () => import('./view-page/view-page.component'), data: {[WorkbenchRouteData.title]: 'Package Explorer'}}, - {path: '', outlet: 'git-repositories', loadComponent: () => import('./view-page/view-page.component'), data: {[WorkbenchRouteData.title]: 'Git Repositories'}}, - {path: '', outlet: 'console', loadComponent: () => import('./view-page/view-page.component'), data: {[WorkbenchRouteData.title]: 'Console'}}, - {path: '', outlet: 'problems', loadComponent: () => import('./view-page/view-page.component'), data: {[WorkbenchRouteData.title]: 'Problems'}}, - {path: '', outlet: 'search', loadComponent: () => import('./view-page/view-page.component'), data: {[WorkbenchRouteData.title]: 'Search'}}, - {path: '', outlet: 'outline', loadComponent: () => import('./view-page/view-page.component'), data: {[WorkbenchRouteData.title]: 'Outline'}}, - {path: '', outlet: 'debug', loadComponent: () => import('./view-page/view-page.component'), data: {[WorkbenchRouteData.title]: 'Debug'}}, - {path: '', outlet: 'expressions', loadComponent: () => import('./view-page/view-page.component'), data: {[WorkbenchRouteData.title]: 'Expressions'}}, - {path: '', outlet: 'breakpoints', loadComponent: () => import('./view-page/view-page.component'), data: {[WorkbenchRouteData.title]: 'Breakpoints'}}, - {path: '', outlet: 'variables', loadComponent: () => import('./view-page/view-page.component'), data: {[WorkbenchRouteData.title]: 'Variables'}}, - {path: '', outlet: 'servers', loadComponent: () => import('./view-page/view-page.component'), data: {[WorkbenchRouteData.title]: 'Servers'}}, - {path: '', outlet: 'progress', loadComponent: () => import('./view-page/view-page.component'), data: {[WorkbenchRouteData.title]: 'Progress'}}, - {path: '', outlet: 'git-staging', loadComponent: () => import('./view-page/view-page.component'), data: {[WorkbenchRouteData.title]: 'Git Staging'}}, - ], + {path: '', canMatch: [canMatchWorkbenchView('navigator')], loadComponent: () => import('./view-page/view-page.component'), data: {[WorkbenchRouteData.title]: 'Navigator'}}, + {path: '', canMatch: [canMatchWorkbenchView('package-explorer')], loadComponent: () => import('./view-page/view-page.component'), data: {[WorkbenchRouteData.title]: 'Package Explorer'}}, + {path: '', canMatch: [canMatchWorkbenchView('git-repositories')], loadComponent: () => import('./view-page/view-page.component'), data: {[WorkbenchRouteData.title]: 'Git Repositories'}}, + {path: '', canMatch: [canMatchWorkbenchView('console')], loadComponent: () => import('./view-page/view-page.component'), data: {[WorkbenchRouteData.title]: 'Console'}}, + {path: '', canMatch: [canMatchWorkbenchView('problems')], loadComponent: () => import('./view-page/view-page.component'), data: {[WorkbenchRouteData.title]: 'Problems'}}, + {path: '', canMatch: [canMatchWorkbenchView('search')], loadComponent: () => import('./view-page/view-page.component'), data: {[WorkbenchRouteData.title]: 'Search'}}, + {path: '', canMatch: [canMatchWorkbenchView('outline')], loadComponent: () => import('./view-page/view-page.component'), data: {[WorkbenchRouteData.title]: 'Outline'}}, + {path: '', canMatch: [canMatchWorkbenchView('debug')], loadComponent: () => import('./view-page/view-page.component'), data: {[WorkbenchRouteData.title]: 'Debug'}}, + {path: '', canMatch: [canMatchWorkbenchView('expressions')], loadComponent: () => import('./view-page/view-page.component'), data: {[WorkbenchRouteData.title]: 'Expressions'}}, + {path: '', canMatch: [canMatchWorkbenchView('breakpoints')], loadComponent: () => import('./view-page/view-page.component'), data: {[WorkbenchRouteData.title]: 'Breakpoints'}}, + {path: '', canMatch: [canMatchWorkbenchView('variables')], loadComponent: () => import('./view-page/view-page.component'), data: {[WorkbenchRouteData.title]: 'Variables'}}, + {path: '', canMatch: [canMatchWorkbenchView('servers')], loadComponent: () => import('./view-page/view-page.component'), data: {[WorkbenchRouteData.title]: 'Servers'}}, + {path: '', canMatch: [canMatchWorkbenchView('progress')], loadComponent: () => import('./view-page/view-page.component'), data: {[WorkbenchRouteData.title]: 'Progress'}}, + {path: '', canMatch: [canMatchWorkbenchView('git-staging')], loadComponent: () => import('./view-page/view-page.component'), data: {[WorkbenchRouteData.title]: 'Git Staging'}}, + ] satisfies Routes, }, ]); }, @@ -106,6 +106,15 @@ function provideDeveloperPerspectiveLayout(factory: WorkbenchLayoutFactory): Wor .addView('search', {partId: 'bottom'}) .addView('progress', {partId: 'bottom'}) .addView('outline', {partId: 'right'}) + .navigateView('package-explorer', [], {hint: 'package-explorer'}) + .navigateView('navigator', [], {hint: 'navigator'}) + .navigateView('git-repositories', [], {hint: 'git-repositories'}) + .navigateView('problems', [], {hint: 'problems'}) + .navigateView('git-staging', [], {hint: 'git-staging'}) + .navigateView('console', [], {hint: 'console'}) + .navigateView('search', [], {hint: 'search'}) + .navigateView('progress', [], {hint: 'progress'}) + .navigateView('outline', [], {hint: 'outline'}) .activateView('package-explorer') .activateView('git-repositories') .activateView('console') @@ -127,7 +136,14 @@ function provideDebugPerspectiveLayout(factory: WorkbenchLayoutFactory): Workben .addView('variables', {partId: 'right'}) .addView('expressions', {partId: 'right'}) .addView('breakpoints', {partId: 'right'}) - .addPart('findArea', {align: 'right', ratio: .25}) + .navigateView('debug', [], {hint: 'debug'}) + .navigateView('package-explorer', [], {hint: 'package-explorer'}) + .navigateView('servers', [], {hint: 'servers'}) + .navigateView('console', [], {hint: 'console'}) + .navigateView('problems', [], {hint: 'problems'}) + .navigateView('variables', [], {hint: 'variables'}) + .navigateView('expressions', [], {hint: 'expressions'}) + .navigateView('breakpoints', [], {hint: 'breakpoints'}) .activateView('debug') .activateView('console') .activateView('variables'); diff --git a/docs/site/features.md b/docs/site/features.md index f96ecd053..fca95eeef 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) -| 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. +| 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. Only one perspective is 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 | [![][done]](#) | 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/getting-started.md b/docs/site/getting-started.md index fbc70a88a..9703ffd14 100644 --- a/docs/site/getting-started.md +++ b/docs/site/getting-started.md @@ -5,9 +5,9 @@ ## [SCION Workbench][menu-home] > Getting Started -We will create a simple todo list app to introduce you to the SCION Workbench. This short tutorial helps to install the SCION Workbench and explains how to arrange and open views. +We will create a simple TODO app to introduce you to the SCION Workbench. This short tutorial helps to install the SCION Workbench and explains how to arrange and open views. -The application lists todos on the left side. When the user clicks a todo, a new view opens displaying the todo. Different todos open a different view. To open a todo multiple times, the Ctrl key can be pressed. The user can size and arrange views by drag and drop. +The application lists TODOs on the left side. When the user clicks a TODO, a new view opens displaying the TODO. Different TODOs open a different view. To open a TODO multiple times, the Ctrl key can be pressed. The user can size and arrange views by drag and drop. *** - After you complete this guide, the application will look like this: https://scion-workbench-getting-started.vercel.app. @@ -21,7 +21,7 @@ The application lists todos on the left side. When the user clicks a todo, a new Run the following command to create a new Angular application. ```console -ng new workbench-getting-started --routing=false --style=scss --skip-tests +ng new workbench-getting-started --routing=false --style=scss --ssr=false --skip-tests ``` @@ -74,7 +74,7 @@ Open `app.module.ts` and import the `WorkbenchModule` and `BrowserAnimationsModu Open `app.component.html` and change it as follows: ```html - + ``` The workbench itself does not position nor lay out the `` component. Depending on your requirements, you may want the workbench to fill the entire page viewport or only parts of it, for example, if you have a header, footer, or navigation panel. @@ -109,10 +109,10 @@ Also, download the workbench icon font from here. +In this step, we create the TODO list and place it to the left of the main area. We will use the `TodoService` to get some sample TODOs. You can download the `todo.service.ts` file from here. -1. Create a new standalone component using the Angluar CLI. +1. Create a new component using the Angular CLI. ```console - ng generate component todos --standalone --skip-tests + ng generate component todos --skip-tests ``` 2. Open `todos.component.ts` and change it as follows. @@ -207,19 +207,21 @@ In this step, we will create a component to display the todos. We will use the ` In the constructor, we inject the view handle `WorkbenchView`. Using this handle, we can interact with the view, for example, set the title or make the view non-closable. We also inject a reference to the `TodoService` to iterate over the todos in the template. - > Do not forget to export the component by default to simplify route registration. + We also change the component to be exported by default, making it easier to register the route for the component. 3. Open `todos.component.html` and change it as follows: ```html
  1. - {{todo.task}} + {{ todo.task }}
``` - For each todo, we create a link. When the user clicks on a link, a new view with the respective todo will open. In a next step we will create the todo component and register it under the route `/todos/:id`. Note that we are using the `wbRouterLink` and not the `routerLink` directive. The `wbRouterLink` directive is the Workbench equivalent of the Angular Router link, which enables us to target views. + For each TODO, we create a link. When the user clicks on a link, a new view with the TODO will open. In a next step we will create the TODO component and register it under the route `/todos/:id`. + + > Note that we are using the `wbRouterLink` and not the `routerLink` directive. The `wbRouterLink` directive is the Workbench equivalent of the Angular Router link to navigate views. By default, `wbRouterLink` navigates the current view. In this example, however, we want to open the `todo` component in a new view or, if already open, activate it. Therefore, we set the target to `auto`. 4. Register a route in `app.module.ts` for the component. @@ -237,7 +239,7 @@ In this step, we will create a component to display the todos. We will use the ` WorkbenchModule.forRoot(), RouterModule.forRoot([ {path: '', loadComponent: () => import('./welcome/welcome.component')}, - [+] {path: '', outlet: 'todos', loadComponent: () => import('./todos/todos.component')}, + [+] {path: 'todos', loadComponent: () => import('./todos/todos.component')}, ]), BrowserModule, BrowserAnimationsModule, @@ -248,67 +250,57 @@ In this step, we will create a component to display the todos. We will use the ` } ``` - We create an empty path secondary route. The route object for a secondary route has an outlet property. Its value refers to the view in the workbench layout. In our example, we name the outlet `todos`. In the next step, we will add a view named `todos` to the workbench layout. +5. Add the TODO list to the workbench layout. + + Open `app.module.ts` to define the layout of the workbench. We define the layout by registering a layout function in the workbench config. The workbench will invoke this function with a factory to create the layout. - - -
- Display Todos on the Left Side -
- -In this step, we will define a simple workbench layout that displays the todos component as a view on the left to the main area. - -Open `app.module.ts` and pass `WorkbenchModule.forRoot()` a configuration object with the initial workbench layout. - -```ts - import {NgModule} from '@angular/core'; - import {AppComponent} from './app.component'; -[+] import {MAIN_AREA, WorkbenchLayoutFactory, WorkbenchModule} from '@scion/workbench'; - import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; - import {RouterModule} from '@angular/router'; - import {BrowserModule} from '@angular/platform-browser'; - - @NgModule({ - declarations: [AppComponent], - imports: [ - WorkbenchModule.forRoot({ -[+] layout: (factory: WorkbenchLayoutFactory) => factory -[+] .addPart(MAIN_AREA) -[+] .addPart('left', {relativeTo: MAIN_AREA, align: 'left', ratio: .25}) -[+] .addView('todos', {partId: 'left', activateView: true}), - }), - RouterModule.forRoot([ - {path: '', loadComponent: () => import('./welcome/welcome.component')}, - {path: '', outlet: 'todos', loadComponent: () => import('./todos/todos.component')}, - ]), - BrowserModule, - BrowserAnimationsModule, - ], - bootstrap: [AppComponent], - }) - export class AppModule { - } -``` - -We define the initial arrangement of views by specifying a layout function. The function is passed a factory to create the layout. - -> The workbench layout is a grid of parts. Parts are aligned relative to each other. A part is a stack of views. Content is displayed in views. -> The layout can be divided into a main and a peripheral area, with the main area as the primary place for opening views. The peripheral area arranges parts around the main area to provide navigation or context-sensitive assistance to support the user's workflow. Defining a main area is optional and recommended for applications requiring a dedicated and maximizable area for user interaction. - -In this example, we create a layout with two parts, the main area and a part left to it. We name the left part `left` and align it to the left of the main area. We want it to take up 25% of the available space. Next, we add the todos view to the part. We name the view `todos`, the same name we used in the previous step where we created the secondary route for the view. This is how we link a view to a route. - -Open a browser to http://localhost:4200. You should see the todo list left to the main area. However, when you click on a todo, you will get an error because we have not registered the route yet. + ```ts + import {NgModule} from '@angular/core'; + import {AppComponent} from './app.component'; + [+] import {MAIN_AREA, WorkbenchLayoutFactory, WorkbenchModule} from '@scion/workbench'; + import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; + import {RouterModule} from '@angular/router'; + import {BrowserModule} from '@angular/platform-browser'; + + @NgModule({ + declarations: [AppComponent], + imports: [ + WorkbenchModule.forRoot({ + [+] layout: (factory: WorkbenchLayoutFactory) => factory + [+] .addPart(MAIN_AREA) + [+] .addPart('left', {relativeTo: MAIN_AREA, align: 'left', ratio: .25}) + [+] .addView('todos', {partId: 'left', activateView: true}) + [+] .navigateView('todos', ['todos']), + }), + RouterModule.forRoot([ + {path: '', loadComponent: () => import('./welcome/welcome.component')}, + {path: 'todos', loadComponent: () => import('./todos/todos.component')}, + ]), + BrowserModule, + BrowserAnimationsModule, + ], + bootstrap: [AppComponent], + }) + export class AppModule { + } + ``` + + In the above code snippet, we create a layout with two parts, the main area and a part left to it. We align the `left` part to the left of the main area. We want it to take up 25% of the available space. Next, we add the `todos` view to the left part. Finally, we navigate the `todos` view to the `todos` component. + + For detailed explanations on defining a workbench layout, refer to [Defining the initial workbench layout][link-how-to-define-initial-workbench-layout]. + + Open a browser to http://localhost:4200. You should see the TODO list left to the main area.
Create Todo Component
-In this step, we will create a component to open a todo in a view. +In this step, we will create a component to open a TODO in a view. -1. Create a new standalone component using the Angluar CLI. +1. Create a new component using the Angular CLI. ```console - ng generate component todo --standalone --skip-tests + ng generate component todo --skip-tests ``` 2. Open `todo.component.ts` and change it as follows. @@ -347,11 +339,11 @@ In this step, we will create a component to open a todo in a view. } ``` - As with the todo list component, we change the component to be exported by default, making it easier to register the route for the component. + As with the TODO list component, we change the component to be exported by default, making it easier to register the route for the component. - In the constructor, we inject the `ActivatedRoute` to read the id of the todo that we want to display in the view. We also inject the `TodoService` to look up the todo. As a side effect, after looking up the todo, we set the title and heading of the view. + In the constructor, we inject the `ActivatedRoute` to read the id of the TODO that we want to display in the view. We also inject the `TodoService` to look up the TODO. As a side effect, after looking up the TODO, we set the title and heading of the view. - In the next step, we will subscribe to the observable in the template. + In the next step, we will subscribe to the observable in the template. 3. Open `todo.component.html` and change it as follows. @@ -362,11 +354,11 @@ In this step, we will create a component to open a todo in a view. Notes:{{todo.notes}} ``` - Using Angular's `async` pipe, we subscribe to the `todo$` observable and assign its emitted value to the template variable `todo`. Then, we render the todo. + Using Angular's `async` pipe, we subscribe to the `todo$` observable and assign its emitted value to the template variable `todo`. Then, we render the TODO. -4. Open `todo.component.scss` and add the following content. +4. Open `todo.component.scss` and add the following styles. - Next, we add some CSS to get a tabular presentation of the todo. + Next, we add some CSS to get a tabular presentation of the TODO. ```css :host { @@ -380,7 +372,7 @@ In this step, we will create a component to open a todo in a view. 5. Register a route in `app.module.ts` for the component. - Finally, we need to register a route for the component. Unlike the todo list component, we do not create a secondary route, but a primary route with a path, in our example `todos/:id`. We can then navigate to this component in a view using the `WorkbenchRouter` or `wbRouterLink`. + Finally, we need to register a route for the component. We can then navigate to this component in a view using the `WorkbenchRouter` or `wbRouterLink`. ```ts import {NgModule} from '@angular/core'; @@ -397,11 +389,12 @@ In this step, we will create a component to open a todo in a view. layout: (factory: WorkbenchLayoutFactory) => factory .addPart(MAIN_AREA) .addPart('left', {relativeTo: MAIN_AREA, align: 'left', ratio: .25}) - .addView('todos', {partId: 'left', activateView: true}), + .addView('todos', {partId: 'left', activateView: true}) + .navigateView('todos', ['todos']), }), RouterModule.forRoot([ {path: '', loadComponent: () => import('./welcome/welcome.component')}, - {path: '', outlet: 'todos', loadComponent: () => import('./todos/todos.component')}, + {path: 'todos', loadComponent: () => import('./todos/todos.component')}, [+] {path: 'todos/:id', loadComponent: () => import('./todo/todo.component')}, ]), BrowserModule, @@ -413,16 +406,16 @@ In this step, we will create a component to open a todo in a view. } ``` - Below the code from the previous step how we open the todo view using the `wbRouterLink` directive. - ```html -
    -
  1. - {{todo.task}} -
  2. -
- ``` + Below the code from the previous step how we open the TODO view using the `wbRouterLink` directive. + ```html +
    +
  1. + {{ todo.task }} +
  2. +
+ ``` - Open a browser to http://localhost:4200. You should see the todo list left to the main area. When you click on a todo, a new view opens displaying the todo. Different todos open a different view. To open a todo multiple times, also press the Ctrl key. + Open a browser to http://localhost:4200. You should see the TODO list left to the main area. When you click on a TODO, a new view opens displaying the TODO. Different TODOs open a different view. To open a TODO multiple times, also press the Ctrl key.
@@ -434,6 +427,8 @@ This short guide has introduced you to the basics of SCION Workbench. For more a +[link-how-to-define-initial-workbench-layout]: /docs/site/howto/how-to-define-initial-layout.md + [menu-home]: /README.md [menu-projects-overview]: /docs/site/projects-overview.md [menu-changelog]: /docs/site/changelog.md diff --git a/docs/site/howto/how-to-close-view.md b/docs/site/howto/how-to-close-view.md index cd8e06c43..ba2d58cfa 100644 --- a/docs/site/howto/how-to-close-view.md +++ b/docs/site/howto/how-to-close-view.md @@ -13,6 +13,9 @@ A view can be closed via the view's handle `WorkbenchView`, the `WorkbenchServic Inject `WorkbenchView` handle and invoke the `close` method. ```ts +import {inject} from '@angular/core'; +import {WorkbenchView} from '@scion/workbench'; + inject(WorkbenchView).close(); ``` @@ -21,6 +24,9 @@ Inject `WorkbenchService` and invoke `closeViews`, passing the ids of the views ```ts +import {inject} from '@angular/core'; +import {WorkbenchService} from '@scion/workbench'; + inject(WorkbenchService).closeViews('view.1', 'view.2'); ``` @@ -31,6 +37,9 @@ The router supports for closing views matching the routing commands by setting ` Matrix parameters do not affect view resolution. The path supports the asterisk wildcard segment (`*`) to match views with any value in a segment. To close a specific view, set a view target instead of a path. ```ts +import {inject} from '@angular/core'; +import {WorkbenchRouter} from '@scion/workbench'; + inject(WorkbenchRouter).navigate(['path/*/view'], {close: true}); ``` diff --git a/docs/site/howto/how-to-configure-start-page.md b/docs/site/howto/how-to-configure-start-page.md index 40497de02..1fb258d76 100644 --- a/docs/site/howto/how-to-configure-start-page.md +++ b/docs/site/howto/how-to-configure-start-page.md @@ -11,6 +11,8 @@ A start page can be used to display content when all views are closed. To display a start page, register an empty path route, as follows: ```ts +import {RouterModule} from '@angular/router'; + RouterModule.forRoot([ {path: '', loadComponent: () => import('./start-page/start-page.component')}, ]); @@ -21,6 +23,10 @@ RouterModule.forRoot([ If working with perspectives, configure a different start page per perspective by testing for the active perspective in the `canMatch` route handler. ```ts +import {RouterModule} from '@angular/router'; +import {inject} from '@angular/core'; +import {WorkbenchService} from '@scion/workbench'; + RouterModule.forRoot([ // Match this route only if 'perspective A' is active. { diff --git a/docs/site/howto/how-to-define-initial-layout.md b/docs/site/howto/how-to-define-initial-layout.md index f2ec42a94..72c5cfffa 100644 --- a/docs/site/howto/how-to-define-initial-layout.md +++ b/docs/site/howto/how-to-define-initial-layout.md @@ -7,39 +7,60 @@ The workbench layout is a grid of parts. Parts are aligned relative to each other. A part is a stack of views. Content is displayed in views. -The layout can be divided into a main and a peripheral area, with the main area as the primary place for opening views. The peripheral area arranges parts around the main area to provide navigation or context-sensitive assistance to support the user's workflow. Defining a main area is optional and recommended for applications requiring a dedicated and maximizable area for user interaction. +The workbench layout can be divided into a main and a peripheral area, with the main area as the primary place for opening views. The peripheral area arranges parts around the main area to provide navigation or context-sensitive assistance to support the user's workflow. Defining a main area is optional and recommended for applications requiring a dedicated and maximizable area for user interaction. -### How to define an initial layout +### How to define the initial workbench layout -Arranging views in the workbench layout requires two steps. +Define the workbench layout by registering a layout function in the workbench config. The workbench will invoke this function with a factory to create the layout. The layout is immutable, so each modification creates a new instance. Use the instance for further modifications and finally return it. -
- 1. Define the layout via workbench config -
+Start by adding the first part. From there, you can gradually add more parts and align them relative to each other. Next, add views to the layout, specifying to which part to add the views. The final step is to navigate the views. A view can be navigated to any route. ```ts -import {MAIN_AREA, WorkbenchLayoutFactory, WorkbenchModule} from '@scion/workbench'; - -WorkbenchModule.forRoot({ - layout: (factory: WorkbenchLayoutFactory) => factory - .addPart(MAIN_AREA) - .addPart('topLeft', {relativeTo: MAIN_AREA, align: 'left', ratio: .25}) - .addPart('bottomLeft', {relativeTo: 'topLeft', align: 'bottom', ratio: .5}) - .addPart('bottom', {align: 'bottom', ratio: .3}) - .addView('navigator', {partId: 'topLeft', activateView: true}) - .addView('explorer', {partId: 'topLeft'}) - .addView('console', {partId: 'bottom', activateView: true}) - .addView('problems', {partId: 'bottom'}) - .addView('search', {partId: 'bottom'}) +import {bootstrapApplication} from '@angular/platform-browser'; +import {MAIN_AREA, provideWorkbench, WorkbenchLayoutFactory} from '@scion/workbench'; + +bootstrapApplication(AppComponent, { + providers: [ + provideWorkbench({ + layout: (factory: WorkbenchLayoutFactory) => factory + // Add parts to the layout. + .addPart(MAIN_AREA) + .addPart('topLeft', {relativeTo: MAIN_AREA, align: 'left', ratio: .25}) + .addPart('bottomLeft', {relativeTo: 'topLeft', align: 'bottom', ratio: .5}) + .addPart('bottom', {align: 'bottom', ratio: .3}) + + // Add views to the layout. + .addView('navigator', {partId: 'topLeft'}) + .addView('explorer', {partId: 'bottomLeft'}) + .addView('console', {partId: 'bottom'}) + .addView('problems', {partId: 'bottom'}) + .addView('search', {partId: 'bottom'}) + + // Navigate views. + .navigateView('navigator', ['path/to/navigator']) + .navigateView('explorer', ['path/to/explorer']) + .navigateView('console', [], {hint: 'console'}) // Set hint to differentiate between routes with an empty path. + .navigateView('problems', [], {hint: 'problems'}) // Set hint to differentiate between routes with an empty path. + .navigateView('search', ['path/to/search']) + + // Decide which views to activate. + .activateView('navigator') + .activateView('explorer') + .activateView('console'), + }), + ], }); ``` + +> The layout function can call `inject` to get any required dependencies. + The above code snippet defines the following layout. ```plain +--------+----------------+ -| top | main area | +| top | | | left | | -|--------+ | +|--------+ main area | | bottom | | | left | | +--------+----------------+ @@ -47,26 +68,36 @@ The above code snippet defines the following layout. +-------------------------+ ``` -A layout is defined through a layout function in the workbench config. The function is passed a factory to create the layout. The layout has methods to modify it. Each modification creates a new layout instance that can be used for further modifications. - -> The function can call `inject` to get required dependencies, if any. -
- -
- 2. Register the routes for views added to the layout -
+The above layout requires the following routes. ```ts -RouterModule.forRoot([ - {path: '', outlet: 'navigator', loadComponent: () => import('./navigator/navigator.component')}, - {path: '', outlet: 'explorer', loadComponent: () => import('./explorer/explorer.component')}, - {path: '', outlet: 'console', loadComponent: () => import('./console/console.component')}, - {path: '', outlet: 'problems', loadComponent: () => import('./problems/problems.component')}, - {path: '', outlet: 'search', loadComponent: () => import('./search/search.component')}, -]); +import {bootstrapApplication} from '@angular/platform-browser'; +import {provideRouter} from '@angular/router'; +import {canMatchWorkbenchView} from '@scion/workbench'; + +bootstrapApplication(AppComponent, { + providers: [ + provideRouter([ + // Navigator View + {path: 'path/to/navigator', loadComponent: () => import('./navigator/navigator.component')}, + // Explorer View + {path: 'path/to/explorer', loadComponent: () => import('./explorer/explorer.component')}, + // Search View + {path: 'path/to/search', loadComponent: () => import('./search/search.component')}, + // Console View + {path: '', canMatch: [canMatchWorkbenchView('console')], loadComponent: () => import('./console/console.component')}, + // Problems View + {path: '', canMatch: [canMatchWorkbenchView('problems')], loadComponent: () => import('./problems/problems.component')}, + ]), + ], +}); ``` -A route for a view in the initial layout must be a secondary route with an empty path. The outlet refers to the view in the layout. Because the path is empty, no outlet needs to be added to the URL. -
+ +> To avoid cluttering the initial URL, we recommend navigating the views of the initial layout to empty path routes and using a navigation hint to differentiate. + +> Use the `canMatchWorkbenchView` guard to match a route only when navigating a view with a particular hint. + +> Use the `canMatchWorkbenchView` guard and pass `false` to never match a route for a workbench view, e.g., to exclude the application root path, if any, necessary when navigating views to the empty path route. [menu-how-to]: /docs/site/howto/how-to.md diff --git a/docs/site/howto/how-to-install-workbench.md b/docs/site/howto/how-to-install-workbench.md index 218793b9c..42b698bb1 100644 --- a/docs/site/howto/how-to-install-workbench.md +++ b/docs/site/howto/how-to-install-workbench.md @@ -57,7 +57,7 @@ Open `app.module.ts` and import the `WorkbenchModule`. The lines to be added are Open `app.component.html` and replace it with the following content: ```html - + ``` The workbench itself does not position nor lay out the `` component. Depending on your requirements, you may want the workbench to fill the entire page viewport or only parts of it, for example, if you have a header, footer, or navigation panel. @@ -89,9 +89,9 @@ Also, download the workbench icon font from Link ``` + *** #### Related Links: - [Learn how to provide a view.][link-how-to-provide-view] -- [Learn how to define an initial layout.][link-how-to-define-initial-layout] +- [Learn how to define the initial workbench layout.][link-how-to-define-initial-workbench-layout] *** [link-how-to-provide-view]: /docs/site/howto/how-to-provide-view.md -[link-how-to-define-initial-layout]: /docs/site/howto/how-to-define-initial-layout.md +[link-how-to-define-initial-workbench-layout]: /docs/site/howto/how-to-define-initial-layout.md [menu-how-to]: /docs/site/howto/how-to.md [menu-home]: /README.md diff --git a/docs/site/howto/how-to-prevent-view-closing.md b/docs/site/howto/how-to-prevent-view-closing.md index 8b6e9f665..f0f7370a7 100644 --- a/docs/site/howto/how-to-prevent-view-closing.md +++ b/docs/site/howto/how-to-prevent-view-closing.md @@ -12,10 +12,13 @@ The closing of a view can be intercepted by implementing the `WorkbenchViewPreDe The following snippet asks the user whether to save changes. ```ts +import {Component} from '@angular/core'; +import {WorkbenchMessageBoxService, WorkbenchView, WorkbenchViewPreDestroy} from '@scion/workbench'; + @Component({}) export class ViewComponent implements WorkbenchViewPreDestroy { - constructor(private view: WorkbenchView, private messageBoxService: MessageBoxService) { + constructor(private view: WorkbenchView, private messageBoxService: WorkbenchMessageBoxService) { } public async onWorkbenchViewPreDestroy(): Promise { @@ -23,9 +26,7 @@ export class ViewComponent implements WorkbenchViewPreDestroy { return true; } - const messageBoxService = inject(MessageBoxService); - const action = await messageBoxService.open({ - content: 'Do you want to save changes?', + const action = await this.messageBoxService.open('Do you want to save changes?', { severity: 'info', actions: { yes: 'Yes', diff --git a/docs/site/howto/how-to-open-initial-view.md b/docs/site/howto/how-to-provide-not-found-page.md similarity index 52% rename from docs/site/howto/how-to-open-initial-view.md rename to docs/site/howto/how-to-provide-not-found-page.md index e0fc2d456..f2036a8e3 100644 --- a/docs/site/howto/how-to-open-initial-view.md +++ b/docs/site/howto/how-to-provide-not-found-page.md @@ -5,23 +5,17 @@ ## [SCION Workbench][menu-home] > [How To Guides][menu-how-to] > View -### How to open an initial view in the main area -The workbench supports listening for the opened views. If the number of views is zero or drops to zero, perform a navigation to open the initial view. Also, consider configuring the initial view as non-closable. +The workbench displays a "Not Found Page" if no route matches the requested URL. This happens when navigating to a route that does not exist or when loading the application, and the routes have changed since the last use. + +The built-in "Not Found Page" can be replaced as follows, e.g., to localize the page. ```ts -@Component({selector: 'app-root'}) -export class AppComponent { - - constructor(workbenchService: WorkbenchService, wbRouter: WorkbenchRouter) { - workbenchService.views$ - .pipe(takeUntilDestroyed()) - .subscribe(views => { - if (views.length === 0) { - wbRouter.navigate(['path/to/view']); - } - }); - } -} +import {WorkbenchModule} from '@scion/workbench'; + +WorkbenchModule.forRoot({ + // Register custom "Not Found Page". + pageNotFoundComponent: YourPageNotFoundComponent, +}); ``` [menu-how-to]: /docs/site/howto/how-to.md diff --git a/docs/site/howto/how-to-provide-part-action.md b/docs/site/howto/how-to-provide-part-action.md index b04a92eef..b3915f32e 100644 --- a/docs/site/howto/how-to-provide-part-action.md +++ b/docs/site/howto/how-to-provide-part-action.md @@ -32,9 +32,11 @@ Add a `` to an HTML template and decorate it with the `wbPartAction As an alternative to modeling an action in HTML templates, actions can be contributed programmatically using the `WorkbenchService.registerPartAction` method. The content is specified in the form of a CDK portal, i.e., a component portal or a template portal. ```ts -const workbenchService = inject(WorkbenchService); +import {inject} from '@angular/core'; +import {WorkbenchService} from '@scion/workbench'; +import {ComponentPortal} from '@angular/cdk/portal'; -workbenchService.registerPartAction({ +inject(WorkbenchService).registerPartAction({ portal: new ComponentPortal(YourComponent), align: 'end', }); @@ -56,6 +58,9 @@ The action can be configured with a `canMatch` function to match a specific part The following function contributes the action only to parts in the perspective 'MyPerspective' located in the main area. ```ts +import {inject} from '@angular/core'; +import {CanMatchPartFn, WorkbenchPart, WorkbenchService} from '@scion/workbench'; + public canMatch: CanMatchPartFn = (part: WorkbenchPart): boolean => { if (!inject(WorkbenchService).getPerspective('MyPerspective')?.active) { return false; diff --git a/docs/site/howto/how-to-provide-perspective.md b/docs/site/howto/how-to-provide-perspective.md index 894eb38a7..36868bbe1 100644 --- a/docs/site/howto/how-to-provide-perspective.md +++ b/docs/site/howto/how-to-provide-perspective.md @@ -7,15 +7,15 @@ A perspective is a named workbench layout. Multiple perspectives are supported. Perspectives can be switched. Only one perspective is active at a time. Perspectives share the same main area, if any. +The workbench layout is a grid of parts. Parts are aligned relative to each other. A part is a stack of views. Content is displayed in views. + ### How to provide a perspective -Providing a perspective requires two steps. +Perspectives are registered similarly to [Defining the initial workbench layout][link-how-to-define-initial-workbench-layout] via the configuration passed to `WorkbenchModule.forRoot()`. However, an array of perspective definitions is passed instead of a single workbench layout. A perspective must have a unique identity and define a workbench layout. Optionally, data can be associated with the perspective via data dictionary, e.g., to associate an icon, label or tooltip with the perspective. -
- 1. Register the perspective via workbench config -
+Define the perspective's layout by registering a layout function in the perspective definition. The workbench will invoke this function with a factory to create the layout. The layout is immutable, so each modification creates a new instance. Use the instance for further modifications and finally return it. -Perspectives are registered similarly to [Defining an initial layout][link-how-to-define-initial-layout] via the configuration passed to `WorkbenchModule.forRoot()`. However, an array of perspective definitions is passed instead of a single layout. A perspective must have a unique identity and define a layout. Optionally, data can be associated with the perspective via data dictionary, e.g., to associate an icon, label or tooltip with the perspective. +Start by adding the first part. From there, you can gradually add more parts and align them relative to each other. Next, add views to the layout, specifying to which part to add the views. The final step is to navigate the views. A view can be navigated to any route. ```ts import {MAIN_AREA, WorkbenchLayoutFactory, WorkbenchModule} from '@scion/workbench'; @@ -26,16 +26,32 @@ WorkbenchModule.forRoot({ { id: 'admin', layout: (factory: WorkbenchLayoutFactory) => factory - .addPart(MAIN_AREA) - .addPart('topLeft', {relativeTo: MAIN_AREA, align: 'left', ratio: .25}) - .addPart('bottomLeft', {relativeTo: 'topLeft', align: 'bottom', ratio: .5}) - .addPart('bottom', {align: 'bottom', ratio: .3}) - .addView('navigator', {partId: 'topLeft', activateView: true}) - .addView('explorer', {partId: 'topLeft'}) - .addView('outline', {partId: 'bottomLeft', activateView: true}) - .addView('console', {partId: 'bottom', activateView: true}) - .addView('problems', {partId: 'bottom'}) - .addView('search', {partId: 'bottom'}), + // Add parts to the layout. + .addPart(MAIN_AREA) + .addPart('topLeft', {relativeTo: MAIN_AREA, align: 'left', ratio: .25}) + .addPart('bottomLeft', {relativeTo: 'topLeft', align: 'bottom', ratio: .5}) + .addPart('bottom', {align: 'bottom', ratio: .3}) + + // Add views to the layout. + .addView('navigator', {partId: 'topLeft'}) + .addView('explorer', {partId: 'topLeft'}) + .addView('outline', {partId: 'bottomLeft'}) + .addView('console', {partId: 'bottom'}) + .addView('problems', {partId: 'bottom'}) + .addView('search', {partId: 'bottom'}) + + // Navigate views. + .navigateView('navigator', ['path/to/navigator']) + .navigateView('explorer', ['path/to/explorer']) + .navigateView('outline', [], {hint: 'outline'}) // Set hint to differentiate between routes with an empty path. + .navigateView('console', [], {hint: 'console'}) // Set hint to differentiate between routes with an empty path. + .navigateView('problems', [], {hint: 'problems'}) // Set hint to differentiate between routes with an empty path. + .navigateView('search', ['path/to/search']) + + // Decide which views to activate. + .activateView('navigator') + .activateView('outline') + .activateView('console'), data: { label: 'Administrator', }, @@ -43,12 +59,24 @@ WorkbenchModule.forRoot({ { id: 'manager', layout: (factory: WorkbenchLayoutFactory) => factory - .addPart(MAIN_AREA) - .addPart('bottom', {relativeTo: MAIN_AREA, align: 'bottom', ratio: .3}) - .addView('explorer', {partId: 'bottom', activateView: true}) - .addView('navigator', {partId: 'bottom'}) - .addView('outline', {partId: 'bottom'}) - .addView('search', {partId: 'bottom'}), + // Add parts to the layout. + .addPart(MAIN_AREA) + .addPart('bottom', {relativeTo: MAIN_AREA, align: 'bottom', ratio: .3}) + + // Add views to the layout. + .addView('navigator', {partId: 'bottom'}) + .addView('explorer', {partId: 'bottom'}) + .addView('outline', {partId: 'bottom'}) + .addView('search', {partId: 'bottom'}) + + // Navigate views. + .navigateView('navigator', ['path/to/navigator']) + .navigateView('explorer', ['path/to/explorer']) + .navigateView('outline', [], {hint: 'outline'}) // Set hint to differentiate between routes with an empty path. + .navigateView('search', ['path/to/search']) + + // Decide which views to activate. + .activateView('explorer'), data: { label: 'Manager', }, @@ -59,13 +87,19 @@ WorkbenchModule.forRoot({ }); ``` -The perspective 'admin' defines the following layout. +> The layout function can call `inject` to get any required dependencies. + +> A `canActivate` function can be configured to determine if the perspective can be activated, for example based on the user's permissions. + +> The initial perspective can be set via `initialPerspective` property which accepts a string literal or a function for more advanced use cases. + +The perspective `admin` defines the following layout. ```plain +--------+----------------+ -| top | main area | +| top | | | left | | -|--------+ | +|--------+ main area | | bottom | | | left | | +--------+----------------+ @@ -73,48 +107,52 @@ The perspective 'admin' defines the following layout. +-------------------------+ ``` -The perspective 'manager' defines the following layout. +The perspective `manager` defines the following layout. ```plain +-------------------------+ -| main area | | | +| main area | | | +-------------------------+ | bottom | +-------------------------+ ``` -The workbench layout is a grid of parts. Parts are aligned relative to each other. A part is a stack of views. Content is displayed in views. - -A layout is defined through a layout function in the workbench config. The function is passed a factory to create the layout. The layout has methods to modify it. Each modification creates a new layout instance that can be used for further modifications. - -> The function can call `inject` to get required dependencies, if any. - -Optionally, a `canActivate` function can be configured with a perspective descriptor to determine whether the perspective can be activated, for example based on the user's permissions. The initial activated perspective can be set via `initialPerspective` property which accepts a string literal or a function for more advanced use cases. -
- -
- 2. Register the routes for views added to the perspectives -
+The above perspectives require the following routes. -Routes for views added to the perspective layouts must be registered via the router module, as follows: - ```ts -RouterModule.forRoot([ - {path: '', outlet: 'navigator', loadComponent: () => import('./navigator/navigator.component')}, - {path: '', outlet: 'explorer', loadComponent: () => import('./explorer/explorer.component')}, - {path: '', outlet: 'outline', loadComponent: () => import('./outline/outline.component')}, - {path: '', outlet: 'console', loadComponent: () => import('./console/console.component')}, - {path: '', outlet: 'problems', loadComponent: () => import('./problems/problems.component')}, - {path: '', outlet: 'search', loadComponent: () => import('./search/search.component')}, -]); +import {bootstrapApplication} from '@angular/platform-browser'; +import {provideRouter} from '@angular/router'; +import {canMatchWorkbenchView} from '@scion/workbench'; + +bootstrapApplication(AppComponent, { + providers: [ + provideRouter([ + // Navigator View + {path: 'path/to/navigator', loadComponent: () => import('./navigator/navigator.component')}, + // Explorer View + {path: 'path/to/explorer', loadComponent: () => import('./explorer/explorer.component')}, + // Outline View + {path: '', canMatch: [canMatchWorkbenchView('outline')], loadComponent: () => import('./outline/outline.component')}, + // Console View + {path: '', canMatch: [canMatchWorkbenchView('console')], loadComponent: () => import('./console/console.component')}, + // Problems View + {path: '', canMatch: [canMatchWorkbenchView('problems')], loadComponent: () => import('./problems/problems.component')}, + // Search View + {path: 'path/to/search', loadComponent: () => import('./search/search.component')}, + ]), + ], +}); ``` -A route for a view in the perspective layout must be a secondary route with an empty path. The outlet refers to the view in the layout. Because the path is empty, no outlet needs to be added to the URL. -
+> To avoid cluttering the initial URL, we recommend navigating the views of a perspective to empty path routes and using a navigation hint to differentiate. + +> Use the `canMatchWorkbenchView` guard to match a route only when navigating a view with a particular hint. + +> Use the `canMatchWorkbenchView` guard and pass `false` to never match a route for a workbench view, e.g., to exclude the application root path, if any, necessary when navigating views to the empty path route. -[link-how-to-define-initial-layout]: /docs/site/howto/how-to-define-initial-layout.md +[link-how-to-define-initial-workbench-layout]: /docs/site/howto/how-to-define-initial-layout.md [menu-how-to]: /docs/site/howto/how-to.md diff --git a/docs/site/howto/how-to-provide-view.md b/docs/site/howto/how-to-provide-view.md index 47e0d7d0c..84fdb8a2e 100644 --- a/docs/site/howto/how-to-provide-view.md +++ b/docs/site/howto/how-to-provide-view.md @@ -9,6 +9,8 @@ Any component can be displayed as a view. A view is a regular Angular component associated with a route. Below are some examples of common route configurations. ```ts +import {RouterModule} from '@angular/router'; + // Routes RouterModule.forRoot([ {path: 'path/to/view1', component: ViewComponent}, @@ -18,6 +20,8 @@ RouterModule.forRoot([ ``` ```ts +import {Routes} from '@angular/router'; + // file: routes.ts export default [ {path: 'view3', component: ViewComponent}, @@ -28,6 +32,9 @@ export default [ Having defined the routes, views can be opened using the `WorkbenchRouter`. ```ts +import {WorkbenchRouter} from '@scion/workbench'; +import {inject} from '@angular/core'; + // Open view 1 inject(WorkbenchRouter).navigate(['/path/to/view1']); @@ -41,30 +48,36 @@ inject(WorkbenchRouter).navigate(['/path/to/views/view3']); inject(WorkbenchRouter).navigate(['/path/to/views/view4']); ``` -The workbench supports associating view-specific data with a route, such as a tile, a heading, or a CSS class. Alternatively, this data can be set in the view by injecting the view handle `WorkbenchView`. +The workbench supports associating view-specific data with a route, such as a tile, a heading, or a CSS class. ```ts -RouterModule.forRoot([ +import {provideRouter} from '@angular/router'; +import {WorkbenchRouteData} from '@scion/workbench'; + +provideRouter([ { path: 'path/to/view', loadComponent: () => import('./view/view.component'), data: { [WorkbenchRouteData.title]: 'View Title', [WorkbenchRouteData.heading]: 'View Heading', - [WorkbenchRouteData.cssClass]: 'e2e-view', + [WorkbenchRouteData.cssClass]: ['class 1', 'class 2'], }, }, -]) +]); ``` +Alternatively, the above data can be set in the view by injecting the view handle `WorkbenchView`. See [How to interact with a view][how-to-interact-with-view]. + *** #### Related Links: - [Learn how to open a view.][link-how-to-open-view] -- [Learn how to define an initial layout.][link-how-to-define-initial-layout] +- [Learn how to define the initial workbench layout.][link-how-to-define-initial-workbench-layout] *** [link-how-to-open-view]: /docs/site/howto/how-to-open-view.md -[link-how-to-define-initial-layout]: /docs/site/howto/how-to-define-initial-layout.md +[link-how-to-define-initial-workbench-layout]: /docs/site/howto/how-to-define-initial-layout.md +[how-to-interact-with-view]: /docs/site/howto/how-to-interact-with-view.md [menu-how-to]: /docs/site/howto/how-to.md [menu-home]: /README.md diff --git a/docs/site/howto/how-to-show-notification.md b/docs/site/howto/how-to-show-notification.md index 54e64eae2..4086e8eea 100644 --- a/docs/site/howto/how-to-show-notification.md +++ b/docs/site/howto/how-to-show-notification.md @@ -11,6 +11,9 @@ A notification is a closable message that appears in the upper-right corner and To show a notification, inject `NotificationService` and invoke the `notify` method, passing a `Notification` options object to control the appearance of the notification, like its severity, its content and show duration. ```ts +import {inject} from '@angular/core'; +import {NotificationService} from '@scion/workbench'; + const notificationService = inject(NotificationService); notificationService.notify({ content: 'Person successfully created', diff --git a/docs/site/howto/how-to.md b/docs/site/howto/how-to.md index e82d9afda..61199bb8f 100644 --- a/docs/site/howto/how-to.md +++ b/docs/site/howto/how-to.md @@ -15,19 +15,19 @@ We are working on a comprehensive guide that explains the features and concepts - [How to install the SCION Workbench](how-to-install-workbench.md) #### Layout -- [How to define an initial layout](how-to-define-initial-layout.md) +- [How to define the initial workbench layout](how-to-define-initial-layout.md) - [How to provide a perspective](how-to-provide-perspective.md) +- [How to work with perspectives](how-to-perspective.md) - [How to configure a start page](how-to-configure-start-page.md) -- [How to query, switch and reset perspectives](how-to-perspective.md) #### View - [How to provide a view](how-to-provide-view.md) - [How to open a view](how-to-open-view.md) -- [How to open an initial view in the main area](how-to-open-initial-view.md) -- [How to set a view title](how-to-set-view-title.md) +- [How to interact with a view](how-to-interact-with-view.md) - [How to close a view](how-to-close-view.md) - [How to prevent a view from being closed](how-to-prevent-view-closing.md) - [How to provide a part action](how-to-provide-part-action.md) +- [How to provide a "Not Found Page"](how-to-provide-not-found-page.md) #### Dialog, Popup, Messagebox, Notification - [How to open a dialog](how-to-open-dialog.md) diff --git a/docs/site/overview.md b/docs/site/overview.md index a050ebb40..d66ca14c1 100644 --- a/docs/site/overview.md +++ b/docs/site/overview.md @@ -11,19 +11,21 @@ The workbench layout is a grid of parts. Parts are aligned relative to each othe The layout can be divided into a main and a peripheral area, with the main area as the primary place for opening views. The peripheral area arranges parts around the main area to provide navigation or context-sensitive assistance to support the user's workflow. Defining a main area is optional and recommended for applications requiring a dedicated and maximizable area for user interaction. -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. +Multiple layouts, called perspectives, are supported. Perspectives can be switched. Only one perspective is active at a time. Perspectives share the same main area, if any. [](https://github.com/SchweizerischeBundesbahnen/scion-workbench/raw/master/docs/site/images/workbench-layout-parts.svg) #### Developer Experience -The SCION Workbench integrates seamlessly with Angular, leveraging familiar Angular APIs and concepts. It is designed to have minimal impact on development. Developing with the SCION Workbench is as straightforward as developing a regular Angular application. Workbench views are registered as primary routes that can be navigated using the router. Data is passed to views through navigation, either as path or matrix parameters. A view can read passed data from `ActivatedRoute`. +The SCION Workbench is built on top of Angular and is designed to have minimal impact on application development. The Workbench API is based on familiar Angular concepts, making development straightforward. -Because the workbench navigation is fully based on the Angular Router, the application can continue to leverage the rich and powerful features of the Angular Router, such as lazy component loading, resolvers, browser back/forward navigation, persistent navigation, and more. Dependency on SCION is minimal. +Any component can be opened as a view. A view is a regular Angular component associated with a route. Views are navigated using the Workbench Router. Like the Angular Router, it has a `navigate` method to open views or change the workbench layout. Data is passed to views through navigation. A view can read data from its `ActivatedRoute`. + +Because SCION Workbench uses Angular's routing mechanism to navigate and lay out views, the application can harness Angular's extensive routing capabilities, such as lazy component loading, resolvers, browser back/forward navigation, persistent navigation, and more. #### Integration into Angular SCION Workbench integrates with the Angular Router to perform layout changes and populate views, enabling persistent and backward/forward navigation. -A view is a named router outlet that is filled based on the current Angular router state. For all top-level primary routes, SCION Workbench registers view-specific secondary routes, allowing routing on a per-view basis. +A view is a named router outlet that is filled based on the current Angular router state. The SCION Workbench registers view-specific auxiliary routes for all routes, enabling routing on a per-view basis. The browser URL contains the path and arrangement of views in the main area. The arrangement of views outside the main area is passed as state to the navigation and stored in local storage. The figure below shows the browser URL when there are 3 views opened in the main area. For each view, Angular adds an auxiliary route to the URL. An auxiliary route consists of the view identifier and the path. Multiple views are separated by two slashes. diff --git a/projects/scion/e2e-testing/src/app-header.po.ts b/projects/scion/e2e-testing/src/app-header.po.ts index 5b49aec07..87232af06 100644 --- a/projects/scion/e2e-testing/src/app-header.po.ts +++ b/projects/scion/e2e-testing/src/app-header.po.ts @@ -24,7 +24,7 @@ export class AppHeaderPO { * Handle to the specified perspective toggle button. * * @param locateBy - Specifies how to locate the perspective toggle button. - * @property perspectiveId - Identifies the toggle button by the perspective id + * @param locateBy.perspectiveId - Identifies the toggle button by the perspective id */ public perspectiveToggleButton(locateBy: {perspectiveId: string}): PerspectiveTogglePO { return new PerspectiveTogglePO(this._locator.locator('div.e2e-perspective-toggles').locator(`button.e2e-perspective[data-perspectiveid="${locateBy.perspectiveId}"]`)); diff --git a/projects/scion/e2e-testing/src/app.po.ts b/projects/scion/e2e-testing/src/app.po.ts index 99532a202..9f281041a 100644 --- a/projects/scion/e2e-testing/src/app.po.ts +++ b/projects/scion/e2e-testing/src/app.po.ts @@ -8,7 +8,7 @@ * SPDX-License-Identifier: EPL-2.0 */ -import {coerceArray, DomRect, fromRect, waitUntilStable} from './helper/testing.util'; +import {coerceArray, DomRect, fromRect, waitForCondition, waitUntilStable} from './helper/testing.util'; import {StartPagePO} from './start-page.po'; import {Locator, Page} from '@playwright/test'; import {PartPO} from './part.po'; @@ -19,6 +19,7 @@ import {MessageBoxPO} from './message-box.po'; import {NotificationPO} from './notification.po'; import {AppHeaderPO} from './app-header.po'; import {DialogPO} from './dialog.po'; +import {ViewId} from '@scion/workbench'; export class AppPO { @@ -52,6 +53,15 @@ export class AppPO { * By passing a features object, you can control how to start the workbench and which app features to enable. */ public async navigateTo(options?: Options): Promise { + // Prepare local storage. + if (options?.localStorage) { + await this.navigateTo({microfrontendSupport: false}); + await this.page.evaluate(data => { + Object.entries(data).forEach(([key, value]) => window.localStorage.setItem(key, value)); + }, options.localStorage); + await this.page.goto('about:blank'); + } + this._workbenchStartupQueryParams = new URLSearchParams(); this._workbenchStartupQueryParams.append(WorkenchStartupQueryParams.LAUNCHER, options?.launcher ?? 'LAZY'); this._workbenchStartupQueryParams.append(WorkenchStartupQueryParams.STANDALONE, `${(options?.microfrontendSupport ?? true) === false}`); @@ -130,7 +140,7 @@ export class AppPO { * Handle to the specified part in the workbench layout. * * @param locateBy - Specifies how to locate the part. - * @property partId - Identifies the part by its id + * @param locateBy.partId - Identifies the part by its id */ public part(locateBy: {partId: string}): PartPO { return new PartPO(this.page.locator(`wb-part[data-partid="${locateBy.partId}"]`)); @@ -159,10 +169,10 @@ export class AppPO { * Handle to the specified view in the workbench layout. * * @param locateBy - Specifies how to locate the view. Either `viewId` or `cssClass`, or both must be set. - * @property viewId? - Identifies the view by its id - * @property cssClass? - Identifies the view by its CSS class + * @param locateBy.viewId - Identifies the view by its id + * @param locateBy.cssClass - Identifies the view by its CSS class */ - public view(locateBy: {viewId?: string; cssClass?: string}): ViewPO { + public view(locateBy: {viewId?: ViewId; cssClass?: string}): ViewPO { if (locateBy.viewId !== undefined && locateBy.cssClass !== undefined) { const viewLocator = this.page.locator(`wb-view[data-viewid="${locateBy.viewId}"].${locateBy.cssClass}`); const viewTabLocator = this.page.locator(`wb-view-tab[data-viewid="${locateBy.viewId}"].${locateBy.cssClass}`); @@ -235,17 +245,21 @@ export class AppPO { * Opens a new view tab. */ public async openNewViewTab(): Promise { + const navigationId = await this.getCurrentNavigationId(); await this.header.clickMenuItem({cssClass: 'e2e-open-start-page'}); // Wait until opened the start page to get its view id. - await waitUntilStable(() => this.getCurrentNavigationId()); - return new StartPagePO(this, {viewId: await this.activePart({inMainArea: true}).activeView.getViewId()}); + await waitForCondition(async () => (await this.getCurrentNavigationId()) !== navigationId); + const inMainArea = await this.hasMainArea(); + return new StartPagePO(this, {viewId: await this.activePart({inMainArea}).activeView.getViewId()}); } /** * Switches to the specified perspective. */ public async switchPerspective(perspectiveId: string): Promise { + const navigationId = await this.getCurrentNavigationId(); await this.header.perspectiveToggleButton({perspectiveId}).click(); + await waitForCondition(async () => (await this.getCurrentNavigationId()) !== navigationId); } /** @@ -278,7 +292,7 @@ export class AppPO { * * @see WORKBENCH_ID */ - public getWorkbenchIdId(): Promise { + public getWorkbenchId(): Promise { return this.page.locator('app-root').getAttribute('data-workbench-id').then(value => value ?? undefined); } @@ -307,6 +321,27 @@ export class AppPO { const pageFunction = (workbenchElement: HTMLElement, token: {name: string; value: string}): void => workbenchElement.style.setProperty(token.name, token.value); await this.workbench.evaluate(pageFunction, {name, value}); } + + /** + * Obtains the name of the current browser window. + */ + public getWindowName(): Promise { + return this.page.evaluate(() => window.name); + } + + /** + * Obtains the value associated with the specified key from local storage. + */ + public getLocalStorageItem(key: string): Promise { + return this.page.evaluate(key => localStorage.getItem(key), key); + } + + /** + * Tests if the layout has a main area. + */ + public hasMainArea(): Promise { + return this.workbench.locator('wb-main-area-layout').isVisible(); + } } /** @@ -346,6 +381,10 @@ export interface Options { * Controls the scope of application-modal workbench dialogs. By default, if not specified, workbench scope will be used. */ dialogModalityScope?: 'workbench' | 'viewport'; + /** + * Specifies data to be in local storage. + */ + localStorage?: {[key: string]: string}; } /** diff --git a/projects/scion/e2e-testing/src/helper/testing.util.ts b/projects/scion/e2e-testing/src/helper/testing.util.ts index 2050c03f4..abb151eec 100644 --- a/projects/scion/e2e-testing/src/helper/testing.util.ts +++ b/projects/scion/e2e-testing/src/helper/testing.util.ts @@ -10,6 +10,7 @@ import {Locator, Page} from '@playwright/test'; import {exhaustMap, filter, firstValueFrom, map, pairwise, timer} from 'rxjs'; +import {Commands} from '@scion/workbench'; /** * Returns if given CSS class is present on given element. @@ -55,6 +56,18 @@ export async function waitUntilStable(value: () => Promise | A, options?: return firstValueFrom(value$); } +/** + * Waits for a condition to be fulfilled. + */ +export async function waitForCondition(predicate: () => Promise): Promise { + const value$ = timer(0, 100) + .pipe( + exhaustMap(async () => await predicate()), + filter(Boolean), + ); + await firstValueFrom(value$); +} + /** * Waits until given locators are attached to the DOM. */ @@ -131,6 +144,13 @@ export function coerceMap(value: Record | Map): Map | null | undefined): string { + return Object.entries(object ?? {}).map(([key, value]) => `${key}=${value}`).join(';'); +} + /** * Returns a new {@link Record} with `undefined` and `` values removed. */ @@ -170,3 +190,22 @@ export interface DomRect { hcenter: number; vcenter: number; } + +/** + * Converts given segments to a path. + */ +export function commandsToPath(commands: Commands): string { + return commands + .reduce((path, command) => { + if (typeof command === 'string') { + return path.concat(command); + } + else if (!path.length) { + return path.concat(`.;${toMatrixNotation(command)}`); // Note that matrix parameters in the first segment are only supported in combination with a `relativeTo`. + } + else { + return path.concat(`${path.pop()};${toMatrixNotation(command)}`); + } + }, []) + .join('/'); +} diff --git a/projects/scion/e2e-testing/src/matcher/to-equal-workbench-layout.matcher.ts b/projects/scion/e2e-testing/src/matcher/to-equal-workbench-layout.matcher.ts index 6790336ed..750b77546 100644 --- a/projects/scion/e2e-testing/src/matcher/to-equal-workbench-layout.matcher.ts +++ b/projects/scion/e2e-testing/src/matcher/to-equal-workbench-layout.matcher.ts @@ -12,6 +12,7 @@ import {Locator} from '@playwright/test'; import {ExpectationResult} from './custom-matchers.definition'; import {MAIN_AREA} from '../workbench.model'; import {retryOnError} from '../helper/testing.util'; +import {ViewId} from '@scion/workbench'; /** * Provides the implementation of {@link CustomMatchers#toEqualWorkbenchLayout}. @@ -357,7 +358,7 @@ export class MPart { public readonly type = 'MPart'; public readonly id?: string; public views?: MView[]; - public activeViewId?: string; + public activeViewId?: ViewId; constructor(part: Omit) { Object.assign(this, part); @@ -368,5 +369,5 @@ export class MPart { * Modified version of {@link MView} to expect the workbench layout. */ export interface MView { - readonly id: string; + readonly id: ViewId; } diff --git a/projects/scion/e2e-testing/src/start-page.po.ts b/projects/scion/e2e-testing/src/start-page.po.ts index f32e4ff27..228fb2f71 100644 --- a/projects/scion/e2e-testing/src/start-page.po.ts +++ b/projects/scion/e2e-testing/src/start-page.po.ts @@ -14,6 +14,7 @@ import {Locator} from '@playwright/test'; import {SciTabbarPO} from './@scion/components.internal/tabbar.po'; import {SciRouterOutletPO} from './workbench-client/page-object/sci-router-outlet.po'; import {WorkbenchViewPagePO} from './workbench/page-object/workbench-view-page.po'; +import {ViewId} from '@scion/workbench'; /** * Page object to interact with {@link StartPageComponent}. @@ -26,7 +27,7 @@ export class StartPagePO implements WorkbenchViewPagePO { public readonly locator: Locator; - constructor(private _appPO: AppPO, locateBy?: {viewId?: string; cssClass?: string}) { + constructor(private _appPO: AppPO, locateBy?: {viewId?: ViewId; cssClass?: string}) { if (locateBy?.viewId || locateBy?.cssClass) { this._view = this._appPO.view({viewId: locateBy?.viewId, cssClass: locateBy?.cssClass}); this.locator = this._view.locator.locator('app-start-page'); diff --git a/projects/scion/e2e-testing/src/view-tab.po.ts b/projects/scion/e2e-testing/src/view-tab.po.ts index 7a6b957a7..fdeeab33b 100644 --- a/projects/scion/e2e-testing/src/view-tab.po.ts +++ b/projects/scion/e2e-testing/src/view-tab.po.ts @@ -14,6 +14,7 @@ import {PartPO} from './part.po'; import {ViewTabContextMenuPO} from './view-tab-context-menu.po'; import {ViewMoveDialogTestPagePO} from './workbench/page-object/test-pages/view-move-dialog-test-page.po'; import {AppPO} from './app.po'; +import {ViewId} from '@scion/workbench'; /** * Handle for interacting with a workbench view tab. @@ -44,8 +45,8 @@ export class ViewTabPO { this.part = part; } - public async getViewId(): Promise { - return (await this.locator.getAttribute('data-viewid'))!; + public async getViewId(): Promise { + return (await this.locator.getAttribute('data-viewid'))! as ViewId; } public async click(): Promise { @@ -158,12 +159,12 @@ export class ViewTabPO { * Drags this view tab to the specified region of specified part or grid. * * @param target - Specifies the part or grid where to drop this view tab. - * @property partId - Specifies the part where to drag this tab. - * @property grid - Specifies the grid where to drag this tab. - * @property region - Specifies the region where to drop this tab in the specified target. + * @param target.partId - Specifies the part where to drag this tab. + * @param target.grid - Specifies the grid where to drag this tab. + * @param target.region - Specifies the region where to drop this tab in the specified target. * @param options - Controls the drag operation. - * @property steps - Sets the number of intermediate events to be emitted while dragging; defaults to `2`. - * @property performDrop - Controls whether to perform the drop; defaults to `true`. + * @param options.steps - Sets the number of intermediate events to be emitted while dragging; defaults to `2`. + * @param options.performDrop - Controls whether to perform the drop; defaults to `true`. */ public async dragTo(target: {partId: string; region: 'north' | 'east' | 'south' | 'west' | 'center'}, options?: {steps?: number; performDrop?: boolean}): Promise; public async dragTo(target: {grid: 'workbench' | 'mainArea'; region: 'north' | 'east' | 'south' | 'west' | 'center'}, options?: {steps?: number; performDrop?: boolean}): Promise; diff --git a/projects/scion/e2e-testing/src/view.po.ts b/projects/scion/e2e-testing/src/view.po.ts index 16bd272bb..0b8e4fabb 100644 --- a/projects/scion/e2e-testing/src/view.po.ts +++ b/projects/scion/e2e-testing/src/view.po.ts @@ -12,6 +12,7 @@ import {DomRect, fromRect, getCssClasses} from './helper/testing.util'; import {Locator} from '@playwright/test'; import {PartPO} from './part.po'; import {ViewTabPO} from './view-tab.po'; +import {ViewId} from '@scion/workbench'; import {ViewInfo, ViewInfoDialogPO} from './workbench/page-object/view-info-dialog.po'; import {AppPO} from './app.po'; @@ -29,7 +30,7 @@ export class ViewPO { this.tab = tab; } - public async getViewId(): Promise { + public async getViewId(): Promise { return this.tab.getViewId(); } diff --git a/projects/scion/e2e-testing/src/workbench-client/page-object/dialog-opener-page.po.ts b/projects/scion/e2e-testing/src/workbench-client/page-object/dialog-opener-page.po.ts index 73656e489..858303011 100644 --- a/projects/scion/e2e-testing/src/workbench-client/page-object/dialog-opener-page.po.ts +++ b/projects/scion/e2e-testing/src/workbench-client/page-object/dialog-opener-page.po.ts @@ -17,7 +17,7 @@ import {SciCheckboxPO} from '../../@scion/components.internal/checkbox.po'; import {Locator} from '@playwright/test'; import {SciRouterOutletPO} from './sci-router-outlet.po'; import {MicrofrontendViewPagePO} from '../../workbench/page-object/workbench-view-page.po'; -import {WorkbenchDialogOptions} from '@scion/workbench-client'; +import {ViewId, WorkbenchDialogOptions} from '@scion/workbench-client'; /** * Page object to interact with {@link DialogOpenerPageComponent}. @@ -30,7 +30,7 @@ export class DialogOpenerPagePO implements MicrofrontendViewPagePO { public readonly returnValue: Locator; public readonly error: Locator; - constructor(private _appPO: AppPO, locateBy: {viewId?: string; cssClass?: string}) { + constructor(private _appPO: AppPO, locateBy: {viewId?: ViewId; cssClass?: string}) { this.view = this._appPO.view({viewId: locateBy.viewId, cssClass: locateBy.cssClass}); this.outlet = new SciRouterOutletPO(this._appPO, {name: locateBy.viewId, cssClass: locateBy.cssClass}); this.locator = this.outlet.frameLocator.locator('app-dialog-opener-page'); diff --git a/projects/scion/e2e-testing/src/workbench-client/page-object/message-box-opener-page.po.ts b/projects/scion/e2e-testing/src/workbench-client/page-object/message-box-opener-page.po.ts index 901f52860..ed7052d63 100644 --- a/projects/scion/e2e-testing/src/workbench-client/page-object/message-box-opener-page.po.ts +++ b/projects/scion/e2e-testing/src/workbench-client/page-object/message-box-opener-page.po.ts @@ -17,6 +17,7 @@ import {Locator} from '@playwright/test'; import {SciRouterOutletPO} from './sci-router-outlet.po'; import {ViewPO} from '../../view.po'; import {MicrofrontendViewPagePO} from '../../workbench/page-object/workbench-view-page.po'; +import {ViewId} from '@scion/workbench-client'; /** * Page object to interact with {@link MessageBoxOpenerPageComponent}. @@ -28,7 +29,7 @@ export class MessageBoxOpenerPagePO implements MicrofrontendViewPagePO { public readonly outlet: SciRouterOutletPO; public readonly closeAction: Locator; - constructor(private _appPO: AppPO, locateBy: {viewId?: string; cssClass?: string}) { + constructor(private _appPO: AppPO, locateBy: {viewId?: ViewId; cssClass?: string}) { this.view = this._appPO.view({viewId: locateBy.viewId, cssClass: locateBy.cssClass}); this.outlet = new SciRouterOutletPO(this._appPO, {name: locateBy.viewId, cssClass: locateBy.cssClass}); this.locator = this.outlet.frameLocator.locator('app-message-box-opener-page'); diff --git a/projects/scion/e2e-testing/src/workbench-client/page-object/messaging-page.po.ts b/projects/scion/e2e-testing/src/workbench-client/page-object/messaging-page.po.ts index 577e5eff6..e967d99a6 100644 --- a/projects/scion/e2e-testing/src/workbench-client/page-object/messaging-page.po.ts +++ b/projects/scion/e2e-testing/src/workbench-client/page-object/messaging-page.po.ts @@ -15,6 +15,7 @@ import {SciTabbarPO} from '../../@scion/components.internal/tabbar.po'; import {SciRouterOutletPO} from './sci-router-outlet.po'; import {rejectWhenAttached, waitUntilAttached} from '../../helper/testing.util'; import {MicrofrontendViewPagePO} from '../../workbench/page-object/workbench-view-page.po'; +import {ViewId} from '@scion/workbench-client'; /** * Page object to interact with {@link MessagingPageComponent}. @@ -27,7 +28,7 @@ export class MessagingPagePO implements MicrofrontendViewPagePO { public readonly view: ViewPO; public readonly outlet: SciRouterOutletPO; - constructor(appPO: AppPO, locateBy: {viewId?: string; cssClass?: string}) { + constructor(appPO: AppPO, locateBy: {viewId?: ViewId; cssClass?: string}) { this.view = appPO.view({viewId: locateBy.viewId, cssClass: locateBy.cssClass}); this.outlet = new SciRouterOutletPO(appPO, {name: locateBy.viewId, cssClass: locateBy.cssClass}); this.locator = this.outlet.frameLocator.locator('app-messaging-page'); diff --git a/projects/scion/e2e-testing/src/workbench-client/page-object/notification-opener-page.po.ts b/projects/scion/e2e-testing/src/workbench-client/page-object/notification-opener-page.po.ts index 32c547476..1d9e1af2e 100644 --- a/projects/scion/e2e-testing/src/workbench-client/page-object/notification-opener-page.po.ts +++ b/projects/scion/e2e-testing/src/workbench-client/page-object/notification-opener-page.po.ts @@ -16,6 +16,7 @@ import {Locator} from '@playwright/test'; import {SciRouterOutletPO} from './sci-router-outlet.po'; import {MicrofrontendViewPagePO} from '../../workbench/page-object/workbench-view-page.po'; import {ViewPO} from '../../view.po'; +import {ViewId} from '@scion/workbench-client'; /** * Page object to interact with {@link NotificationOpenerPageComponent}. @@ -27,7 +28,7 @@ export class NotificationOpenerPagePO implements MicrofrontendViewPagePO { public readonly view: ViewPO; public readonly error: Locator; - constructor(private _appPO: AppPO, locateBy: {viewId?: string; cssClass?: string}) { + constructor(private _appPO: AppPO, locateBy: {viewId?: ViewId; cssClass?: string}) { this.view = this._appPO.view({viewId: locateBy.viewId, cssClass: locateBy.cssClass}); this.outlet = new SciRouterOutletPO(this._appPO, {name: locateBy.viewId, cssClass: locateBy.cssClass}); this.locator = this.outlet.frameLocator.locator('app-notification-opener-page'); diff --git a/projects/scion/e2e-testing/src/workbench-client/page-object/popup-opener-page.po.ts b/projects/scion/e2e-testing/src/workbench-client/page-object/popup-opener-page.po.ts index 8374d1fab..9abff506c 100644 --- a/projects/scion/e2e-testing/src/workbench-client/page-object/popup-opener-page.po.ts +++ b/projects/scion/e2e-testing/src/workbench-client/page-object/popup-opener-page.po.ts @@ -19,6 +19,7 @@ import {SciCheckboxPO} from '../../@scion/components.internal/checkbox.po'; import {Locator} from '@playwright/test'; import {SciRouterOutletPO} from './sci-router-outlet.po'; import {MicrofrontendViewPagePO} from '../../workbench/page-object/workbench-view-page.po'; +import {ViewId} from '@scion/workbench-client'; /** * Page object to interact with {@link PopupOpenerPageComponent}. @@ -31,7 +32,7 @@ export class PopupOpenerPagePO implements MicrofrontendViewPagePO { public readonly returnValue: Locator; public readonly error: Locator; - constructor(private _appPO: AppPO, locateBy: {viewId?: string; cssClass?: string}) { + constructor(private _appPO: AppPO, locateBy: {viewId?: ViewId; cssClass?: string}) { this.view = this._appPO.view({viewId: locateBy.viewId, cssClass: locateBy.cssClass}); this.outlet = new SciRouterOutletPO(this._appPO, {name: locateBy.viewId, cssClass: locateBy.cssClass}); this.locator = this.outlet.frameLocator.locator('app-popup-opener-page'); @@ -99,7 +100,7 @@ export class PopupOpenerPagePO implements MicrofrontendViewPagePO { } } - public async enterContextualViewId(viewId: string | '' | ''): Promise { + public async enterContextualViewId(viewId: ViewId | '' | ''): Promise { await this.locator.locator('input.e2e-contextual-view-id').fill(viewId); } diff --git a/projects/scion/e2e-testing/src/workbench-client/page-object/register-workbench-capability-page.po.ts b/projects/scion/e2e-testing/src/workbench-client/page-object/register-workbench-capability-page.po.ts index 7d787b71d..a9d1f6131 100644 --- a/projects/scion/e2e-testing/src/workbench-client/page-object/register-workbench-capability-page.po.ts +++ b/projects/scion/e2e-testing/src/workbench-client/page-object/register-workbench-capability-page.po.ts @@ -13,7 +13,7 @@ import {AppPO} from '../../app.po'; import {SciKeyValueFieldPO} from '../../@scion/components.internal/key-value-field.po'; import {SciCheckboxPO} from '../../@scion/components.internal/checkbox.po'; import {Locator} from '@playwright/test'; -import {WorkbenchDialogCapability as _WorkbenchDialogCapability, WorkbenchPopupCapability as _WorkbenchPopupCapability, WorkbenchViewCapability as _WorkbenchViewCapability} from '@scion/workbench-client'; +import {ViewId, WorkbenchDialogCapability as _WorkbenchDialogCapability, WorkbenchPopupCapability as _WorkbenchPopupCapability, WorkbenchViewCapability as _WorkbenchViewCapability} from '@scion/workbench-client'; import {Capability} from '@scion/microfrontend-platform'; import {SciRouterOutletPO} from './sci-router-outlet.po'; import {MicrofrontendViewPagePO} from '../../workbench/page-object/workbench-view-page.po'; @@ -39,7 +39,7 @@ export class RegisterWorkbenchCapabilityPagePO implements MicrofrontendViewPageP public readonly outlet: SciRouterOutletPO; public readonly view: ViewPO; - constructor(appPO: AppPO, locateBy: {viewId?: string; cssClass?: string}) { + constructor(appPO: AppPO, locateBy: {viewId?: ViewId; cssClass?: string}) { this.view = appPO.view({viewId: locateBy.viewId, cssClass: locateBy.cssClass}); this.outlet = new SciRouterOutletPO(appPO, {name: locateBy.viewId, cssClass: locateBy.cssClass}); this.locator = this.outlet.frameLocator.locator('app-register-workbench-capability-page'); diff --git a/projects/scion/e2e-testing/src/workbench-client/page-object/register-workbench-intention-page.po.ts b/projects/scion/e2e-testing/src/workbench-client/page-object/register-workbench-intention-page.po.ts index 6a9d231b7..a738fc7e8 100644 --- a/projects/scion/e2e-testing/src/workbench-client/page-object/register-workbench-intention-page.po.ts +++ b/projects/scion/e2e-testing/src/workbench-client/page-object/register-workbench-intention-page.po.ts @@ -16,6 +16,7 @@ import {rejectWhenAttached} from '../../helper/testing.util'; import {SciRouterOutletPO} from './sci-router-outlet.po'; import {MicrofrontendViewPagePO} from '../../workbench/page-object/workbench-view-page.po'; import {ViewPO} from '../../view.po'; +import {ViewId} from '@scion/workbench-client'; /** * Page object to interact with {@link RegisterWorkbenchIntentionPageComponent}. @@ -26,7 +27,7 @@ export class RegisterWorkbenchIntentionPagePO implements MicrofrontendViewPagePO public readonly outlet: SciRouterOutletPO; public readonly view: ViewPO; - constructor(appPO: AppPO, locateBy: {viewId?: string; cssClass?: string}) { + constructor(appPO: AppPO, locateBy: {viewId?: ViewId; cssClass?: string}) { this.view = appPO.view({viewId: locateBy.viewId, cssClass: locateBy.cssClass}); this.outlet = new SciRouterOutletPO(appPO, {name: locateBy.viewId, cssClass: locateBy.cssClass}); this.locator = this.outlet.frameLocator.locator('app-register-workbench-intention-page'); diff --git a/projects/scion/e2e-testing/src/workbench-client/page-object/router-page.po.ts b/projects/scion/e2e-testing/src/workbench-client/page-object/router-page.po.ts index 8c149679e..c02adff34 100644 --- a/projects/scion/e2e-testing/src/workbench-client/page-object/router-page.po.ts +++ b/projects/scion/e2e-testing/src/workbench-client/page-object/router-page.po.ts @@ -17,6 +17,7 @@ import {Locator} from '@playwright/test'; import {coerceArray, rejectWhenAttached, waitUntilStable} from '../../helper/testing.util'; import {SciRouterOutletPO} from './sci-router-outlet.po'; import {MicrofrontendViewPagePO} from '../../workbench/page-object/workbench-view-page.po'; +import {ViewId} from '@scion/workbench-client'; /** * Page object to interact with {@link RouterPageComponent} of workbench-client testing app. @@ -27,7 +28,7 @@ export class RouterPagePO implements MicrofrontendViewPagePO { public readonly view: ViewPO; public readonly outlet: SciRouterOutletPO; - constructor(private _appPO: AppPO, locateBy: {viewId?: string; cssClass?: string}) { + constructor(private _appPO: AppPO, locateBy: {viewId?: ViewId; cssClass?: string}) { this.view = this._appPO.view({viewId: locateBy.viewId, cssClass: locateBy.cssClass}); this.outlet = new SciRouterOutletPO(this._appPO, {name: locateBy.viewId, cssClass: locateBy.cssClass}); this.locator = this.outlet.frameLocator.locator('app-router-page'); @@ -62,7 +63,7 @@ export class RouterPagePO implements MicrofrontendViewPagePO { } public async enterCssClass(cssClass: string | string[]): Promise { - await this.locator.locator('input.e2e-css-class').fill(coerceArray(cssClass).join(' ')); + await this.locator.locator('input.e2e-class').fill(coerceArray(cssClass).join(' ')); } /** diff --git a/projects/scion/e2e-testing/src/workbench-client/page-object/test-pages/angular-zone-test-page.po.ts b/projects/scion/e2e-testing/src/workbench-client/page-object/test-pages/angular-zone-test-page.po.ts index 93e3ccc16..1f5bcfc33 100644 --- a/projects/scion/e2e-testing/src/workbench-client/page-object/test-pages/angular-zone-test-page.po.ts +++ b/projects/scion/e2e-testing/src/workbench-client/page-object/test-pages/angular-zone-test-page.po.ts @@ -16,6 +16,7 @@ import {MicrofrontendNavigator} from '../../microfrontend-navigator'; import {SciRouterOutletPO} from '../sci-router-outlet.po'; import {MicrofrontendViewPagePO} from '../../../workbench/page-object/workbench-view-page.po'; import {ViewPO} from '../../../view.po'; +import {ViewId} from '@scion/workbench-client'; export class AngularZoneTestPagePO implements MicrofrontendViewPagePO { @@ -29,7 +30,7 @@ export class AngularZoneTestPagePO implements MicrofrontendViewPagePO { activePanel: PanelPO; }; - constructor(appPO: AppPO, viewId: string) { + constructor(appPO: AppPO, viewId: ViewId) { this.view = appPO.view({viewId}); this.outlet = new SciRouterOutletPO(appPO, {name: viewId}); this.locator = this.outlet.frameLocator.locator('app-angular-zone-test-page'); diff --git a/projects/scion/e2e-testing/src/workbench-client/page-object/test-pages/bulk-navigation-test-page.po.ts b/projects/scion/e2e-testing/src/workbench-client/page-object/test-pages/bulk-navigation-test-page.po.ts index 25fa84dba..baa076746 100644 --- a/projects/scion/e2e-testing/src/workbench-client/page-object/test-pages/bulk-navigation-test-page.po.ts +++ b/projects/scion/e2e-testing/src/workbench-client/page-object/test-pages/bulk-navigation-test-page.po.ts @@ -15,6 +15,7 @@ import {MicrofrontendNavigator} from '../../microfrontend-navigator'; import {SciRouterOutletPO} from '../sci-router-outlet.po'; import {MicrofrontendViewPagePO} from '../../../workbench/page-object/workbench-view-page.po'; import {ViewPO} from '../../../view.po'; +import {ViewId} from '@scion/workbench-client'; export class BulkNavigationTestPagePO implements MicrofrontendViewPagePO { @@ -22,7 +23,7 @@ export class BulkNavigationTestPagePO implements MicrofrontendViewPagePO { public readonly view: ViewPO; public readonly outlet: SciRouterOutletPO; - constructor(private _appPO: AppPO, viewId: string) { + constructor(private _appPO: AppPO, viewId: ViewId) { this.view = this._appPO.view({viewId}); this.outlet = new SciRouterOutletPO(this._appPO, {name: viewId}); this.locator = this.outlet.frameLocator.locator('app-bulk-navigation-test-page'); @@ -33,7 +34,7 @@ export class BulkNavigationTestPagePO implements MicrofrontendViewPagePO { } public async enterCssClass(cssClass: string): Promise { - await this.locator.locator('input.e2e-css-class').fill(cssClass); + await this.locator.locator('input.e2e-class').fill(cssClass); } /** diff --git a/projects/scion/e2e-testing/src/workbench-client/page-object/test-pages/microfrontend-view-test-page.po.ts b/projects/scion/e2e-testing/src/workbench-client/page-object/test-pages/microfrontend-view-test-page.po.ts index d15f4d5ff..875450f30 100644 --- a/projects/scion/e2e-testing/src/workbench-client/page-object/test-pages/microfrontend-view-test-page.po.ts +++ b/projects/scion/e2e-testing/src/workbench-client/page-object/test-pages/microfrontend-view-test-page.po.ts @@ -13,6 +13,7 @@ import {Locator} from '@playwright/test'; import {ViewPO} from '../../../view.po'; import {SciRouterOutletPO} from '../sci-router-outlet.po'; import {MicrofrontendViewPagePO} from '../../../workbench/page-object/workbench-view-page.po'; +import {ViewId} from '@scion/workbench-client'; export class MicrofrontendViewTestPagePO implements MicrofrontendViewPagePO { @@ -20,7 +21,7 @@ export class MicrofrontendViewTestPagePO implements MicrofrontendViewPagePO { public readonly view: ViewPO; public readonly outlet: SciRouterOutletPO; - constructor(appPO: AppPO, locateBy: {viewId?: string; cssClass?: string}) { + constructor(appPO: AppPO, locateBy: {viewId?: ViewId; cssClass?: string}) { this.view = appPO.view({viewId: locateBy?.viewId, cssClass: locateBy?.cssClass}); this.outlet = new SciRouterOutletPO(appPO, {name: locateBy?.viewId, cssClass: locateBy?.cssClass}); this.locator = this.outlet.frameLocator.locator('app-microfrontend-test-page'); diff --git a/projects/scion/e2e-testing/src/workbench-client/page-object/test-pages/view-properties-test-page.po.ts b/projects/scion/e2e-testing/src/workbench-client/page-object/test-pages/view-properties-test-page.po.ts index 7a1be173c..bc4b7f2c7 100644 --- a/projects/scion/e2e-testing/src/workbench-client/page-object/test-pages/view-properties-test-page.po.ts +++ b/projects/scion/e2e-testing/src/workbench-client/page-object/test-pages/view-properties-test-page.po.ts @@ -13,6 +13,7 @@ import {Locator} from '@playwright/test'; import {SciRouterOutletPO} from '../sci-router-outlet.po'; import {MicrofrontendViewPagePO} from '../../../workbench/page-object/workbench-view-page.po'; import {ViewPO} from '../../../view.po'; +import {ViewId} from '@scion/workbench-client'; export class ViewPropertiesTestPagePO implements MicrofrontendViewPagePO { @@ -20,7 +21,7 @@ export class ViewPropertiesTestPagePO implements MicrofrontendViewPagePO { public readonly view: ViewPO; public readonly outlet: SciRouterOutletPO; - constructor(appPO: AppPO, viewId: string) { + constructor(appPO: AppPO, viewId: ViewId) { this.view = appPO.view({viewId}); this.outlet = new SciRouterOutletPO(appPO, {name: viewId}); this.locator = this.outlet.frameLocator.locator('app-view-properties-test-page'); diff --git a/projects/scion/e2e-testing/src/workbench-client/page-object/test-pages/workbench-theme-test-page.po.ts b/projects/scion/e2e-testing/src/workbench-client/page-object/test-pages/workbench-theme-test-page.po.ts index 391f0b224..c14b5c492 100644 --- a/projects/scion/e2e-testing/src/workbench-client/page-object/test-pages/workbench-theme-test-page.po.ts +++ b/projects/scion/e2e-testing/src/workbench-client/page-object/test-pages/workbench-theme-test-page.po.ts @@ -14,6 +14,7 @@ import {MicrofrontendNavigator} from '../../microfrontend-navigator'; import {SciRouterOutletPO} from '../sci-router-outlet.po'; import {MicrofrontendViewPagePO} from '../../../workbench/page-object/workbench-view-page.po'; import {ViewPO} from '../../../view.po'; +import {ViewId} from '@scion/workbench-client'; export class WorkbenchThemeTestPagePO implements MicrofrontendViewPagePO { @@ -24,7 +25,7 @@ export class WorkbenchThemeTestPagePO implements MicrofrontendViewPagePO { public readonly theme: Locator; public readonly colorScheme: Locator; - constructor(appPO: AppPO, viewId: string) { + constructor(appPO: AppPO, viewId: ViewId) { this.view = appPO.view({viewId}); this.outlet = new SciRouterOutletPO(appPO, {name: viewId}); this.locator = this.outlet.frameLocator.locator('app-workbench-theme-test-page'); diff --git a/projects/scion/e2e-testing/src/workbench-client/page-object/unregister-workbench-capability-page.po.ts b/projects/scion/e2e-testing/src/workbench-client/page-object/unregister-workbench-capability-page.po.ts index 6409fc76b..8caa7f6c7 100644 --- a/projects/scion/e2e-testing/src/workbench-client/page-object/unregister-workbench-capability-page.po.ts +++ b/projects/scion/e2e-testing/src/workbench-client/page-object/unregister-workbench-capability-page.po.ts @@ -14,6 +14,7 @@ import {rejectWhenAttached, waitUntilAttached} from '../../helper/testing.util'; import {SciRouterOutletPO} from './sci-router-outlet.po'; import {MicrofrontendViewPagePO} from '../../workbench/page-object/workbench-view-page.po'; import {ViewPO} from '../../view.po'; +import {ViewId} from '@scion/workbench-client'; /** * Page object to interact with {@link UnregisterWorkbenchCapabilityPageComponent}. @@ -24,7 +25,7 @@ export class UnregisterWorkbenchCapabilityPagePO implements MicrofrontendViewPag public readonly view: ViewPO; public readonly outlet: SciRouterOutletPO; - constructor(appPO: AppPO, locateBy: {viewId?: string; cssClass?: string}) { + constructor(appPO: AppPO, locateBy: {viewId?: ViewId; cssClass?: string}) { this.view = appPO.view({viewId: locateBy.viewId, cssClass: locateBy.cssClass}); this.outlet = new SciRouterOutletPO(appPO, {name: locateBy.viewId, cssClass: locateBy.cssClass}); this.locator = this.outlet.frameLocator.locator('app-unregister-workbench-capability-page'); diff --git a/projects/scion/e2e-testing/src/workbench-client/page-object/view-page.po.ts b/projects/scion/e2e-testing/src/workbench-client/page-object/view-page.po.ts index 13271f064..03ea6a798 100644 --- a/projects/scion/e2e-testing/src/workbench-client/page-object/view-page.po.ts +++ b/projects/scion/e2e-testing/src/workbench-client/page-object/view-page.po.ts @@ -12,7 +12,7 @@ import {DomRect, fromRect} from '../../helper/testing.util'; import {AppPO} from '../../app.po'; import {ViewPO} from '../../view.po'; import {Params} from '@angular/router'; -import {WorkbenchViewCapability} from '@scion/workbench-client'; +import {ViewId, WorkbenchViewCapability} from '@scion/workbench-client'; import {SciAccordionPO} from '../../@scion/components.internal/accordion.po'; import {SciKeyValuePO} from '../../@scion/components.internal/key-value.po'; import {SciCheckboxPO} from '../../@scion/components.internal/checkbox.po'; @@ -32,7 +32,7 @@ export class ViewPagePO implements MicrofrontendViewPagePO { public readonly outlet: SciRouterOutletPO; public readonly path: Locator; - constructor(appPO: AppPO, locateBy: {viewId?: string; cssClass?: string}) { + constructor(appPO: AppPO, locateBy: {viewId?: ViewId; cssClass?: string}) { this.view = appPO.view({viewId: locateBy?.viewId, cssClass: locateBy?.cssClass}); this.outlet = new SciRouterOutletPO(appPO, {name: locateBy?.viewId, cssClass: locateBy?.cssClass}); this.locator = this.outlet.frameLocator.locator('app-view-page'); diff --git a/projects/scion/e2e-testing/src/workbench-client/router-params.e2e-spec.ts b/projects/scion/e2e-testing/src/workbench-client/router-params.e2e-spec.ts index f07dea315..dbdd6c400 100644 --- a/projects/scion/e2e-testing/src/workbench-client/router-params.e2e-spec.ts +++ b/projects/scion/e2e-testing/src/workbench-client/router-params.e2e-spec.ts @@ -580,9 +580,10 @@ test.describe('Workbench Router', () => { await routerPage.enterQualifier({component: 'view', app: 'app1'}); await routerPage.enterParams({initialTitle: 'TITLE', transientParam: 'TRANSIENT PARAM'}); await routerPage.enterTarget('blank'); + await routerPage.enterCssClass('testee'); await routerPage.clickNavigate(); - const testeeViewPage = new ViewPagePO(appPO, {cssClass: 'e2e-test-view'}); + const testeeViewPage = new ViewPagePO(appPO, {cssClass: 'testee'}); // expect transient param to be contained in view params await expect.poll(() => testeeViewPage.getViewParams()).toMatchObject({ 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 436d391ea..5fa66df0a 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 @@ -852,7 +852,6 @@ test.describe('Workbench Router', () => { properties: { path: 'test-view', title: 'testee', - cssClass: 'testee', }, }); @@ -860,6 +859,7 @@ test.describe('Workbench Router', () => { const routerPage = await microfrontendNavigator.openInNewTab(RouterPagePO, 'app1'); await routerPage.enterQualifier({component: 'testee'}); await routerPage.enterTarget('blank'); + await routerPage.enterCssClass('testee'); await routerPage.clickNavigate(); // expect the view to be present @@ -1279,7 +1279,7 @@ test.describe('Workbench Router', () => { await routerPage.checkClose(true); // expect closing to be rejected - await expect(routerPage.clickNavigate()).rejects.toThrow(/\[WorkbenchRouterError]\[IllegalArgumentError]/); + await expect(routerPage.clickNavigate()).rejects.toThrow(/\[NavigateError]/); await expect(appPO.views()).toHaveCount(2); }); diff --git a/projects/scion/e2e-testing/src/workbench-client/view-css-class.e2e-spec.ts b/projects/scion/e2e-testing/src/workbench-client/view-css-class.e2e-spec.ts index 6a1f90fef..6fed2b08c 100644 --- a/projects/scion/e2e-testing/src/workbench-client/view-css-class.e2e-spec.ts +++ b/projects/scion/e2e-testing/src/workbench-client/view-css-class.e2e-spec.ts @@ -12,7 +12,6 @@ import {test} from '../fixtures'; import {ViewPagePO} from './page-object/view-page.po'; import {expect} from '@playwright/test'; import {RouterPagePO} from './page-object/router-page.po'; -import {LayoutPagePO} from '../workbench/page-object/layout-page.po'; test.describe('Workbench View CSS Class', () => { @@ -43,9 +42,10 @@ test.describe('Workbench View CSS Class', () => { }); await microfrontendNavigator.registerIntention('app1', {type: 'view', qualifier: {component: 'testee-2'}}); - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - await layoutPage.addPart('right', {align: 'right'}); - await layoutPage.addView('view.100', {partId: 'right', activateView: true}); + await workbenchNavigator.modifyLayout(layout => layout + .addPart('right', {align: 'right'}) + .addView('view.100', {partId: 'right', activateView: true, cssClass: 'testee-layout'}), + ); const viewPage = new ViewPagePO(appPO, {viewId: 'view.100'}); @@ -59,6 +59,10 @@ test.describe('Workbench View CSS Class', () => { await expect.poll(() => viewPage.view.getCssClasses()).toContain('testee-navigation-1'); await expect.poll(() => viewPage.view.tab.getCssClasses()).toContain('testee-navigation-1'); await expect.poll(() => viewPage.outlet.getCssClasses()).toContain('testee-navigation-1'); + // Expect CSS classes of the view to be set. + await expect.poll(() => viewPage.view.getCssClasses()).toContain('testee-layout'); + await expect.poll(() => viewPage.view.tab.getCssClasses()).toContain('testee-layout'); + await expect.poll(() => viewPage.outlet.getCssClasses()).toContain('testee-layout'); // Expect CSS classes of the capability to be set. await expect.poll(() => viewPage.view.getCssClasses()).toContain('testee-capability-1'); await expect.poll(() => viewPage.view.tab.getCssClasses()).toContain('testee-capability-1'); @@ -81,6 +85,10 @@ test.describe('Workbench View CSS Class', () => { await expect.poll(() => viewPage.view.getCssClasses()).not.toContain('testee-capability-1'); await expect.poll(() => viewPage.view.tab.getCssClasses()).not.toContain('testee-capability-1'); await expect.poll(() => viewPage.outlet.getCssClasses()).not.toContain('testee-capability-1'); + // Expect CSS classes of the view to be set. + await expect.poll(() => viewPage.view.getCssClasses()).toContain('testee-layout'); + await expect.poll(() => viewPage.view.tab.getCssClasses()).toContain('testee-layout'); + await expect.poll(() => viewPage.outlet.getCssClasses()).toContain('testee-layout'); // Expect CSS classes of the capability to be set. await expect.poll(() => viewPage.view.getCssClasses()).toContain('testee-capability-2'); await expect.poll(() => viewPage.view.tab.getCssClasses()).toContain('testee-capability-2'); @@ -101,6 +109,10 @@ test.describe('Workbench View CSS Class', () => { await expect.poll(() => viewPage.view.getCssClasses()).not.toContain('testee-capability-2'); await expect.poll(() => viewPage.view.tab.getCssClasses()).not.toContain('testee-capability-2'); await expect.poll(() => viewPage.outlet.getCssClasses()).not.toContain('testee-capability-2'); + // Expect CSS classes of the view to be set. + await expect.poll(() => viewPage.view.getCssClasses()).toContain('testee-layout'); + await expect.poll(() => viewPage.view.tab.getCssClasses()).toContain('testee-layout'); + await expect.poll(() => viewPage.outlet.getCssClasses()).toContain('testee-layout'); // Expect CSS classes of the capability to be set. await expect.poll(() => viewPage.view.getCssClasses()).toContain('testee-capability-1'); await expect.poll(() => viewPage.view.tab.getCssClasses()).toContain('testee-capability-1'); diff --git a/projects/scion/e2e-testing/src/workbench.model.ts b/projects/scion/e2e-testing/src/workbench.model.ts index e248703f5..a5d762ddd 100644 --- a/projects/scion/e2e-testing/src/workbench.model.ts +++ b/projects/scion/e2e-testing/src/workbench.model.ts @@ -9,7 +9,7 @@ */ /** - * This files contains model classes of @scion/workbench. + * This file contains model classes of @scion/workbench. * * In Playwright tests, we cannot reference types from other modules, only interfaces, because they are erased when transpiled to JavaScript. */ diff --git a/projects/scion/e2e-testing/src/workbench/browser-history.e2e-spec.ts b/projects/scion/e2e-testing/src/workbench/browser-history.e2e-spec.ts index a4ce5338e..22d9f81c8 100644 --- a/projects/scion/e2e-testing/src/workbench/browser-history.e2e-spec.ts +++ b/projects/scion/e2e-testing/src/workbench/browser-history.e2e-spec.ts @@ -10,7 +10,6 @@ import {test} from '../fixtures'; import {RouterPagePO} from './page-object/router-page.po'; -import {LayoutPagePO} from './page-object/layout-page.po'; import {StandaloneViewTestPagePO} from './page-object/test-pages/standalone-view-test-page.po'; import {NonStandaloneViewTestPagePO} from './page-object/test-pages/non-standalone-view-test-page.po'; import {MAIN_AREA} from '../workbench.model'; @@ -23,8 +22,7 @@ test.describe('Browser History', () => { await appPO.navigateTo({microfrontendSupport: false}); // Add part to the workbench grid - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - await layoutPage.addPart('left', {relativeTo: MAIN_AREA, align: 'left', ratio: .25}); + await workbenchNavigator.modifyLayout(layout => layout.addPart('left', {relativeTo: MAIN_AREA, align: 'left', ratio: .25})); // Add view-1 to the left part const routerPage = await workbenchNavigator.openInNewTab(RouterPagePO); diff --git a/projects/scion/e2e-testing/src/workbench/dialog.e2e-spec.ts b/projects/scion/e2e-testing/src/workbench/dialog.e2e-spec.ts index c19fb973c..5378b3e3c 100644 --- a/projects/scion/e2e-testing/src/workbench/dialog.e2e-spec.ts +++ b/projects/scion/e2e-testing/src/workbench/dialog.e2e-spec.ts @@ -44,7 +44,7 @@ test.describe('Workbench Dialog', () => { const dialogOpenerPage = await workbenchNavigator.openInNewTab(DialogOpenerPagePO); // Expect to error when opening the dialog. - await expect(dialogOpenerPage.open('dialog-page', {modality: 'view', context: {viewId: 'non-existent'}})).rejects.toThrow('[NullViewError] View \'non-existent\' not found.'); + await expect(dialogOpenerPage.open('dialog-page', {modality: 'view', context: {viewId: 'view.100'}})).rejects.toThrow('[NullViewError] View \'view.100\' not found.'); // Expect no error to be logged to the console. await expect.poll(() => consoleLogs.get({severity: 'error'})).toEqual([]); diff --git a/projects/scion/e2e-testing/src/workbench/maximize-main-area.e2e-spec.ts b/projects/scion/e2e-testing/src/workbench/maximize-main-area.e2e-spec.ts index f2d52bcb4..2eb61ad5f 100644 --- a/projects/scion/e2e-testing/src/workbench/maximize-main-area.e2e-spec.ts +++ b/projects/scion/e2e-testing/src/workbench/maximize-main-area.e2e-spec.ts @@ -9,8 +9,6 @@ */ import {test} from '../fixtures'; -import {PerspectivePagePO} from './page-object/perspective-page.po'; -import {LayoutPagePO} from './page-object/layout-page.po'; import {MAIN_AREA} from '../workbench.model'; import {RouterPagePO} from './page-object/router-page.po'; import {expect} from '@playwright/test'; @@ -21,27 +19,12 @@ test.describe('Workbench', () => { test('should allow maximizing the main area', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - // Register Angular routes. - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - await layoutPage.registerRoute({outlet: 'view', path: '', component: 'view-page'}); - await layoutPage.view.tab.close(); - - // Register perspective. - const perspectivePage = await workbenchNavigator.openInNewTab(PerspectivePagePO); - await perspectivePage.registerPerspective({ - id: 'perspective', - parts: [ - {id: MAIN_AREA}, - {id: 'left', relativeTo: MAIN_AREA, align: 'left', ratio: .2}, - ], - views: [ - {id: 'view', partId: 'left', activateView: true}, - ], - }); - await perspectivePage.view.tab.close(); - - // Activate the perspective. - await appPO.switchPerspective('perspective'); + await workbenchNavigator.createPerspective(factory => factory + .addPart(MAIN_AREA) + .addPart('left', {relativeTo: MAIN_AREA, align: 'left', ratio: .2}) + .addView('view.100', {partId: 'left', activateView: true}) + .navigateView('view.100', ['test-view']), + ); // Open view 1 in main area. const routerPage = await workbenchNavigator.openInNewTab(RouterPagePO); @@ -75,8 +58,8 @@ test.describe('Workbench', () => { ratio: .2, child1: new MPart({ id: 'left', - views: [{id: 'view'}], - activeViewId: 'view', + views: [{id: 'view.100'}], + activeViewId: 'view.100', }), child2: new MPart({id: MAIN_AREA}), }), @@ -125,8 +108,8 @@ test.describe('Workbench', () => { ratio: .2, child1: new MPart({ id: 'left', - views: [{id: 'view'}], - activeViewId: 'view', + views: [{id: 'view.100'}], + activeViewId: 'view.100', }), child2: new MPart({id: MAIN_AREA}), }), diff --git a/projects/scion/e2e-testing/src/workbench/move-view-to-new-window.e2e-spec.ts b/projects/scion/e2e-testing/src/workbench/move-view-to-new-window.e2e-spec.ts index 89ba7ebf5..e4bed0aea 100644 --- a/projects/scion/e2e-testing/src/workbench/move-view-to-new-window.e2e-spec.ts +++ b/projects/scion/e2e-testing/src/workbench/move-view-to-new-window.e2e-spec.ts @@ -14,37 +14,31 @@ import {RouterPagePO} from './page-object/router-page.po'; import {ViewPagePO} from './page-object/view-page.po'; import {MPart, MTreeNode} from '../matcher/to-equal-workbench-layout.matcher'; import {MAIN_AREA} from '../workbench.model'; -import {PerspectivePagePO} from './page-object/perspective-page.po'; import {WorkbenchNavigator} from './workbench-navigator'; -import {LayoutPagePO} from './page-object/layout-page.po'; import {getPerspectiveId} from '../helper/testing.util'; import {expectView} from '../matcher/view-matcher'; test.describe('Workbench View', () => { - test('should allow moving a named view in the workbench grid to a new window', async ({appPO, workbenchNavigator}) => { + test('should move a path-based view in the workbench grid to a new window', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - // Define perspective with a view in the workbench grid. - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - await layoutPage.addPart('left', {align: 'left', ratio: .25}); - await layoutPage.addView('other', {partId: 'left', activateView: true}); - await layoutPage.addView('testee', {partId: 'left', activateView: true}); - await layoutPage.registerRoute({path: '', component: 'view-page', outlet: 'other'}); - await layoutPage.registerRoute({path: '', component: 'view-page', outlet: 'testee'}); + // Add two views to the peripheral area. + await workbenchNavigator.modifyLayout(layout => layout + .addPart('left', {align: 'left', ratio: .25}) + .addView('view.101', {partId: 'left', activateView: true}) + .addView('view.102', {partId: 'left', activateView: true}) + .navigateView('view.101', ['test-view']) + .navigateView('view.102', ['test-view']), + ); // Move test view to new window. - const newAppPO = await appPO.view({viewId: 'testee'}).tab.moveToNewWindow(); + const newAppPO = await appPO.view({viewId: 'view.101'}).tab.moveToNewWindow(); const newWindow = { appPO: newAppPO, workbenchNavigator: new WorkbenchNavigator(newAppPO), }; - // Register route for named view. - const newWindowLayoutPage = await newWindow.workbenchNavigator.openInNewTab(LayoutPagePO); - await newWindowLayoutPage.registerRoute({path: '', component: 'view-page', outlet: 'testee'}); - await newWindowLayoutPage.view.tab.close(); - // Expect test view to be moved to the new window. await expect(newAppPO.workbench).toEqualWorkbenchLayout({ workbenchGrid: { @@ -52,14 +46,14 @@ test.describe('Workbench View', () => { }, mainAreaGrid: { root: new MPart({ - views: [{id: 'testee'}], - activeViewId: 'testee', + views: [{id: 'view.1'}], + activeViewId: 'view.1', }), }, }); // Expect test view to display. - const viewPage = new ViewPagePO(newWindow.appPO, {viewId: 'testee'}); + const viewPage = new ViewPagePO(newWindow.appPO, {viewId: 'view.1'}); await expectView(viewPage).toBeActive(); // Expect test view to be removed from the origin window. @@ -68,41 +62,31 @@ test.describe('Workbench View', () => { root: new MTreeNode({ direction: 'row', ratio: .25, - child1: new MPart({id: 'left', views: [{id: 'other'}], activeViewId: 'other'}), + child1: new MPart({id: 'left', views: [{id: 'view.102'}], activeViewId: 'view.102'}), child2: new MPart({id: MAIN_AREA}), }), }, - mainAreaGrid: { - root: new MPart({ - views: [{id: await layoutPage.view.getViewId()}], - activeViewId: await layoutPage.view.getViewId(), - }), - }, }); }); - test('should allow moving an unnamed view in the workbench grid to a new window', async ({appPO, workbenchNavigator}) => { + test('should move an empty-path view in the workbench grid to a new window', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); // Define perspective with a view in the workbench grid. - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - await layoutPage.addPart('left', {align: 'left', ratio: .25}); - await layoutPage.addView('other', {partId: 'left', activateView: true}); - await layoutPage.registerRoute({path: '', component: 'view-page', outlet: 'other'}); - - // Open test view in workbench grid. - const routerPage = await workbenchNavigator.openInNewTab(RouterPagePO); - await routerPage.enterPath('test-view'); - await routerPage.enterBlankPartId('left'); - await routerPage.enterCssClass('testee'); - await routerPage.clickNavigate(); - await routerPage.view.tab.close(); - - const testViewPage = appPO.view({cssClass: 'testee'}); - const testViewId = await testViewPage.getViewId(); + await workbenchNavigator.modifyLayout(layout => layout + .addPart('left', {align: 'left', ratio: .25}) + .addView('view.101', {partId: 'left', activateView: true}) + .addView('view.102', {partId: 'left', activateView: true}) + .navigateView('view.101', [], {hint: 'test-view'}) + .navigateView('view.102', [], {hint: 'test-view'}), + ); // Move test view to new window. - const newAppPO = await appPO.view({viewId: testViewId}).tab.moveToNewWindow(); + const newAppPO = await appPO.view({viewId: 'view.101'}).tab.moveToNewWindow(); + const newWindow = { + appPO: newAppPO, + workbenchNavigator: new WorkbenchNavigator(newAppPO), + }; // Expect test view to be moved to the new window. await expect(newAppPO.workbench).toEqualWorkbenchLayout({ @@ -111,14 +95,14 @@ test.describe('Workbench View', () => { }, mainAreaGrid: { root: new MPart({ - views: [{id: testViewId}], - activeViewId: testViewId, + views: [{id: 'view.1'}], + activeViewId: 'view.1', }), }, }); // Expect test view to display. - const viewPage = new ViewPagePO(newAppPO, {viewId: testViewId}); + const viewPage = new ViewPagePO(newWindow.appPO, {viewId: 'view.1'}); await expectView(viewPage).toBeActive(); // Expect test view to be removed from the origin window. @@ -127,44 +111,29 @@ test.describe('Workbench View', () => { root: new MTreeNode({ direction: 'row', ratio: .25, - child1: new MPart({id: 'left', views: [{id: 'other'}], activeViewId: 'other'}), + child1: new MPart({id: 'left', views: [{id: 'view.102'}], activeViewId: 'view.102'}), child2: new MPart({id: MAIN_AREA}), }), }, - mainAreaGrid: { - root: new MPart({ - views: [{id: await layoutPage.view.getViewId()}], - activeViewId: await layoutPage.view.getViewId(), - }), - }, }); }); - test('should allow moving a named view in the main area to a new window', async ({appPO, workbenchNavigator}) => { + test('should move a path-based view in the main area to a new window', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - // Register route of named view - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - await layoutPage.registerRoute({path: '', component: 'view-page', outlet: 'testee'}); - // Open test view in main area. const routerPage = await workbenchNavigator.openInNewTab(RouterPagePO); - await routerPage.enterPath(''); - await routerPage.enterTarget('testee'); + await routerPage.enterPath('test-view'); + await routerPage.enterTarget('view.100'); await routerPage.clickNavigate(); // Move test view to new window. - const newAppPO = await appPO.view({viewId: 'testee'}).tab.moveToNewWindow(); + const newAppPO = await appPO.view({viewId: 'view.100'}).tab.moveToNewWindow(); const newWindow = { appPO: newAppPO, workbenchNavigator: new WorkbenchNavigator(newAppPO), }; - // Register route for named view. - const newWindowLayoutPage = await newWindow.workbenchNavigator.openInNewTab(LayoutPagePO); - await newWindowLayoutPage.registerRoute({path: '', component: 'view-page', outlet: 'testee'}); - await newWindowLayoutPage.view.tab.close(); - // Expect test view to be moved to the new window. await expect(newAppPO.workbench).toEqualWorkbenchLayout({ workbenchGrid: { @@ -172,14 +141,14 @@ test.describe('Workbench View', () => { }, mainAreaGrid: { root: new MPart({ - views: [{id: 'testee'}], - activeViewId: 'testee', + views: [{id: 'view.1'}], + activeViewId: 'view.1', }), }, }); // Expect test view to display. - const viewPage = new ViewPagePO(newWindow.appPO, {viewId: 'testee'}); + const viewPage = new ViewPagePO(newWindow.appPO, {viewId: 'view.1'}); await expectView(viewPage).toBeActive(); // Expect test view to be removed from the origin window. @@ -190,7 +159,6 @@ test.describe('Workbench View', () => { mainAreaGrid: { root: new MPart({ views: [ - {id: await layoutPage.view.getViewId()}, {id: await routerPage.view.getViewId()}, ], activeViewId: await routerPage.view.getViewId(), @@ -199,7 +167,7 @@ test.describe('Workbench View', () => { }); }); - test('should allow moving an unnamed view in the main area to a new window', async ({appPO, workbenchNavigator}) => { + test('should move an empty-path view in the main area to a new window', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); // Open test view in main area. @@ -209,7 +177,6 @@ test.describe('Workbench View', () => { await routerPage.clickNavigate(); const testViewPage = appPO.view({cssClass: 'testee'}); - const testViewId = await testViewPage.getViewId(); // Move test view to new window. const newAppPO = await testViewPage.tab.moveToNewWindow(); @@ -221,14 +188,14 @@ test.describe('Workbench View', () => { }, mainAreaGrid: { root: new MPart({ - views: [{id: testViewId}], - activeViewId: testViewId, + views: [{id: 'view.1'}], + activeViewId: 'view.1', }), }, }); // Expect test view to display. - const viewPage = new ViewPagePO(newAppPO, {viewId: testViewId}); + const viewPage = new ViewPagePO(newAppPO, {viewId: 'view.1'}); await expectView(viewPage).toBeActive(); // Expect test view to be removed from the origin window. @@ -245,23 +212,21 @@ test.describe('Workbench View', () => { }); }); - test('should allow moving a view to another window', async ({appPO, workbenchNavigator}) => { + test('should move a view to another window', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - // Open view 1 (path-based view) - const view1 = (await workbenchNavigator.openInNewTab(ViewPagePO)).view; - // Open view 2 (path-based view) - const view2 = (await workbenchNavigator.openInNewTab(ViewPagePO)).view; - // Open view 3 (empty-path view) - const routerPage = await workbenchNavigator.openInNewTab(RouterPagePO); - await routerPage.enterPath(''); - await routerPage.enterTarget('outlet'); // TODO [WB-LAYOUT] Create test views via layout - await routerPage.clickNavigate(); - await routerPage.view.tab.close(); - const view3 = appPO.view({viewId: 'outlet'}); + await workbenchNavigator.createPerspective(factory => factory + .addPart('part') + .addView('view.101', {partId: 'part'}) + .addView('view.102', {partId: 'part'}) + .addView('view.103', {partId: 'part', activateView: true}) + .navigateView('view.101', ['test-view']) // path-based view + .navigateView('view.102', ['test-view']) // path-based view + .navigateView('view.103', [], {hint: 'test-view'}), // empty-path view + ); // Move view 1 to a new window - const newAppPO = await view1.tab.moveToNewWindow(); + const newAppPO = await appPO.view({viewId: 'view.101'}).tab.moveToNewWindow(); // Expect view 1 to be moved to the new window. await expect(newAppPO.workbench).toEqualWorkbenchLayout({ workbenchGrid: { @@ -279,22 +244,19 @@ test.describe('Workbench View', () => { // Expect view 1 to be removed from the original window. await expect(appPO.workbench).toEqualWorkbenchLayout({ workbenchGrid: { - root: new MPart({id: MAIN_AREA}), - }, - mainAreaGrid: { root: new MPart({ views: [ - {id: 'view.2'}, - {id: 'outlet'}, + {id: 'view.102'}, + {id: 'view.103'}, ], - activeViewId: 'outlet', + activeViewId: 'view.103', }), }, }); // Move view 2 to the new window - await view2.tab.moveTo(await newAppPO.activePart({inMainArea: true}).getPartId(), { - workbenchId: await newAppPO.getWorkbenchIdId(), + await appPO.view({viewId: 'view.102'}).tab.moveTo(await newAppPO.activePart({inMainArea: true}).getPartId(), { + workbenchId: await newAppPO.getWorkbenchId(), }); // Expect view 2 to be moved to the new window. await expect(newAppPO.workbench).toEqualWorkbenchLayout({ @@ -314,21 +276,18 @@ test.describe('Workbench View', () => { // Expect view 2 to be removed from the original window. await expect(appPO.workbench).toEqualWorkbenchLayout({ workbenchGrid: { - root: new MPart({id: MAIN_AREA}), - }, - mainAreaGrid: { root: new MPart({ views: [ - {id: 'outlet'}, + {id: 'view.103'}, ], - activeViewId: 'outlet', + activeViewId: 'view.103', }), }, }); // Move view 3 (empty-path view) to the new window - await view3.tab.moveTo(await newAppPO.activePart({inMainArea: true}).getPartId(), { - workbenchId: await newAppPO.getWorkbenchIdId(), + await appPO.view({viewId: 'view.103'}).tab.moveTo(await newAppPO.activePart({inMainArea: true}).getPartId(), { + workbenchId: await newAppPO.getWorkbenchId(), }); // Expect view 3 to be moved to the new window. await expect(newAppPO.workbench).toEqualWorkbenchLayout({ @@ -340,9 +299,9 @@ test.describe('Workbench View', () => { views: [ {id: 'view.1'}, {id: 'view.2'}, - {id: 'outlet'}, + {id: 'view.3'}, ], - activeViewId: 'outlet', + activeViewId: 'view.3', }), }, }); @@ -360,7 +319,6 @@ test.describe('Workbench View', () => { await routerPage.clickNavigate(); const testViewPage = appPO.view({cssClass: 'test-view'}); - const testViewId = await testViewPage.getViewId(); // Move test view to new window. const newAppPO = await testViewPage.tab.moveToNewWindow(); @@ -398,8 +356,8 @@ test.describe('Workbench View', () => { }, mainAreaGrid: { root: new MPart({ - views: [{id: testViewId}], - activeViewId: testViewId, + views: [{id: 'view.1'}], + activeViewId: 'view.1', }), }, }); @@ -407,19 +365,8 @@ test.describe('Workbench View', () => { // Capture name of anonymous perspective. const anonymousPerspectiveName = await getPerspectiveId(newWindow.page); - // Register new perspective. - const perspectivePage = await newWindow.workbenchNavigator.openInNewTab(PerspectivePagePO); - await perspectivePage.registerPerspective({ - id: 'test-blank', - data: { - label: 'blank', - }, - parts: [{id: MAIN_AREA}], - }); - await perspectivePage.view.tab.close(); - // Switch to the new perspective. - await newWindow.appPO.switchPerspective('test-blank'); + await newWindow.workbenchNavigator.createPerspective(factory => factory.addPart(MAIN_AREA)); // Expect the layout to be blank. await expect(newWindow.appPO.workbench).toEqualWorkbenchLayout({ @@ -428,8 +375,8 @@ test.describe('Workbench View', () => { }, mainAreaGrid: { root: new MPart({ - views: [{id: testViewId}], - activeViewId: testViewId, + views: [{id: 'view.1'}], + activeViewId: 'view.1', }), }, }); @@ -452,8 +399,8 @@ test.describe('Workbench View', () => { }, mainAreaGrid: { root: new MPart({ - views: [{id: testViewId}], - activeViewId: testViewId, + views: [{id: 'view.1'}], + activeViewId: 'view.1', }), }, }); @@ -469,7 +416,6 @@ test.describe('Workbench View', () => { await routerPage.clickNavigate(); const testViewPage = appPO.view({cssClass: 'test-view'}); - const testViewId = await testViewPage.getViewId(); // Move test view to new window. const newAppPO = await testViewPage.tab.moveToNewWindow(); @@ -506,8 +452,8 @@ test.describe('Workbench View', () => { }, mainAreaGrid: { root: new MPart({ - views: [{id: testViewId}], - activeViewId: testViewId, + views: [{id: 'view.1'}], + activeViewId: 'view.1', }), }, }); @@ -522,8 +468,8 @@ test.describe('Workbench View', () => { }, mainAreaGrid: { root: new MPart({ - views: [{id: testViewId}], - activeViewId: testViewId, + views: [{id: 'view.1'}], + activeViewId: 'view.1', }), }, }); diff --git a/projects/scion/e2e-testing/src/workbench/navigational-state.e2e-spec.ts b/projects/scion/e2e-testing/src/workbench/navigational-state.e2e-spec.ts index ea72d278c..c67dfd01f 100644 --- a/projects/scion/e2e-testing/src/workbench/navigational-state.e2e-spec.ts +++ b/projects/scion/e2e-testing/src/workbench/navigational-state.e2e-spec.ts @@ -21,23 +21,36 @@ test.describe('Navigational State', () => { const routerPage = await workbenchNavigator.openInNewTab(RouterPagePO); await routerPage.enterPath('test-view'); - await routerPage.enterTarget('view.101'); + await routerPage.enterCssClass('testee'); await routerPage.clickNavigate(); - const viewPage = new ViewPagePO(appPO, {viewId: 'view.101'}); + const viewPage = new ViewPagePO(appPO, {cssClass: 'testee'}); await expect.poll(() => viewPage.getState()).toEqual({}); }); - test('should have state passed', async ({appPO, workbenchNavigator}) => { + test('should pass state (WorkbenchRouter.navigate)', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); const routerPage = await workbenchNavigator.openInNewTab(RouterPagePO); await routerPage.enterPath('test-view'); await routerPage.enterState({some: 'state'}); - await routerPage.enterTarget('view.101'); + await routerPage.enterCssClass('testee'); await routerPage.clickNavigate(); - const viewPage = new ViewPagePO(appPO, {viewId: 'view.101'}); + const viewPage = new ViewPagePO(appPO, {cssClass: 'testee'}); + await expect.poll(() => viewPage.getState()).toEqual({some: 'state'}); + }); + + test('should pass state (WorkbenchLayout.navigateView)', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + await workbenchNavigator.modifyLayout(layout => layout + .addPart('right', {align: 'right'}) + .addView('testee', {partId: 'right', activateView: true, cssClass: 'testee'}) + .navigateView('testee', ['test-view'], {state: {some: 'state'}}), + ); + + const viewPage = new ViewPagePO(appPO, {cssClass: 'testee'}); await expect.poll(() => viewPage.getState()).toEqual({some: 'state'}); }); @@ -55,10 +68,10 @@ test.describe('Navigational State', () => { state6: '', state7: '', }); - await routerPage.enterTarget('view.101'); + await routerPage.enterCssClass('testee'); await routerPage.clickNavigate(); - const viewPage = new ViewPagePO(appPO, {viewId: 'view.101'}); + const viewPage = new ViewPagePO(appPO, {cssClass: 'testee'}); await expect.poll(() => viewPage.getState()).toEqual({ state1: 'value', state2: '0 [number]', @@ -75,20 +88,19 @@ test.describe('Navigational State', () => { // Navigate view const routerPage = await workbenchNavigator.openInNewTab(RouterPagePO); - await routerPage.enterPath('test-view'); + await routerPage.enterCommands(['test-view']); await routerPage.enterState({state1: 'state 1'}); - await routerPage.enterTarget('view.101'); + await routerPage.enterCssClass('testee'); await routerPage.clickNavigate(); - const viewPage = new ViewPagePO(appPO, {viewId: 'view.101'}); + const viewPage = new ViewPagePO(appPO, {cssClass: 'testee'}); await expect.poll(() => viewPage.getState()).toEqual({state1: 'state 1'}); // Navigate view again with a different state await routerPage.view.tab.click(); - await routerPage.enterPath('test-view'); - await routerPage.enterMatrixParams({matrix: 'param'}); + await routerPage.enterCommands(['test-view', {matrix: 'param'}]); await routerPage.enterState({state2: 'state 2'}); - await routerPage.enterTarget('view.101'); + await routerPage.enterCssClass('testee'); await routerPage.clickNavigate(); await expect.poll(() => viewPage.getState()).toEqual({state2: 'state 2'}); @@ -96,10 +108,8 @@ test.describe('Navigational State', () => { // Navigate view again without state await routerPage.view.tab.click(); - await routerPage.enterPath('test-view'); - await routerPage.enterMatrixParams({}); - await routerPage.enterState({}); - await routerPage.enterTarget('view.101'); + await routerPage.enterCommands(['test-view']); + await routerPage.enterCssClass('testee'); await routerPage.clickNavigate(); await expect.poll(() => viewPage.getState()).toEqual({}); @@ -107,8 +117,9 @@ test.describe('Navigational State', () => { // Navigate view again with a different state await routerPage.view.tab.click(); - await routerPage.enterPath('test-view'); + await routerPage.enterCommands(['test-view']); await routerPage.enterState({state3: 'state 3'}); + await routerPage.enterCssClass('testee'); await routerPage.clickNavigate(); await expect.poll(() => viewPage.getState()).toEqual({state3: 'state 3'}); @@ -120,10 +131,10 @@ test.describe('Navigational State', () => { const routerPage = await workbenchNavigator.openInNewTab(RouterPagePO); await routerPage.enterPath('test-view'); await routerPage.enterState({some: 'state'}); - await routerPage.enterTarget('view.101'); + await routerPage.enterCssClass('testee'); await routerPage.clickNavigate(); - const viewPage = new ViewPagePO(appPO, {viewId: 'view.101'}); + const viewPage = new ViewPagePO(appPO, {cssClass: 'testee'}); await expect.poll(() => viewPage.getState()).toEqual({some: 'state'}); await appPO.reload(); @@ -136,15 +147,14 @@ test.describe('Navigational State', () => { const routerPage = await workbenchNavigator.openInNewTab(RouterPagePO); await routerPage.enterPath('test-view'); await routerPage.enterState({some: 'state'}); - await routerPage.enterTarget('view.101'); + await routerPage.enterCssClass('testee'); await routerPage.clickNavigate(); - const viewPage = new ViewPagePO(appPO, {viewId: 'view.101'}); + const viewPage = new ViewPagePO(appPO, {cssClass: 'testee'}); await expect.poll(() => viewPage.getState()).toEqual({some: 'state'}); await routerPage.view.tab.click(); await routerPage.enterPath('test-view'); - await routerPage.enterState({}); await routerPage.enterTarget('blank'); await routerPage.clickNavigate(); @@ -156,29 +166,38 @@ test.describe('Navigational State', () => { test('should maintain state when navigating back and forth in browser history', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - const routerPage = await workbenchNavigator.openInNewTab(RouterPagePO); - await routerPage.enterPath('test-view'); - await routerPage.enterTarget('view.101'); - await routerPage.clickNavigate(); + await workbenchNavigator.createPerspective(factory => factory + .addPart('left') + .addPart('right', {align: 'right'}) + .addView('router', {partId: 'left', activateView: true, cssClass: 'router'}) + .addView('testee', {partId: 'right', activateView: true, cssClass: 'testee'}) + .navigateView('router', ['test-router']) + .navigateView('testee', ['test-view']), + ); - const viewPage = new ViewPagePO(appPO, {viewId: 'view.101'}); - await viewPage.view.tab.moveTo(await viewPage.view.part.getPartId(), {region: 'east'}); + const viewPage = new ViewPagePO(appPO, {cssClass: 'testee'}); + const routerPage = new RouterPagePO(appPO, {cssClass: 'router'}); await routerPage.enterPath('test-view'); await routerPage.enterState({'state': 'a'}); - await routerPage.enterTarget('view.101'); + await routerPage.enterTarget('testee'); await routerPage.clickNavigate(); await expect.poll(() => viewPage.getState()).toEqual({state: 'a'}); + // Move the view to the left and back again, simulating navigation without explicitly setting the state. + // When navigating back, expect the view state to be restored. + await viewPage.view.tab.moveTo('left'); + await viewPage.view.tab.moveTo('right'); + await routerPage.enterPath('test-view'); await routerPage.enterState({'state': 'b'}); - await routerPage.enterTarget('view.101'); + await routerPage.enterTarget('testee'); await routerPage.clickNavigate(); await expect.poll(() => viewPage.getState()).toEqual({state: 'b'}); await routerPage.enterPath('test-view'); await routerPage.enterState({'state': 'c'}); - await routerPage.enterTarget('view.101'); + await routerPage.enterTarget('testee'); await routerPage.clickNavigate(); await expect.poll(() => viewPage.getState()).toEqual({state: 'c'}); @@ -198,30 +217,32 @@ test.describe('Navigational State', () => { test('should maintain state when navigating through the Angular router', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - // Open Workbench Router - const routerPage = await workbenchNavigator.openInNewTab(RouterPagePO); - - // Open Angular router - await routerPage.enterPath('test-pages/angular-router-test-page'); - await routerPage.enterTarget('view.101'); - await routerPage.clickNavigate(); - const angularRouterPage = new AngularRouterTestPagePO(appPO, {viewId: 'view.101'}); + await workbenchNavigator.createPerspective(factory => factory + .addPart('left') + .addPart('right', {align: 'right'}) + .addView('workbench-router', {partId: 'left', activateView: true, cssClass: 'workbench-router'}) + .addView('angular-router', {partId: 'left', cssClass: 'angular-router'}) + .navigateView('workbench-router', ['test-router']) + .navigateView('angular-router', ['test-pages/angular-router-test-page']), + ); // Open test view - await routerPage.view.tab.click(); - await routerPage.enterPath('test-view'); + const routerPage = new RouterPagePO(appPO, {cssClass: 'workbench-router'}); + await routerPage.enterCommands(['test-view']); await routerPage.enterState({some: 'state'}); - await routerPage.enterTarget('view.102'); + await routerPage.enterCssClass('testee'); + await routerPage.enterTarget('blank'); + await routerPage.enterBlankPartId('right'); await routerPage.clickNavigate(); - const viewPage = new ViewPagePO(appPO, {viewId: 'view.102'}); - await viewPage.view.tab.moveTo(await viewPage.view.part.getPartId(), {region: 'east'}); // Expect view state to be passed to the view. + const viewPage = new ViewPagePO(appPO, {cssClass: 'testee'}); await expect.poll(() => viewPage.getState()).toEqual({some: 'state'}); // Navigate through the Angular router + const angularRouterPage = new AngularRouterTestPagePO(appPO, {cssClass: 'angular-router'}); await angularRouterPage.view.tab.click(); - await angularRouterPage.navigate('test-view', {outlet: await angularRouterPage.view.getViewId()}); + await angularRouterPage.navigate(['test-view'], {outlet: await angularRouterPage.view.getViewId()}); // Expect view state to be preserved. await expect.poll(() => viewPage.getState()).toEqual({some: 'state'}); diff --git a/projects/scion/e2e-testing/src/workbench/page-object/blank-view-page.po.ts b/projects/scion/e2e-testing/src/workbench/page-object/blank-view-page.po.ts new file mode 100644 index 000000000..801e50877 --- /dev/null +++ b/projects/scion/e2e-testing/src/workbench/page-object/blank-view-page.po.ts @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2018-2024 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 {AppPO} from '../../app.po'; +import {ViewPO} from '../../view.po'; +import {ViewId} from '@scion/workbench'; +import {WorkbenchViewPagePO} from './workbench-view-page.po'; +import {Locator} from '@playwright/test'; + +/** + * Page object to interact with a workbench view, which has not been navigated. + */ +export class BlankViewPagePO implements WorkbenchViewPagePO { + + public readonly view: ViewPO; + public readonly locator: Locator; + + constructor(appPO: AppPO, locateBy: {viewId?: ViewId; cssClass?: string}) { + this.view = appPO.view({viewId: locateBy?.viewId, cssClass: locateBy?.cssClass}); + this.locator = this.view.locator.locator(':scope:has(router-outlet:first-child:last-child)'); + } +} diff --git a/projects/scion/e2e-testing/src/workbench/page-object/layout-page.po.ts b/projects/scion/e2e-testing/src/workbench/page-object/layout-page.po.ts deleted file mode 100644 index 0c66c5341..000000000 --- a/projects/scion/e2e-testing/src/workbench/page-object/layout-page.po.ts +++ /dev/null @@ -1,122 +0,0 @@ -/* - * 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, rejectWhenAttached, waitUntilAttached} from '../../helper/testing.util'; -import {AppPO} from '../../app.po'; -import {ViewPO} from '../../view.po'; -import {SciCheckboxPO} from '../../@scion/components.internal/checkbox.po'; -import {Locator} from '@playwright/test'; -import {ReferencePart} from '@scion/workbench'; -import {SciTabbarPO} from '../../@scion/components.internal/tabbar.po'; -import {WorkbenchViewPagePO} from './workbench-view-page.po'; - -/** - * Page object to interact with {@link LayoutPageComponent}. - */ -export class LayoutPagePO implements WorkbenchViewPagePO { - - public readonly locator: Locator; - public readonly view: ViewPO; - - private readonly _tabbar: SciTabbarPO; - - constructor(appPO: AppPO, locateBy: {viewId?: string; cssClass?: string}) { - this.view = appPO.view({viewId: locateBy?.viewId, cssClass: locateBy?.cssClass}); - this.locator = this.view.locator.locator('app-layout-page'); - this._tabbar = new SciTabbarPO(this.locator.locator('sci-tabbar')); - } - - public async addPart(partId: string, relativeTo: ReferencePart, options?: {activate?: boolean}): Promise { - const locator = this.locator.locator('app-add-part-page'); - - await this.view.tab.click(); - await this._tabbar.selectTab('e2e-add-part'); - await locator.locator('section.e2e-part').locator('input.e2e-part-id').fill(partId); - await new SciCheckboxPO(locator.locator('section.e2e-part').locator('sci-checkbox.e2e-activate')).toggle(options?.activate ?? false); - await locator.locator('section.e2e-reference-part').locator('input.e2e-part-id').fill(relativeTo.relativeTo ?? ''); - await locator.locator('section.e2e-reference-part').locator('select.e2e-align').selectOption(relativeTo.align); - await locator.locator('section.e2e-reference-part').locator('input.e2e-ratio').fill(relativeTo.ratio ? `${relativeTo.ratio}` : ''); - await locator.locator('button.e2e-navigate').click(); - - // Evaluate the response: resolve the promise on success, or reject it on error. - await Promise.race([ - waitUntilAttached(this.locator.locator('output.e2e-navigate-success')), - rejectWhenAttached(this.locator.locator('output.e2e-navigate-error')), - ]); - } - - public async addView(viewId: string, options: {partId: string; position?: number; activateView?: boolean; activatePart?: boolean}): Promise { - const locator = this.locator.locator('app-add-view-page'); - - await this.view.tab.click(); - await this._tabbar.selectTab('e2e-add-view'); - await locator.locator('section.e2e-view').locator('input.e2e-view-id').fill(viewId); - await locator.locator('section.e2e-view-options').locator('input.e2e-part-id').fill(options.partId); - options.position && await locator.locator('section.e2e-view-options').locator('input.e2e-position').fill(`${options.position}`); - await new SciCheckboxPO(locator.locator('section.e2e-view-options').locator('sci-checkbox.e2e-activate-view')).toggle(options.activateView ?? false); - await new SciCheckboxPO(locator.locator('section.e2e-view-options').locator('sci-checkbox.e2e-activate-part')).toggle(options.activatePart ?? false); - await locator.locator('button.e2e-navigate').click(); - - // Evaluate the response: resolve the promise on success, or reject it on error. - await Promise.race([ - waitUntilAttached(this.locator.locator('output.e2e-navigate-success')), - rejectWhenAttached(this.locator.locator('output.e2e-navigate-error')), - ]); - } - - public async activateView(viewId: string, options?: {activatePart?: boolean}): Promise { - const locator = this.locator.locator('app-activate-view-page'); - - await this.view.tab.click(); - await this._tabbar.selectTab('e2e-activate-view'); - await locator.locator('section.e2e-view').locator('input.e2e-view-id').fill(viewId); - await new SciCheckboxPO(locator.locator('section.e2e-view-options').locator('sci-checkbox.e2e-activate-part')).toggle(options?.activatePart ?? false); - await locator.locator('button.e2e-navigate').click(); - - // Evaluate the response: resolve the promise on success, or reject it on error. - await Promise.race([ - waitUntilAttached(this.locator.locator('output.e2e-navigate-success')), - rejectWhenAttached(this.locator.locator('output.e2e-navigate-error')), - ]); - } - - public async registerPartAction(content: string, options?: {align?: 'start' | 'end'; viewId?: string | string[]; partId?: string | string[]; grid?: 'workbench' | 'mainArea'; cssClass?: string | string[]}): Promise { - const locator = this.locator.locator('app-register-part-action-page'); - - await this.view.tab.click(); - await this._tabbar.selectTab('e2e-register-part-action'); - await locator.locator('section').locator('input.e2e-content').fill(content); - await locator.locator('section').locator('select.e2e-align').selectOption(options?.align ?? ''); - await locator.locator('section').locator('input.e2e-class').fill(coerceArray(options?.cssClass).join(' ')); - await locator.locator('section.e2e-can-match').locator('input.e2e-view-id').fill(coerceArray(options?.viewId).join(' ')); - await locator.locator('section.e2e-can-match').locator('input.e2e-part-id').fill(coerceArray(options?.partId).join(' ')); - await locator.locator('section.e2e-can-match').locator('input.e2e-grid').fill(options?.grid ?? ''); - await locator.locator('button.e2e-register').click(); - - // Evaluate the response: resolve the promise on success, or reject it on error. - await Promise.race([ - waitUntilAttached(this.locator.locator('output.e2e-register-success')), - rejectWhenAttached(this.locator.locator('output.e2e-register-error')), - ]); - } - - public async registerRoute(route: {path: string; component: 'view-page' | 'router-page'; outlet?: string}, routeData?: {title?: string; cssClass?: string | string[]}): Promise { - const locator = this.locator.locator('app-register-route-page'); - - await this._tabbar.selectTab('e2e-register-route'); - - await locator.locator('input.e2e-path').fill(route.path); - await locator.locator('input.e2e-component').fill(route.component); - await locator.locator('input.e2e-outlet').fill(route.outlet ?? ''); - await locator.locator('section.e2e-route-data').locator('input.e2e-title').fill(routeData?.title ?? ''); - await locator.locator('section.e2e-route-data').locator('input.e2e-css-class').fill(coerceArray(routeData?.cssClass).join(' ')); - await locator.locator('button.e2e-register').click(); - } -} diff --git a/projects/scion/e2e-testing/src/workbench/page-object/layout-page/create-perspective-page.po.ts b/projects/scion/e2e-testing/src/workbench/page-object/layout-page/create-perspective-page.po.ts new file mode 100644 index 000000000..4120fde89 --- /dev/null +++ b/projects/scion/e2e-testing/src/workbench/page-object/layout-page/create-perspective-page.po.ts @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2018-2024 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 {rejectWhenAttached, waitUntilAttached} from '../../../helper/testing.util'; +import {Locator} from '@playwright/test'; +import {SciCheckboxPO} from '../../../@scion/components.internal/checkbox.po'; +import {SciKeyValueFieldPO} from '../../../@scion/components.internal/key-value-field.po'; +import {WorkbenchLayout, WorkbenchLayoutFactory} from '@scion/workbench'; +import {LayoutPages} from './layout-pages.po'; +import {ɵWorkbenchLayout, ɵWorkbenchLayoutFactory} from './layout.model'; + +/** + * Page object to interact with {@link CreatePerspectivePageComponent}. + */ +export class CreatePerspectivePagePO { + + constructor(public locator: Locator) { + } + + public async createPerspective(id: string, definition: PerspectiveDefinition): Promise { + // Enter perspective data. + await this.locator.locator('input.e2e-id').fill(id); + await new SciCheckboxPO(this.locator.locator('sci-checkbox.e2e-transient')).toggle(definition.transient === true); + await this.enterData(definition.data); + + // Enter the layout. + const {parts, views, viewNavigations} = definition.layout(new ɵWorkbenchLayoutFactory()) as ɵWorkbenchLayout; + await LayoutPages.enterParts(this.locator.locator('app-add-parts'), parts); + await LayoutPages.enterViews(this.locator.locator('app-add-views'), views); + await LayoutPages.enterViewNavigations(this.locator.locator('app-navigate-views'), viewNavigations); + + // Register the perspective. + await this.locator.locator('button.e2e-register').click(); + + // Evaluate the response: resolve the promise on success, or reject it on error. + await Promise.race([ + waitUntilAttached(this.locator.locator('output.e2e-register-success')), + rejectWhenAttached(this.locator.locator('output.e2e-register-error')), + ]); + } + + private async enterData(data: {[key: string]: any} | undefined): Promise { + const keyValueField = new SciKeyValueFieldPO(this.locator.locator('sci-key-value-field.e2e-data')); + await keyValueField.clear(); + await keyValueField.addEntries(data ?? {}); + } +} + +export interface PerspectiveDefinition { + layout: (factory: WorkbenchLayoutFactory) => WorkbenchLayout; + data?: {[key: string]: any}; + transient?: true; +} diff --git a/projects/scion/e2e-testing/src/workbench/page-object/layout-page/layout-page.po.ts b/projects/scion/e2e-testing/src/workbench/page-object/layout-page/layout-page.po.ts new file mode 100644 index 000000000..f0993870a --- /dev/null +++ b/projects/scion/e2e-testing/src/workbench/page-object/layout-page/layout-page.po.ts @@ -0,0 +1,70 @@ +/* + * 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 {AppPO} from '../../../app.po'; +import {ViewPO} from '../../../view.po'; +import {Locator} from '@playwright/test'; +import {ViewId, WorkbenchLayout} from '@scion/workbench'; +import {SciTabbarPO} from '../../../@scion/components.internal/tabbar.po'; +import {WorkbenchViewPagePO} from '../workbench-view-page.po'; +import {RegisterPartActionPagePO} from './register-part-action-page.po'; +import {ModifyLayoutPagePO} from './modify-layout-page.po'; +import {CreatePerspectivePagePO, PerspectiveDefinition} from './create-perspective-page.po'; + +/** + * Page object to interact with {@link LayoutPageComponent}. + */ +export class LayoutPagePO implements WorkbenchViewPagePO { + + public readonly locator: Locator; + public readonly view: ViewPO; + + private readonly _tabbar: SciTabbarPO; + + constructor(appPO: AppPO, locateBy: {viewId?: ViewId; cssClass?: string}) { + this.view = appPO.view({viewId: locateBy?.viewId, cssClass: locateBy?.cssClass}); + this.locator = this.view.locator.locator('app-layout-page'); + this._tabbar = new SciTabbarPO(this.locator.locator('sci-tabbar')); + } + + /** + * Creates a perspective based on the given definition. + * + * @see WorkbenchService.registerPerspective + */ + public async createPerspective(id: string, definition: PerspectiveDefinition): Promise { + await this.view.tab.click(); + await this._tabbar.selectTab('e2e-create-perspective'); + + const createPerspectivePage = new CreatePerspectivePagePO(this.locator.locator('app-create-perspective-page')); + return createPerspectivePage.createPerspective(id, definition); + } + + /** + * Modifies the current workbench layout. + * + * @see WorkbenchRouter.ɵnavigate + */ + public async modifyLayout(fn: (layout: WorkbenchLayout, activePartId: string) => WorkbenchLayout): Promise { + await this.view.tab.click(); + await this._tabbar.selectTab('e2e-modify-layout'); + + const modifyLayoutPage = new ModifyLayoutPagePO(this.view, this.locator.locator('app-modify-layout-page')); + return modifyLayoutPage.modify(fn); + } + + public async registerPartAction(content: string, options?: {align?: 'start' | 'end'; viewId?: ViewId | ViewId[]; partId?: string | string[]; grid?: 'workbench' | 'mainArea'; cssClass?: string | string[]}): Promise { + await this.view.tab.click(); + await this._tabbar.selectTab('e2e-register-part-action'); + + const registerPartActionPage = new RegisterPartActionPagePO(this.locator.locator('app-register-part-action-page')); + return registerPartActionPage.registerPartAction(content, options); + } +} diff --git a/projects/scion/e2e-testing/src/workbench/page-object/layout-page/layout-pages.po.ts b/projects/scion/e2e-testing/src/workbench/page-object/layout-page/layout-pages.po.ts new file mode 100644 index 000000000..3f04f016c --- /dev/null +++ b/projects/scion/e2e-testing/src/workbench/page-object/layout-page/layout-pages.po.ts @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2018-2024 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 {coerceArray, commandsToPath, toMatrixNotation} from '../../../helper/testing.util'; +import {PartDescriptor, ViewDescriptor, ViewNavigationDescriptor} from './layout.model'; +import {SciCheckboxPO} from '../../../@scion/components.internal/checkbox.po'; + +export const LayoutPages = { + + /** + * Enters parts into {@link AddPartsComponent}. + */ + enterParts: async (locator: Locator, parts: PartDescriptor[]): Promise => { + for (const [i, part] of parts.entries()) { + await locator.locator('button.e2e-add').click(); + await locator.locator('input.e2e-part-id').nth(i).fill(part.id); + await new SciCheckboxPO(locator.locator('sci-checkbox.e2e-activate-part').nth(i)).toggle(part.activate === true); + if (part.relativeTo !== undefined) { + await locator.locator('input.e2e-relative-to').nth(i).fill(part.relativeTo ?? ''); + } + if (part.align !== undefined) { + await locator.locator('select.e2e-align').nth(i).selectOption(part.align ?? null); + } + if (part.ratio !== undefined) { + await locator.locator('input.e2e-ratio').nth(i).fill(`${part.ratio ?? ''}`); + } + } + }, + + /** + * Enters views into {@link AddViewsComponent}. + */ + enterViews: async (locator: Locator, views: ViewDescriptor[] = []): Promise => { + for (const [i, view] of views.entries()) { + await locator.locator('button.e2e-add').click(); + await locator.locator('input.e2e-view-id').nth(i).fill(view.id); + await locator.locator('input.e2e-part-id').nth(i).fill(view.partId); + await locator.locator('input.e2e-position').nth(i).fill(view.position?.toString() ?? ''); + await locator.locator('input.e2e-class').nth(i).fill(coerceArray(view.cssClass).join(' ')); + await new SciCheckboxPO(locator.locator('sci-checkbox.e2e-activate-view').nth(i)).toggle(view.activateView === true); + await new SciCheckboxPO(locator.locator('sci-checkbox.e2e-activate-part').nth(i)).toggle(view.activatePart === true); + } + }, + + /** + * Enters view navigations into {@link NavigateViewsComponent}. + */ + enterViewNavigations: async (locator: Locator, viewNavigations: ViewNavigationDescriptor[] = []): Promise => { + for (const [i, viewNavigation] of viewNavigations.entries()) { + await locator.locator('button.e2e-add').click(); + await locator.locator('input.e2e-view-id').nth(i).fill(viewNavigation.id); + await locator.locator('input.e2e-commands').nth(i).fill(commandsToPath(viewNavigation.commands)); + await locator.locator('input.e2e-hint').nth(i).fill(viewNavigation.hint ?? ''); + await locator.locator('input.e2e-state').nth(i).fill(toMatrixNotation(viewNavigation.state)); + await locator.locator('input.e2e-class').nth(i).fill(coerceArray(viewNavigation.cssClass).join(' ')); + } + }, +} as const; diff --git a/projects/scion/e2e-testing/src/workbench/page-object/layout-page/layout.model.ts b/projects/scion/e2e-testing/src/workbench/page-object/layout-page/layout.model.ts new file mode 100644 index 000000000..b7266c600 --- /dev/null +++ b/projects/scion/e2e-testing/src/workbench/page-object/layout-page/layout.model.ts @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2018-2024 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 {Commands, ReferencePart, ViewState, WorkbenchLayout, WorkbenchLayoutFactory} from '@scion/workbench'; +import {MAIN_AREA} from '../../../workbench.model'; +import {ActivatedRoute} from '@angular/router'; + +/** + * Implementation of {@link WorkbenchLayoutFactory} that can be used in page objects. + */ +export class ɵWorkbenchLayoutFactory implements WorkbenchLayoutFactory { + + public addPart(id: string | MAIN_AREA, options?: {activate?: boolean}): WorkbenchLayout { + return new ɵWorkbenchLayout().addInitialPart(id, options); + } +} + +/** + * Implementation of {@link WorkbenchLayout} that can be used in page objects. + */ +export class ɵWorkbenchLayout implements WorkbenchLayout { + + public parts = new Array(); + public views = new Array(); + public viewNavigations = new Array(); + + public addInitialPart(id: string | MAIN_AREA, options?: {activate?: boolean}): WorkbenchLayout { + this.parts.push({id, activate: options?.activate}); + return this; + } + + public addPart(id: string | MAIN_AREA, relativeTo: ReferencePart, options?: {activate?: boolean}): WorkbenchLayout { + this.parts.push({ + id, + relativeTo: relativeTo.relativeTo, + align: relativeTo.align, + ratio: relativeTo.ratio, + activate: options?.activate, + }); + return this; + } + + public addView(id: string, options: {partId: string; position?: number | 'start' | 'end' | 'before-active-view' | 'after-active-view'; activateView?: boolean; activatePart?: boolean; cssClass?: string | string[]}): WorkbenchLayout { + this.views.push({ + id, + partId: options.partId, + position: options.position, + cssClass: options.cssClass, + activatePart: options.activatePart, + activateView: options.activateView, + }); + return this; + } + + public navigateView(id: string, commands: Commands, extras?: {hint?: string; relativeTo?: ActivatedRoute; state?: ViewState; cssClass?: string | string[]}): WorkbenchLayout { + if (extras?.relativeTo) { + throw Error('[PageObjectError] Property `relativeTo` in `WorkbenchLayout.navigateView` is not supported.'); + } + + this.viewNavigations.push({ + id, + commands, + hint: extras?.hint, + state: extras?.state, + cssClass: extras?.cssClass, + }); + return this; + } + + public removeView(id: string): WorkbenchLayout { + throw Error('[PageObjectError] Operation `WorkbenchLayout.removeView` is not supported.'); + } + + public removePart(id: string): WorkbenchLayout { + throw Error('[PageObjectError] Operation `WorkbenchLayout.removePart` is not supported.'); + } + + public activateView(id: string, options?: {activatePart?: boolean}): WorkbenchLayout { + throw Error('[PageObjectError] Operation `WorkbenchLayout.activateView` is not supported.'); + } + + public activatePart(id: string): WorkbenchLayout { + throw Error('[PageObjectError] Operation `WorkbenchLayout.activatePart` is not supported.'); + } +} + +/** + * Represents a part to add to the layout. + */ +export interface PartDescriptor { + id: string | MAIN_AREA; + relativeTo?: string; + align?: 'left' | 'right' | 'top' | 'bottom'; + ratio?: number; + activate?: boolean; +} + +/** + * Represents a view to add to the layout. + */ +export interface ViewDescriptor { + id: string; + partId: string; + position?: number | 'start' | 'end' | 'before-active-view' | 'after-active-view'; + activateView?: boolean; + activatePart?: boolean; + cssClass?: string | string[]; +} + +/** + * Represents a view navigation in the layout. + */ +export interface ViewNavigationDescriptor { + id: string; + commands: Commands; + hint?: string; + state?: ViewState; + cssClass?: string | string[]; +} diff --git a/projects/scion/e2e-testing/src/workbench/page-object/layout-page/modify-layout-page.po.ts b/projects/scion/e2e-testing/src/workbench/page-object/layout-page/modify-layout-page.po.ts new file mode 100644 index 000000000..59efda12b --- /dev/null +++ b/projects/scion/e2e-testing/src/workbench/page-object/layout-page/modify-layout-page.po.ts @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2018-2024 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 {rejectWhenAttached, waitForCondition} from '../../../helper/testing.util'; +import {Locator} from '@playwright/test'; +import {WorkbenchLayout} from '@scion/workbench'; +import {LayoutPages} from './layout-pages.po'; +import {AppPO} from '../../../app.po'; +import {ViewPO} from '../../../view.po'; +import {ɵWorkbenchLayout} from './layout.model'; + +/** + * Page object to interact with {@link ModifyLayoutPageComponent}. + */ +export class ModifyLayoutPagePO { + + constructor(public view: ViewPO, public locator: Locator) { + } + + public async modify(fn: (layout: WorkbenchLayout, activePartId: string) => WorkbenchLayout): Promise { + const activePartId = await this.view.part.getPartId(); + const {parts, views, viewNavigations} = fn(new ɵWorkbenchLayout(), activePartId) as ɵWorkbenchLayout; + + // Enter the layout. + await LayoutPages.enterParts(this.locator.locator('app-add-parts'), parts); + await LayoutPages.enterViews(this.locator.locator('app-add-views'), views); + await LayoutPages.enterViewNavigations(this.locator.locator('app-navigate-views'), viewNavigations); + + // Apply the layout. + const appPO = new AppPO(this.locator.page()); + const navigationId = await appPO.getCurrentNavigationId(); + await this.locator.locator('button.e2e-modify').click(); + + // Evaluate the response: resolve the promise on success, or reject it on error. + await Promise.race([ + waitForCondition(async () => (await appPO.getCurrentNavigationId()) !== navigationId), + rejectWhenAttached(this.locator.locator('output.e2e-modify-error')), + ]); + } +} diff --git a/projects/scion/e2e-testing/src/workbench/page-object/layout-page/register-part-action-page.po.ts b/projects/scion/e2e-testing/src/workbench/page-object/layout-page/register-part-action-page.po.ts new file mode 100644 index 000000000..5b7a0ef90 --- /dev/null +++ b/projects/scion/e2e-testing/src/workbench/page-object/layout-page/register-part-action-page.po.ts @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2018-2024 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, rejectWhenAttached, waitUntilAttached} from '../../../helper/testing.util'; +import {Locator} from '@playwright/test'; +import {ViewId} from '@scion/workbench'; + +/** + * Page object to interact with {@link RegisterPartActionPageComponent}. + */ +export class RegisterPartActionPagePO { + + constructor(public locator: Locator) { + } + + public async registerPartAction(content: string, options?: {align?: 'start' | 'end'; viewId?: ViewId | ViewId[]; partId?: string | string[]; grid?: 'workbench' | 'mainArea'; cssClass?: string | string[]}): Promise { + await this.locator.locator('input.e2e-content').fill(content); + await this.locator.locator('select.e2e-align').selectOption(options?.align ?? ''); + await this.locator.locator('input.e2e-class').fill(coerceArray(options?.cssClass).join(' ')); + await this.locator.locator('input.e2e-view-id').fill(coerceArray(options?.viewId).join(' ')); + await this.locator.locator('input.e2e-part-id').fill(coerceArray(options?.partId).join(' ')); + await this.locator.locator('input.e2e-grid').fill(options?.grid ?? ''); + await this.locator.locator('button.e2e-register').click(); + + // Evaluate the response: resolve the promise on success, or reject it on error. + await Promise.race([ + waitUntilAttached(this.locator.locator('output.e2e-register-success')), + rejectWhenAttached(this.locator.locator('output.e2e-register-error')), + ]); + } +} diff --git a/projects/scion/e2e-testing/src/workbench/page-object/message-box-opener-page.po.ts b/projects/scion/e2e-testing/src/workbench/page-object/message-box-opener-page.po.ts index 5c9f7bdf9..160cbdc9d 100644 --- a/projects/scion/e2e-testing/src/workbench/page-object/message-box-opener-page.po.ts +++ b/projects/scion/e2e-testing/src/workbench/page-object/message-box-opener-page.po.ts @@ -14,7 +14,7 @@ import {SciCheckboxPO} from '../../@scion/components.internal/checkbox.po'; import {SciKeyValueFieldPO} from '../../@scion/components.internal/key-value-field.po'; import {Locator} from '@playwright/test'; import {ViewPO} from '../../view.po'; -import {WorkbenchMessageBoxOptions} from '@scion/workbench'; +import {ViewId, WorkbenchMessageBoxOptions} from '@scion/workbench'; import {WorkbenchViewPagePO} from './workbench-view-page.po'; /** @@ -28,7 +28,7 @@ export class MessageBoxOpenerPagePO implements WorkbenchViewPagePO { public readonly view: ViewPO; private readonly _openButton: Locator; - constructor(private _appPO: AppPO, locateBy: {viewId?: string; cssClass?: string}) { + constructor(private _appPO: AppPO, locateBy: {viewId?: ViewId; cssClass?: string}) { this.view = this._appPO.view({viewId: locateBy?.viewId, cssClass: locateBy?.cssClass}); this.locator = this.view.locator.locator('app-message-box-opener-page'); this.closeAction = this.locator.locator('output.e2e-close-action'); diff --git a/projects/scion/e2e-testing/src/workbench/page-object/notification-opener-page.po.ts b/projects/scion/e2e-testing/src/workbench/page-object/notification-opener-page.po.ts index bac1fa8d9..1b1c8a87c 100644 --- a/projects/scion/e2e-testing/src/workbench/page-object/notification-opener-page.po.ts +++ b/projects/scion/e2e-testing/src/workbench/page-object/notification-opener-page.po.ts @@ -14,6 +14,7 @@ import {SciCheckboxPO} from '../../@scion/components.internal/checkbox.po'; import {Locator} from '@playwright/test'; import {WorkbenchViewPagePO} from './workbench-view-page.po'; import {ViewPO} from '../../view.po'; +import {ViewId} from '@scion/workbench'; /** * Page object to interact with {@link NotificationPageComponent}. @@ -24,7 +25,7 @@ export class NotificationOpenerPagePO implements WorkbenchViewPagePO { public readonly view: ViewPO; public readonly error: Locator; - constructor(private _appPO: AppPO, locateBy: {viewId?: string; cssClass?: string}) { + constructor(private _appPO: AppPO, locateBy: {viewId?: ViewId; cssClass?: string}) { this.view = this._appPO.view({viewId: locateBy?.viewId, cssClass: locateBy?.cssClass}); this.locator = this.view.locator.locator('app-notification-opener-page'); this.error = this.locator.locator('output.e2e-notification-open-error'); diff --git a/projects/scion/e2e-testing/src/workbench/page-object/page-not-found-page.po.ts b/projects/scion/e2e-testing/src/workbench/page-object/page-not-found-page.po.ts new file mode 100644 index 000000000..7d82f1eb1 --- /dev/null +++ b/projects/scion/e2e-testing/src/workbench/page-object/page-not-found-page.po.ts @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2018-2024 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 {AppPO} from '../../app.po'; +import {ViewPO} from '../../view.po'; +import {Locator} from '@playwright/test'; +import {WorkbenchViewPagePO} from './workbench-view-page.po'; +import {ViewId} from '@scion/workbench'; + +/** + * Page object to interact with {@link PageNotFoundComponent}. + */ +export class PageNotFoundPagePO implements WorkbenchViewPagePO { + + public readonly locator: Locator; + public readonly view: ViewPO; + + constructor(appPO: AppPO, locateBy: {viewId?: ViewId; cssClass?: string}) { + this.view = appPO.view({viewId: locateBy?.viewId, cssClass: locateBy?.cssClass}); + this.locator = this.view.locator.locator('wb-page-not-found'); + } +} diff --git a/projects/scion/e2e-testing/src/workbench/page-object/perspective-page.po.ts b/projects/scion/e2e-testing/src/workbench/page-object/perspective-page.po.ts deleted file mode 100644 index 0898ffe8f..000000000 --- a/projects/scion/e2e-testing/src/workbench/page-object/perspective-page.po.ts +++ /dev/null @@ -1,110 +0,0 @@ -/* - * 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 {rejectWhenAttached, waitUntilAttached} from '../../helper/testing.util'; -import {AppPO} from '../../app.po'; -import {ViewPO} from '../../view.po'; -import {SciCheckboxPO} from '../../@scion/components.internal/checkbox.po'; -import {Locator} from '@playwright/test'; -import {SciKeyValueFieldPO} from '../../@scion/components.internal/key-value-field.po'; -import {MAIN_AREA} from '../../workbench.model'; -import {WorkbenchViewPagePO} from './workbench-view-page.po'; - -/** - * Page object to interact with {@link PerspectivePageComponent}. - */ -export class PerspectivePagePO implements WorkbenchViewPagePO { - - public readonly locator: Locator; - public readonly view: ViewPO; - - constructor(appPO: AppPO, locateBy: {viewId?: string; cssClass?: string}) { - this.view = appPO.view({viewId: locateBy?.viewId, cssClass: locateBy?.cssClass}); - this.locator = this.view.locator.locator('app-perspective-page'); - } - - public async registerPerspective(definition: PerspectiveDefinition): Promise { - // Enter perspective definition. - await this.locator.locator('input.e2e-id').fill(definition.id); - await new SciCheckboxPO(this.locator.locator('sci-checkbox.e2e-transient')).toggle(definition.transient === true); - await this.enterData(definition.data); - - // Enter parts. - await this.enterParts(definition.parts); - - // Enter views. - await this.enterViews(definition.views); - - // Register perspective. - await this.locator.locator('button.e2e-register').click(); - - // Evaluate the response: resolve the promise on success, or reject it on error. - await Promise.race([ - waitUntilAttached(this.locator.locator('output.e2e-register-success')), - rejectWhenAttached(this.locator.locator('output.e2e-register-error')), - ]); - } - - private async enterData(data: {[key: string]: any} | undefined): Promise { - const keyValueField = new SciKeyValueFieldPO(this.locator.locator('sci-key-value-field.e2e-data')); - await keyValueField.clear(); - await keyValueField.addEntries(data ?? {}); - } - - private async enterParts(parts: PerspectivePartDescriptor[]): Promise { - const partsLocator = await this.locator.locator('app-perspective-page-parts'); - for (const [i, part] of parts.entries()) { - await partsLocator.locator('button.e2e-add').click(); - await partsLocator.locator('input.e2e-part-id').nth(i).fill(part.id); - await new SciCheckboxPO(partsLocator.locator('sci-checkbox.e2e-part-activate').nth(i)).toggle(part.activate === true); - if (i > 0) { - await partsLocator.locator('select.e2e-part-align').nth(i).selectOption(part.align!); - await partsLocator.locator('input.e2e-part-relative-to').nth(i).fill(part.relativeTo ?? ''); - await partsLocator.locator('input.e2e-part-ratio').nth(i).fill(part.ratio?.toString() ?? ''); - } - } - } - - private async enterViews(views: PerspectiveViewDescriptor[] = []): Promise { - const viewsLocator = await this.locator.locator('app-perspective-page-views'); - for (const [i, view] of views.entries()) { - await viewsLocator.locator('button.e2e-add').click(); - await viewsLocator.locator('input.e2e-view-id').nth(i).fill(view.id); - await viewsLocator.locator('input.e2e-view-part-id').nth(i).fill(view.partId); - await viewsLocator.locator('input.e2e-view-position').nth(i).fill(view.position?.toString() ?? ''); - await new SciCheckboxPO(viewsLocator.locator('sci-checkbox.e2e-view-activate-view').nth(i)).toggle(view.activateView === true); - await new SciCheckboxPO(viewsLocator.locator('sci-checkbox.e2e-view-activate-part').nth(i)).toggle(view.activatePart === true); - } - } -} - -export interface PerspectiveDefinition { - id: string; - transient?: true; - parts: PerspectivePartDescriptor[]; - views?: PerspectiveViewDescriptor[]; - data?: {[key: string]: any}; -} - -export interface PerspectivePartDescriptor { - id: string | MAIN_AREA; - relativeTo?: string; - align?: 'left' | 'right' | 'top' | 'bottom'; - ratio?: number; - activate?: boolean; -} - -export interface PerspectiveViewDescriptor { - id: string; - partId: string; - position?: number; - activateView?: boolean; - activatePart?: boolean; -} diff --git a/projects/scion/e2e-testing/src/workbench/page-object/popup-opener-page.po.ts b/projects/scion/e2e-testing/src/workbench/page-object/popup-opener-page.po.ts index 5b76fc9a4..cf9926bb9 100644 --- a/projects/scion/e2e-testing/src/workbench/page-object/popup-opener-page.po.ts +++ b/projects/scion/e2e-testing/src/workbench/page-object/popup-opener-page.po.ts @@ -10,7 +10,7 @@ import {coerceArray, DomRect, fromRect, rejectWhenAttached} from '../../helper/testing.util'; import {AppPO} from '../../app.po'; -import {BottomLeftPoint, BottomRightPoint, PopupOrigin, PopupSize, TopLeftPoint, TopRightPoint} from '@scion/workbench'; +import {BottomLeftPoint, BottomRightPoint, PopupOrigin, PopupSize, TopLeftPoint, TopRightPoint, ViewId} from '@scion/workbench'; import {SciAccordionPO} from '../../@scion/components.internal/accordion.po'; import {SciCheckboxPO} from '../../@scion/components.internal/checkbox.po'; import {Locator} from '@playwright/test'; @@ -176,7 +176,7 @@ export class PopupOpenerPagePO implements WorkbenchViewPagePO { } } - public async enterContextualViewId(viewId: string | '' | ''): Promise { + public async enterContextualViewId(viewId: ViewId | '' | ''): Promise { await this.locator.locator('input.e2e-contextual-view-id').fill(viewId); } diff --git a/projects/scion/e2e-testing/src/workbench/page-object/router-page.po.ts b/projects/scion/e2e-testing/src/workbench/page-object/router-page.po.ts index c75d54dea..95faf5633 100644 --- a/projects/scion/e2e-testing/src/workbench/page-object/router-page.po.ts +++ b/projects/scion/e2e-testing/src/workbench/page-object/router-page.po.ts @@ -8,7 +8,7 @@ * SPDX-License-Identifier: EPL-2.0 */ -import {coerceArray, rejectWhenAttached, waitUntilStable} from '../../helper/testing.util'; +import {coerceArray, commandsToPath, rejectWhenAttached, waitForCondition, waitUntilStable} from '../../helper/testing.util'; import {AppPO} from '../../app.po'; import {ViewPO} from '../../view.po'; import {SciKeyValueFieldPO} from '../../@scion/components.internal/key-value-field.po'; @@ -16,7 +16,7 @@ import {SciCheckboxPO} from '../../@scion/components.internal/checkbox.po'; import {Locator} from '@playwright/test'; import {Params} from '@angular/router'; import {WorkbenchViewPagePO} from './workbench-view-page.po'; -import {ViewState} from '@scion/workbench'; +import {Commands, ViewId, ViewState} from '@scion/workbench'; /** * Page object to interact with {@link RouterPageComponent}. @@ -26,19 +26,20 @@ export class RouterPagePO implements WorkbenchViewPagePO { public readonly locator: Locator; public readonly view: ViewPO; - constructor(private _appPO: AppPO, locateBy: {viewId?: string; cssClass?: string}) { + constructor(private _appPO: AppPO, locateBy: {viewId?: ViewId; cssClass?: string}) { this.view = this._appPO.view({viewId: locateBy?.viewId, cssClass: locateBy?.cssClass}); this.locator = this.view.locator.locator('app-router-page'); } + /** + * @deprecated use {@link enterCommands} instead. + */ public async enterPath(path: string): Promise { - await this.locator.locator('input.e2e-path').fill(path); + await this.locator.locator('input.e2e-commands').fill(path); } - public async enterMatrixParams(params: Params): Promise { - const keyValueField = new SciKeyValueFieldPO(this.locator.locator('sci-key-value-field.e2e-matrix-params')); - await keyValueField.clear(); - await keyValueField.addEntries(params); + public async enterCommands(commands: Commands): Promise { + await this.locator.locator('input.e2e-commands').fill(commandsToPath(commands)); } public async enterQueryParams(params: Params): Promise { @@ -65,6 +66,10 @@ export class RouterPagePO implements WorkbenchViewPagePO { await this.locator.locator('input.e2e-target').fill(target ?? ''); } + public async enterHint(hint?: string): Promise { + await this.locator.locator('input.e2e-hint').fill(hint ?? ''); + } + public async enterInsertionIndex(insertionIndex: number | 'start' | 'end' | undefined): Promise { await this.locator.locator('input.e2e-insertion-index').fill(`${insertionIndex}`); } @@ -74,7 +79,7 @@ export class RouterPagePO implements WorkbenchViewPagePO { } public async enterCssClass(cssClass: string | string[]): Promise { - await this.locator.locator('input.e2e-css-class').fill(coerceArray(cssClass).join(' ')); + await this.locator.locator('input.e2e-class').fill(coerceArray(cssClass).join(' ')); } public async checkViewContext(check: boolean): Promise { @@ -85,11 +90,12 @@ export class RouterPagePO implements WorkbenchViewPagePO { * Clicks on a button to navigate via {@link WorkbenchRouter}. */ public async clickNavigate(): Promise { + const navigationId = await this._appPO.getCurrentNavigationId(); await this.locator.locator('button.e2e-router-navigate').click(); // Evaluate the response: resolve the promise on success, or reject it on error. await Promise.race([ - waitUntilStable(() => this._appPO.getCurrentNavigationId()), + waitForCondition(async () => (await this._appPO.getCurrentNavigationId()) !== navigationId), rejectWhenAttached(this.locator.locator('output.e2e-navigate-error')), ]); } diff --git a/projects/scion/e2e-testing/src/workbench/page-object/test-pages/angular-router-test-page.po.ts b/projects/scion/e2e-testing/src/workbench/page-object/test-pages/angular-router-test-page.po.ts index 3745c2684..8d4ac95c9 100644 --- a/projects/scion/e2e-testing/src/workbench/page-object/test-pages/angular-router-test-page.po.ts +++ b/projects/scion/e2e-testing/src/workbench/page-object/test-pages/angular-router-test-page.po.ts @@ -12,26 +12,29 @@ import {AppPO} from '../../../app.po'; import {Locator} from '@playwright/test'; import {WorkbenchViewPagePO} from '../workbench-view-page.po'; import {ViewPO} from '../../../view.po'; -import {rejectWhenAttached, waitUntilStable} from '../../../helper/testing.util'; +import {Commands, ViewId} from '@scion/workbench'; +import {commandsToPath, rejectWhenAttached, waitForCondition} from '../../../helper/testing.util'; export class AngularRouterTestPagePO implements WorkbenchViewPagePO { public readonly locator: Locator; public readonly view: ViewPO; - constructor(private _appPO: AppPO, locateBy: {viewId?: string; cssClass?: string}) { + constructor(private _appPO: AppPO, locateBy: {viewId?: ViewId; cssClass?: string}) { this.view = this._appPO.view({viewId: locateBy?.viewId, cssClass: locateBy?.cssClass}); this.locator = this.view.locator.locator('app-angular-router-test-page'); } - public async navigate(path: string, extras: {outlet: string}): Promise { - await this.locator.locator('input.e2e-path').fill(path); + public async navigate(commands: Commands, extras: {outlet: string}): Promise { + await this.locator.locator('input.e2e-commands').fill(commandsToPath(commands)); await this.locator.locator('input.e2e-outlet').fill(extras.outlet); + + const navigationId = await this._appPO.getCurrentNavigationId(); await this.locator.locator('button.e2e-navigate').click(); // Evaluate the response: resolve the promise on success, or reject it on error. await Promise.race([ - waitUntilStable(() => this._appPO.getCurrentNavigationId()), + waitForCondition(async () => (await this._appPO.getCurrentNavigationId()) !== navigationId), rejectWhenAttached(this.locator.locator('output.e2e-navigate-error')), ]); } diff --git a/projects/scion/e2e-testing/src/workbench/page-object/test-pages/bulk-navigation-test-page.po.ts b/projects/scion/e2e-testing/src/workbench/page-object/test-pages/bulk-navigation-test-page.po.ts index 38d722de3..049d2fb7f 100644 --- a/projects/scion/e2e-testing/src/workbench/page-object/test-pages/bulk-navigation-test-page.po.ts +++ b/projects/scion/e2e-testing/src/workbench/page-object/test-pages/bulk-navigation-test-page.po.ts @@ -15,13 +15,14 @@ import {RouterPagePO} from '../router-page.po'; import {WorkbenchNavigator} from '../../workbench-navigator'; import {WorkbenchViewPagePO} from '../workbench-view-page.po'; import {ViewPO} from '../../../view.po'; +import {ViewId} from '@scion/workbench'; export class BulkNavigationTestPagePO implements WorkbenchViewPagePO { public readonly locator: Locator; public readonly view: ViewPO; - constructor(private _appPO: AppPO, locateBy: {viewId?: string; cssClass?: string}) { + constructor(private _appPO: AppPO, locateBy: {viewId?: ViewId; cssClass?: string}) { this.view = this._appPO.view({viewId: locateBy.viewId, cssClass: locateBy.cssClass}); this.locator = this.view.locator.locator('app-bulk-navigation-test-page'); } @@ -31,7 +32,7 @@ export class BulkNavigationTestPagePO implements WorkbenchViewPagePO { } public async enterCssClass(cssClass: string): Promise { - await this.locator.locator('input.e2e-css-class').fill(cssClass); + await this.locator.locator('input.e2e-class').fill(cssClass); } public async clickNavigateNoAwait(): Promise { 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 a2ea30465..e04aff04b 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 @@ -46,7 +46,7 @@ export class FocusTestPagePO implements WorkbenchViewPagePO, WorkbenchDialogPage return this.lastField.click({timeout: options?.timeout}); } default: { - throw Error(`[IllegalArgumentError] Specified field not found: ${field}`); + throw Error(`[PageObjectError] Specified field not found: ${field}`); } } } diff --git a/projects/scion/e2e-testing/src/workbench/page-object/test-pages/navigation-test-page.po.ts b/projects/scion/e2e-testing/src/workbench/page-object/test-pages/navigation-test-page.po.ts index 70612ab8e..d2ef94dfe 100644 --- a/projects/scion/e2e-testing/src/workbench/page-object/test-pages/navigation-test-page.po.ts +++ b/projects/scion/e2e-testing/src/workbench/page-object/test-pages/navigation-test-page.po.ts @@ -12,13 +12,14 @@ import {AppPO} from '../../../app.po'; import {Locator} from '@playwright/test'; import {WorkbenchViewPagePO} from '../workbench-view-page.po'; import {ViewPO} from '../../../view.po'; +import {ViewId} from '@scion/workbench'; export class NavigationTestPagePO implements WorkbenchViewPagePO { public readonly locator: Locator; public readonly view: ViewPO; - constructor(appPO: AppPO, locateBy: {viewId?: string; cssClass?: string}) { + constructor(appPO: AppPO, locateBy: {viewId?: ViewId; cssClass?: string}) { this.view = appPO.view({viewId: locateBy?.viewId, cssClass: locateBy?.cssClass}); this.locator = this.view.locator.locator('app-navigation-test-page'); } diff --git a/projects/scion/e2e-testing/src/workbench/page-object/test-pages/non-standalone-view-test-page.po.ts b/projects/scion/e2e-testing/src/workbench/page-object/test-pages/non-standalone-view-test-page.po.ts index 678650fcd..ff4bf71f7 100644 --- a/projects/scion/e2e-testing/src/workbench/page-object/test-pages/non-standalone-view-test-page.po.ts +++ b/projects/scion/e2e-testing/src/workbench/page-object/test-pages/non-standalone-view-test-page.po.ts @@ -12,13 +12,14 @@ import {AppPO} from '../../../app.po'; import {Locator} from '@playwright/test'; import {ViewPO} from '../../../view.po'; import {WorkbenchViewPagePO} from '../workbench-view-page.po'; +import {ViewId} from '@scion/workbench'; export class NonStandaloneViewTestPagePO implements WorkbenchViewPagePO { public readonly locator: Locator; public readonly view: ViewPO; - constructor(appPO: AppPO, locateBy: {viewId?: string; cssClass?: string}) { + constructor(appPO: AppPO, locateBy: {viewId?: ViewId; cssClass?: string}) { this.view = appPO.view({viewId: locateBy.viewId, cssClass: locateBy.cssClass}); this.locator = this.view.locator.locator('app-non-standalone-view-test-page'); } diff --git a/projects/scion/e2e-testing/src/workbench/page-object/test-pages/standalone-view-test-page.po.ts b/projects/scion/e2e-testing/src/workbench/page-object/test-pages/standalone-view-test-page.po.ts index e9e249e3f..2bb01eafe 100644 --- a/projects/scion/e2e-testing/src/workbench/page-object/test-pages/standalone-view-test-page.po.ts +++ b/projects/scion/e2e-testing/src/workbench/page-object/test-pages/standalone-view-test-page.po.ts @@ -12,13 +12,14 @@ import {AppPO} from '../../../app.po'; import {Locator} from '@playwright/test'; import {ViewPO} from '../../../view.po'; import {WorkbenchViewPagePO} from '../workbench-view-page.po'; +import {ViewId} from '@scion/workbench'; export class StandaloneViewTestPagePO implements WorkbenchViewPagePO { public readonly locator: Locator; public readonly view: ViewPO; - constructor(appPO: AppPO, locateBy: {viewId?: string; cssClass?: string}) { + constructor(appPO: AppPO, locateBy: {viewId?: ViewId; cssClass?: string}) { this.view = appPO.view({viewId: locateBy.viewId, cssClass: locateBy.cssClass}); this.locator = this.view.locator.locator('app-standalone-view-test-page'); } diff --git a/projects/scion/e2e-testing/src/workbench/page-object/test-pages/workbench-theme-test-page.po.ts b/projects/scion/e2e-testing/src/workbench/page-object/test-pages/workbench-theme-test-page.po.ts index 71f7752bb..852c3880f 100644 --- a/projects/scion/e2e-testing/src/workbench/page-object/test-pages/workbench-theme-test-page.po.ts +++ b/projects/scion/e2e-testing/src/workbench/page-object/test-pages/workbench-theme-test-page.po.ts @@ -14,6 +14,7 @@ import {WorkbenchNavigator} from '../../workbench-navigator'; import {RouterPagePO} from '../router-page.po'; import {WorkbenchViewPagePO} from '../workbench-view-page.po'; import {ViewPO} from '../../../view.po'; +import {ViewId} from '@scion/workbench'; export class WorkbenchThemeTestPagePO implements WorkbenchViewPagePO { @@ -22,7 +23,7 @@ export class WorkbenchThemeTestPagePO implements WorkbenchViewPagePO { public readonly theme: Locator; public readonly colorScheme: Locator; - constructor(appPO: AppPO, locateBy: {viewId?: string; cssClass?: string}) { + constructor(appPO: AppPO, locateBy: {viewId?: ViewId; cssClass?: string}) { this.view = appPO.view({viewId: locateBy.viewId, cssClass: locateBy.cssClass}); this.locator = this.view.locator.locator('app-workbench-theme-test-page'); diff --git a/projects/scion/e2e-testing/src/workbench/page-object/view-info-dialog.po.ts b/projects/scion/e2e-testing/src/workbench/page-object/view-info-dialog.po.ts index 3dda1df1d..370f4008a 100644 --- a/projects/scion/e2e-testing/src/workbench/page-object/view-info-dialog.po.ts +++ b/projects/scion/e2e-testing/src/workbench/page-object/view-info-dialog.po.ts @@ -12,7 +12,7 @@ import {Locator} from '@playwright/test'; import {SciKeyValuePO} from '../../@scion/components.internal/key-value.po'; import {DialogPO} from '../../dialog.po'; import {WorkbenchDialogPagePO} from './workbench-dialog-page.po'; -import {ViewState} from '@scion/workbench'; +import {ViewId, ViewState} from '@scion/workbench'; import {Data, Params} from '@angular/router'; /** @@ -32,11 +32,13 @@ export class ViewInfoDialogPO implements WorkbenchDialogPagePO { const state = this.locator.locator('sci-key-value.e2e-state'); return { - viewId: await this.locator.locator('span.e2e-view-id').innerText(), + viewId: await this.locator.locator('span.e2e-view-id').innerText() as ViewId, + alternativeId: await this.locator.locator('span.e2e-alternative-view-id').innerText(), partId: await this.locator.locator('span.e2e-part-id').innerText(), title: await this.locator.locator('span.e2e-title').innerText(), heading: await this.locator.locator('span.e2e-heading').innerText(), urlSegments: await this.locator.locator('span.e2e-url-segments').innerText(), + navigationHint: await this.locator.locator('span.e2e-navigation-hint').innerText(), routeParams: await routeParams.isVisible() ? await new SciKeyValuePO(routeParams).readEntries() : {}, routeData: await routeData.isVisible() ? await new SciKeyValuePO(routeData).readEntries() : {}, state: await state.isVisible() ? await new SciKeyValuePO(state).readEntries() : {}, @@ -45,11 +47,13 @@ export class ViewInfoDialogPO implements WorkbenchDialogPagePO { } export interface ViewInfo { - viewId: string; + viewId: ViewId; + alternativeId: string; partId: string; title: string; heading: string; urlSegments: string; + navigationHint: string; routeParams: Params; routeData: Data; state: ViewState; diff --git a/projects/scion/e2e-testing/src/workbench/page-object/view-page.po.ts b/projects/scion/e2e-testing/src/workbench/page-object/view-page.po.ts index 091543902..a674a6a35 100644 --- a/projects/scion/e2e-testing/src/workbench/page-object/view-page.po.ts +++ b/projects/scion/e2e-testing/src/workbench/page-object/view-page.po.ts @@ -17,7 +17,7 @@ import {SciAccordionPO} from '../../@scion/components.internal/accordion.po'; import {Params} from '@angular/router'; import {SciKeyValuePO} from '../../@scion/components.internal/key-value.po'; import {WorkbenchViewPagePO} from './workbench-view-page.po'; -import {ViewState} from '@scion/workbench'; +import {ViewId, ViewState} from '@scion/workbench'; /** * Page object to interact with {@link ViewPageComponent}. @@ -28,7 +28,7 @@ export class ViewPagePO implements WorkbenchViewPagePO { public readonly view: ViewPO; public readonly viewId: Locator; - constructor(appPO: AppPO, locateBy: {viewId?: string; cssClass?: string}) { + constructor(appPO: AppPO, locateBy: {viewId?: ViewId; cssClass?: string}) { this.view = appPO.view({viewId: locateBy?.viewId, cssClass: locateBy?.cssClass}); this.locator = this.view.locator.locator('app-view-page'); this.viewId = this.locator.locator('span.e2e-view-id'); @@ -79,7 +79,7 @@ export class ViewPagePO implements WorkbenchViewPagePO { } public async enterCssClass(cssClass: string | string[]): Promise { - await this.locator.locator('input.e2e-css-class').fill(coerceArray(cssClass).join(' ')); + await this.locator.locator('input.e2e-class').fill(coerceArray(cssClass).join(' ')); } public async checkClosable(check: boolean): Promise { diff --git a/projects/scion/e2e-testing/src/workbench/router-link.e2e-spec.ts b/projects/scion/e2e-testing/src/workbench/router-link.e2e-spec.ts index e2e32f4e8..11d06b338 100644 --- a/projects/scion/e2e-testing/src/workbench/router-link.e2e-spec.ts +++ b/projects/scion/e2e-testing/src/workbench/router-link.e2e-spec.ts @@ -11,12 +11,11 @@ import {expect} from '@playwright/test'; import {test} from '../fixtures'; import {RouterPagePO} from './page-object/router-page.po'; -import {LayoutPagePO} from './page-object/layout-page.po'; -import {PerspectivePagePO} from './page-object/perspective-page.po'; import {MPart, MTreeNode} from '../matcher/to-equal-workbench-layout.matcher'; -import {MAIN_AREA} from '../workbench.model'; import {expectView} from '../matcher/view-matcher'; import {ViewPagePO} from './page-object/view-page.po'; +import {ViewInfo} from './page-object/view-info-dialog.po'; +import {MAIN_AREA} from '../workbench.model'; test.describe('Workbench RouterLink', () => { @@ -35,6 +34,47 @@ test.describe('Workbench RouterLink', () => { await expectView(testeeViewPage).toBeActive(); }); + test('should open view in current tab (view is in peripheral area)', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + await workbenchNavigator.createPerspective(factory => factory + .addPart(MAIN_AREA) + .addPart('left', {align: 'left'}) + .addView('view.101', {partId: 'left', activateView: true}), + ); + + // Add state via separate navigation as not supported when adding views to the perspective. + await workbenchNavigator.modifyLayout(layout => layout + .navigateView('view.101', ['test-router'], {state: {navigated: 'false'}}), + ); + + // Open test view via router link. + const routerPage = new RouterPagePO(appPO, {viewId: 'view.101'}); + await routerPage.enterCommands(['/test-view']); + await routerPage.enterState({navigated: 'true'}); + await routerPage.clickNavigateViaRouterLink(); + + // Expect router page to be replaced + await expect.poll(() => routerPage.view.getInfo()).toMatchObject( + { + viewId: 'view.101', + urlSegments: 'test-view', + state: {navigated: 'true'}, + } satisfies Partial, + ); + + await expect(appPO.workbench).toEqualWorkbenchLayout({ + workbenchGrid: { + root: new MTreeNode({ + direction: 'row', + ratio: .5, + child1: new MPart({id: 'left', views: [{id: 'view.101'}], activeViewId: 'view.101'}), + child2: new MPart({id: MAIN_AREA}), + }), + }, + }); + }); + test('should open the view in a new view tab (target="auto")', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); const routerPage = await workbenchNavigator.openInNewTab(RouterPagePO); @@ -79,6 +119,34 @@ test.describe('Workbench RouterLink', () => { await expectView(testeeViewPage).toBeInactive(); }); + test('should open the view in a new view tab without activating it when pressing the CTRL modifier key (target="auto")', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + const routerPage = await workbenchNavigator.openInNewTab(RouterPagePO); + + // Navigate via router link while pressing CTRL Modifier key. + await routerPage.enterCommands(['/test-view']); + await routerPage.enterTarget('auto'); + await routerPage.enterCssClass('testee-1'); + await routerPage.clickNavigateViaRouterLink(['Control']); + + const testeeViewPage1 = new ViewPagePO(appPO, {cssClass: 'testee-1'}); + await expect(appPO.views()).toHaveCount(2); + await expectView(routerPage).toBeActive(); + await expectView(testeeViewPage1).toBeInactive(); + + // Navigate via router link again while pressing CTRL Modifier key. + await routerPage.enterCommands(['/test-view']); + await routerPage.enterTarget('auto'); + await routerPage.enterCssClass('testee-2'); + await routerPage.clickNavigateViaRouterLink(['Control']); + + const testeeViewPage2 = new ViewPagePO(appPO, {cssClass: 'testee-2'}); + await expect(appPO.views()).toHaveCount(3); + await expectView(routerPage).toBeActive(); + await expectView(testeeViewPage1).toBeInactive(); + await expectView(testeeViewPage2).toBeInactive(); + }); + /** * The Meta key is the Windows logo key, or the Command or ⌘ key on Mac keyboards. * @see https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values @@ -172,131 +240,348 @@ test.describe('Workbench RouterLink', () => { await expect(appPO.views()).toHaveCount(2); }); - test('should not navigate current view if not the target of primary routes', async ({appPO, workbenchNavigator}) => { + test('should navigate current view when navigating from path-based route to path-based route', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - // Add router page to the workbench grid as named view - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - await layoutPage.addPart('left', {relativeTo: MAIN_AREA, align: 'left', ratio: .25}); - await layoutPage.addView('router', {partId: 'left', activateView: true}); - await layoutPage.registerRoute({path: '', component: 'router-page', outlet: 'router'}, {title: 'Workbench Router'}); - await layoutPage.view.tab.close(); + // Open router page as path-based route. + await workbenchNavigator.modifyLayout((layout, activePartId) => layout + .addView('view.100', {partId: activePartId}) + .navigateView('view.100', ['test-router']), + ); - // Navigate in the router page via router link - const routerPage = new RouterPagePO(appPO, {viewId: 'router'}); + // Navigate to path-based route via router link. + const routerPage = new RouterPagePO(appPO, {viewId: 'view.100'}); await routerPage.enterPath('/test-view'); - await routerPage.enterCssClass('testee'); await routerPage.clickNavigateViaRouterLink(); - const testeeViewPage = new ViewPagePO(appPO, {cssClass: 'testee'}); + const testeeViewPage = new ViewPagePO(appPO, {viewId: 'view.100'}); - // Expect the test view to be opened in the main area + // Expect view to display path-based route. await expectView(testeeViewPage).toBeActive(); - await expect(appPO.views({inMainArea: true})).toHaveCount(1); - await expect.poll(() => testeeViewPage.view.part.isInMainArea()).toBe(true); + await expect(appPO.views()).toHaveCount(1); + await expect.poll(() => testeeViewPage.view.getInfo()).toMatchObject( + { + routeData: {path: 'test-view', navigationHint: ''}, + } satisfies Partial, + ); + }); - // Expect the router page to be still opened in the workbench grid - await expectView(routerPage).toBeActive(); - await expect.poll(() => routerPage.view.part.getPartId()).toEqual('left'); - await expect.poll(() => routerPage.view.part.isInMainArea()).toBe(false); - await expect(appPO.views({inMainArea: false})).toHaveCount(1); + test('should navigate current view when navigating from path-based route to empty-path route (1/2)', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + // Open router page as path-based route. + await workbenchNavigator.modifyLayout((layout, activePartId) => layout + .addView('view.100', {partId: activePartId}) + .navigateView('view.100', ['test-router']), + ); + + // Navigate to empty-path route via router link. + const routerPage = new RouterPagePO(appPO, {viewId: 'view.100'}); + await routerPage.enterPath(''); + await routerPage.enterHint(''); + await routerPage.enterState({navigated: 'true'}); + await routerPage.clickNavigateViaRouterLink(); + + const testeeViewPage = new RouterPagePO(appPO, {viewId: 'view.100'}); + + // Expect view to display empty-path route. + await expectView(testeeViewPage).toBeActive(); + await expect(appPO.views()).toHaveCount(1); + await expect.poll(() => testeeViewPage.view.getInfo()).toMatchObject( + { + routeData: {path: 'test-router', navigationHint: ''}, + state: {navigated: 'true'}, + } satisfies Partial, + ); }); - test('should navigate current view if the target of primary routes', async ({appPO, workbenchNavigator}) => { + test('should navigate current view when navigating from path-based route to empty-path route (2/2)', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - // Add part to workbench grid - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - await layoutPage.addPart('left', {relativeTo: MAIN_AREA, align: 'left', ratio: .25}); - - // Add router page to the part as unnamed view - { - const routerPage = await workbenchNavigator.openInNewTab(RouterPagePO); - await routerPage.enterPath('/test-router'); - await routerPage.enterTarget('view.101'); - await routerPage.enterCssClass('router'); - await routerPage.enterBlankPartId('left'); - await routerPage.clickNavigate(); - await routerPage.view.tab.close(); - } - - // Navigate in the router page via router link - const routerPage = new RouterPagePO(appPO, {cssClass: 'router'}); - await routerPage.enterPath('/test-view'); - await routerPage.enterCssClass('testee'); + // Open router page as path-based route. + await workbenchNavigator.modifyLayout((layout, activePartId) => layout + .addView('view.100', {partId: activePartId}) + .navigateView('view.100', ['test-router']), + ); + + // Navigate to empty-path route via router link. + const routerPage = new RouterPagePO(appPO, {viewId: 'view.100'}); + await routerPage.enterPath('/'); + await routerPage.enterHint('test-view'); await routerPage.clickNavigateViaRouterLink(); - // Expect the test view to replace the router view - const testeeViewPage = new ViewPagePO(appPO, {cssClass: 'testee'}); + const testeeViewPage = new ViewPagePO(appPO, {viewId: 'view.100'}); + + // Expect view to display empty-path route. await expectView(testeeViewPage).toBeActive(); - await expectView(routerPage).not.toBeAttached(); - await expect.poll(() => testeeViewPage.view.part.getPartId()).toEqual('left'); - await expect.poll(() => testeeViewPage.view.part.isInMainArea()).toBe(false); - await expect.poll(() => testeeViewPage.view.getViewId()).toEqual('view.101'); + await expect(appPO.views()).toHaveCount(1); + await expect.poll(() => testeeViewPage.view.getInfo()).toMatchObject( + { + routeData: {path: '', navigationHint: 'test-view'}, + } satisfies Partial, + ); + }); + + test('should navigate current view when navigating from empty-path route to empty-path route (1/2)', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + // Open router page as empty-path route. + await workbenchNavigator.modifyLayout((layout, activePartId) => layout + .addView('view.100', {partId: activePartId}) + .navigateView('view.100', [], {hint: 'test-router'}), + ); + + // Navigate to empty-path route via router link. + const routerPage = new RouterPagePO(appPO, {viewId: 'view.100'}); + await routerPage.enterPath(''); + await routerPage.enterHint('test-view'); + await routerPage.clickNavigateViaRouterLink(); + + const testeeViewPage = new ViewPagePO(appPO, {viewId: 'view.100'}); + + // Expect view to display empty-path route. + await expectView(testeeViewPage).toBeActive(); + await expect(appPO.views()).toHaveCount(1); + await expect.poll(() => testeeViewPage.view.getInfo()).toMatchObject( + { + routeData: {path: '', navigationHint: 'test-view'}, + } satisfies Partial, + ); + }); + + test('should navigate current view when navigating from empty-path route to empty-path route (2/2)', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + // Open router page as empty-path route. + await workbenchNavigator.modifyLayout((layout, activePartId) => layout + .addView('view.100', {partId: activePartId}) + .navigateView('view.100', [], {hint: 'test-router'}), + ); + + // Navigate to empty-path route via router link. + const routerPage = new RouterPagePO(appPO, {viewId: 'view.100'}); + await routerPage.enterPath('/'); + await routerPage.enterHint('test-view'); + await routerPage.clickNavigateViaRouterLink(); + + const testeeViewPage = new ViewPagePO(appPO, {viewId: 'view.100'}); + + // Expect view to display empty-path route. + await expectView(testeeViewPage).toBeActive(); + await expect(appPO.views()).toHaveCount(1); + await expect.poll(() => testeeViewPage.view.getInfo()).toMatchObject( + { + routeData: {path: '', navigationHint: 'test-view'}, + } satisfies Partial, + ); + }); + + test('should navigate current view when navigating from empty-path route to path-based route', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + // Open router page as empty-path route. + await workbenchNavigator.modifyLayout((layout, activePartId) => layout + .addView('view.100', {partId: activePartId}) + .navigateView('view.100', [], {hint: 'test-router'}), + ); + + // Navigate to path-based route via router link. + const routerPage = new RouterPagePO(appPO, {viewId: 'view.100'}); + await routerPage.enterPath('test-view'); + await routerPage.clickNavigateViaRouterLink(); + + const testeeViewPage = new ViewPagePO(appPO, {viewId: 'view.100'}); + + // Expect view to display path-based route. + await expectView(testeeViewPage).toBeActive(); + await expect(appPO.views()).toHaveCount(1); + await expect.poll(() => testeeViewPage.view.getInfo()).toMatchObject( + { + routeData: {path: 'test-view', navigationHint: ''}, + } satisfies Partial, + ); + }); + + test('should update matrix parameters of current view', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + const routerPage = await workbenchNavigator.openInNewTab(RouterPagePO); + await routerPage.enterCommands([{a: 'b', c: 'd'}]); + await routerPage.clickNavigateViaRouterLink(); + + await expectView(routerPage).toBeActive(); + await expect.poll(() => routerPage.view.getInfo()).toMatchObject( + { + routeParams: {a: 'b', c: 'd'}, + routeData: {path: 'test-router', navigationHint: ''}, + } satisfies Partial, + ); }); test('should open view in the current part (layout without main area)', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - // Register Angular routes. - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - await layoutPage.registerRoute({path: '', component: 'router-page', outlet: 'router'}); - await layoutPage.registerRoute({path: '', component: 'view-page', outlet: 'other'}); - await layoutPage.registerRoute({path: 'testee', component: 'view-page'}); - await layoutPage.view.tab.close(); - - // Register new perspective. - const perspectivePage = await workbenchNavigator.openInNewTab(PerspectivePagePO); - await perspectivePage.registerPerspective({ - id: 'test', - data: { - label: 'test', + await workbenchNavigator.createPerspective(factory => factory + .addPart('left') + .addPart('right', {align: 'right'}) + .addView('view.101', {partId: 'left', activateView: true}) + .addView('view.102', {partId: 'right', activateView: true}), + ); + await workbenchNavigator.modifyLayout(layout => layout + .navigateView('view.101', ['test-router'], {state: {navigated: 'false'}}) + .navigateView('view.102', ['test-view'], {state: {navigated: 'false'}}), + ); + + const view1 = appPO.view({viewId: 'view.101'}); + const view2 = appPO.view({viewId: 'view.102'}); + + // Open test view via router link. + const routerPage = new RouterPagePO(appPO, {viewId: 'view.101'}); + await routerPage.enterPath('/test-view'); + await routerPage.enterState({navigated: 'true'}); + await routerPage.clickNavigateViaRouterLink(); + + // Expect test view to replace the router page + await expect.poll(() => view1.getInfo()).toMatchObject( + { + urlSegments: 'test-view', + state: {navigated: 'true'}, + } satisfies Partial, + ); + + // Expect test view not to replace test view on the right. + await expect.poll(() => view2.getInfo()).toMatchObject( + { + urlSegments: 'test-view', + state: {navigated: 'false'}, + } satisfies Partial, + ); + + await expect(appPO.workbench).toEqualWorkbenchLayout({ + workbenchGrid: { + root: new MTreeNode({ + direction: 'row', + ratio: .5, + child1: new MPart({id: 'left', views: [{id: 'view.101'}], activeViewId: 'view.101'}), + child2: new MPart({id: 'right', views: [{id: 'view.102'}], activeViewId: 'view.102'}), + }), }, - parts: [ - {id: 'left'}, - {id: 'right', align: 'right'}, - ], - views: [ - {id: 'router', partId: 'left', activateView: true}, - {id: 'other', partId: 'right', activateView: true}, - ], }); - await perspectivePage.view.tab.close(); + }); + + test('should open view in main area (view is in peripheral area, target="blank")', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + await workbenchNavigator.createPerspective(factory => factory + .addPart(MAIN_AREA) + .addPart('left', {align: 'left'}) + .addView('view.101', {partId: 'left', activateView: true}), + ); + + // Add state via separate navigation as not supported when adding views to the perspective. + await workbenchNavigator.modifyLayout(layout => layout + .navigateView('view.101', ['test-router'], {state: {navigated: 'false'}}), + ); + + const testView = appPO.view({viewId: 'view.1'}); + + // Open test view via router link. + const routerPage = new RouterPagePO(appPO, {viewId: 'view.101'}); + await routerPage.enterCommands(['/test-view']); + await routerPage.enterTarget('blank'); + await routerPage.enterState({navigated: 'true'}); + await routerPage.clickNavigateViaRouterLink(); - // Switch to the newly created perspective. - await appPO.switchPerspective('test'); + // Expect test view to be opened + await expect.poll(() => testView.getInfo()).toMatchObject( + { + viewId: 'view.1', + urlSegments: 'test-view', + state: {navigated: 'true'}, + } satisfies Partial, + ); + + // Expect router page not to be replaced + await expect.poll(() => routerPage.view.getInfo()).toMatchObject( + { + viewId: 'view.101', + urlSegments: 'test-router', + state: {navigated: 'false'}, + } satisfies Partial, + ); - // Expect layout to match the perspective definition. await expect(appPO.workbench).toEqualWorkbenchLayout({ workbenchGrid: { root: new MTreeNode({ direction: 'row', ratio: .5, - child1: new MPart({id: 'left', views: [{id: 'router'}], activeViewId: 'router'}), - child2: new MPart({id: 'right', views: [{id: 'other'}], activeViewId: 'other'}), + child1: new MPart({id: 'left', views: [{id: 'view.101'}], activeViewId: 'view.101'}), + child2: new MPart({id: MAIN_AREA}), + }), + }, + mainAreaGrid: { + root: new MPart({ + views: [{id: 'view.1'}], + activeViewId: 'view.1', }), }, }); + }); - // Open new view via workbench router link. - const routerPage = new RouterPagePO(appPO, {viewId: 'router'}); - await routerPage.enterPath('/testee'); - await routerPage.enterCssClass('testee'); + test('should open view in main area (view is in peripheral area, target=viewId)', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + await workbenchNavigator.createPerspective(factory => factory + .addPart(MAIN_AREA) + .addPart('left', {align: 'left'}) + .addView('view.101', {partId: 'left', activateView: true}), + ); + + // Add state via separate navigation as not supported when adding views to the perspective. + await workbenchNavigator.modifyLayout(layout => layout + .navigateView('view.101', ['test-router'], {state: {navigated: 'false'}}), + ); + + const testView = appPO.view({viewId: 'view.102'}); + + // Open test view via router link. + const routerPage = new RouterPagePO(appPO, {viewId: 'view.101'}); + await routerPage.enterCommands(['/test-view']); + await routerPage.enterTarget('view.102'); + await routerPage.enterState({navigated: true}); await routerPage.clickNavigateViaRouterLink(); - // Expect new view to be opened. - const testeeViewPage = new ViewPagePO(appPO, {cssClass: 'testee'}); - await expectView(testeeViewPage).toBeActive(); + // Expect test view to be opened + await expect.poll(() => testView.getInfo()).toMatchObject( + { + viewId: 'view.102', + urlSegments: 'test-view', + state: {navigated: 'true'}, + } satisfies Partial, + ); + + // Expect router page not to be replaced + await expect.poll(() => routerPage.view.getInfo()).toMatchObject( + { + viewId: 'view.101', + urlSegments: 'test-router', + state: {navigated: 'false'}, + } satisfies Partial, + ); - // Expect new view to be opened in active part of the contextual view i.e. left - const testeeViewId = await testeeViewPage.view.getViewId(); await expect(appPO.workbench).toEqualWorkbenchLayout({ workbenchGrid: { root: new MTreeNode({ direction: 'row', ratio: .5, - child1: new MPart({id: 'left', views: [{id: 'router'}, {id: testeeViewId}], activeViewId: testeeViewId}), - child2: new MPart({id: 'right', views: [{id: 'other'}], activeViewId: 'other'}), + child1: new MPart({id: 'left', views: [{id: 'view.101'}], activeViewId: 'view.101'}), + child2: new MPart({id: MAIN_AREA}), + }), + }, + mainAreaGrid: { + root: new MPart({ + views: [{id: 'view.102'}], + activeViewId: 'view.102', }), }, }); diff --git a/projects/scion/e2e-testing/src/workbench/router.e2e-spec.ts b/projects/scion/e2e-testing/src/workbench/router.e2e-spec.ts index 9dd7bd04f..5c5ff3fa7 100644 --- a/projects/scion/e2e-testing/src/workbench/router.e2e-spec.ts +++ b/projects/scion/e2e-testing/src/workbench/router.e2e-spec.ts @@ -12,11 +12,11 @@ import {expect} from '@playwright/test'; import {test} from '../fixtures'; 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 {MPart, MTreeNode} from '../matcher/to-equal-workbench-layout.matcher'; import {MAIN_AREA} from '../workbench.model'; import {expectView} from '../matcher/view-matcher'; import {NavigationTestPagePO} from './page-object/test-pages/navigation-test-page.po'; +import {ViewInfo} from './page-object/view-info-dialog.po'; test.describe('Workbench Router', () => { @@ -25,8 +25,7 @@ test.describe('Workbench Router', () => { // open test view 1 const routerPage = await workbenchNavigator.openInNewTab(RouterPagePO); - await routerPage.enterPath('test-view'); - await routerPage.enterMatrixParams({param: '1'}); + await routerPage.enterCommands(['test-view', {param: '1'}]); await routerPage.checkActivate(true); await routerPage.enterTarget('view.101'); await routerPage.clickNavigate(); @@ -37,8 +36,7 @@ test.describe('Workbench Router', () => { // open test view 2 await routerPage.view.tab.click(); - await routerPage.enterPath('test-view'); - await routerPage.enterMatrixParams({param: '2'}); + await routerPage.enterCommands(['test-view', {param: '2'}]); await routerPage.checkActivate(true); await routerPage.enterTarget('auto'); await routerPage.clickNavigate(); @@ -48,8 +46,7 @@ test.describe('Workbench Router', () => { // open test view 3 await routerPage.view.tab.click(); - await routerPage.enterPath('test-view'); - await routerPage.enterMatrixParams({param: '3'}); + await routerPage.enterCommands(['test-view', {param: '3'}]); await routerPage.checkActivate(true); await routerPage.enterTarget('auto'); await routerPage.clickNavigate(); @@ -63,8 +60,7 @@ test.describe('Workbench Router', () => { // open test view 1 const routerPage = await workbenchNavigator.openInNewTab(RouterPagePO); - await routerPage.enterPath('test-view'); - await routerPage.enterMatrixParams({param: '1'}); + await routerPage.enterCommands(['test-view', {param: '1'}]); await routerPage.enterTarget('view.101'); await routerPage.clickNavigate(); @@ -74,8 +70,7 @@ test.describe('Workbench Router', () => { // open test view 2 await routerPage.view.tab.click(); - await routerPage.enterPath('test-view'); - await routerPage.enterMatrixParams({param: '2'}); + await routerPage.enterCommands(['test-view', {param: '2'}]); await routerPage.enterTarget('view.102'); await routerPage.clickNavigate(); @@ -85,8 +80,7 @@ test.describe('Workbench Router', () => { // open test view 3 await routerPage.view.tab.click(); - await routerPage.enterPath('test-view'); - await routerPage.enterMatrixParams({param: '3'}); + await routerPage.enterCommands(['test-view', {param: '3'}]); await routerPage.enterTarget('view.103'); await routerPage.clickNavigate(); @@ -96,9 +90,8 @@ test.describe('Workbench Router', () => { // close all test views await routerPage.view.tab.click(); - await routerPage.enterPath('test-view'); + await routerPage.enterCommands(['test-view', {param: '1'}]); // matrix param is ignored when closing views await routerPage.enterTarget(undefined); - await routerPage.enterMatrixParams({param: '1'}); // matrix param is ignored when closing views await routerPage.checkClose(true); await routerPage.clickNavigate(); @@ -106,36 +99,6 @@ test.describe('Workbench Router', () => { await expect(appPO.views()).toHaveCount(1); }); - test('should show title of inactive views when reloading the application', async ({appPO, workbenchNavigator}) => { - await appPO.navigateTo({microfrontendSupport: false}); - - // open test view 1 - const routerPage = await workbenchNavigator.openInNewTab(RouterPagePO); - await routerPage.enterPath('test-pages/navigation-test-page'); - await routerPage.enterMatrixParams({title: 'view-1-title'}); - await routerPage.enterTarget('view.101'); - await routerPage.clickNavigate(); - - // open test view 2 - await routerPage.view.tab.click(); - await routerPage.enterPath('test-pages/navigation-test-page'); - await routerPage.enterTarget('view.102'); - await routerPage.enterMatrixParams({title: 'view-2-title'}); - await routerPage.clickNavigate(); - - const testee1ViewPage = new NavigationTestPagePO(appPO, {viewId: 'view.101'}); - const testee2ViewPage = new NavigationTestPagePO(appPO, {viewId: 'view.102'}); - - // reload the application - await appPO.reload(); - - await expectView(testee1ViewPage).toBeInactive(); - await expect(testee1ViewPage.view.tab.title).toHaveText('view-1-title'); - - await expectView(testee2ViewPage).toBeActive(); - await expect(testee2ViewPage.view.tab.title).toHaveText('view-2-title'); - }); - test('should not throw outlet activation error when opening a new view tab once a view tab was closed', async ({appPO, consoleLogs}) => { await appPO.navigateTo({microfrontendSupport: false}); @@ -248,7 +211,7 @@ test.describe('Workbench Router', () => { await expectView(testee2ViewPage).toBeInactive(); }); - test('should ignore closing a view with an unknown viewId via router', async ({appPO, workbenchNavigator}) => { + test('should error when trying to close a view that does not exist', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); // open the test view in a new view tab @@ -269,57 +232,39 @@ test.describe('Workbench Router', () => { // expect the test views to be opened await expect(appPO.views()).toHaveCount(3); - // close the unknown view 99 + // try closing view 100 await routerPage.view.tab.click(); await routerPage.enterPath(''); - await routerPage.enterTarget('view.99'); + await routerPage.enterTarget('view.100'); await routerPage.checkClose(true); - await routerPage.clickNavigate(); - - const testee1ViewPage = new NavigationTestPagePO(appPO, {cssClass: 'testee-1'}); - const testee2ViewPage = new NavigationTestPagePO(appPO, {cssClass: 'testee-2'}); - - // expect no view to be closed - await expect(appPO.views()).toHaveCount(3); - await expectView(routerPage).toBeActive(); - await expectView(testee1ViewPage).toBeInactive(); - await expectView(testee2ViewPage).toBeInactive(); + await expect(() => routerPage.clickNavigate()).rejects.toThrow(/NullViewError/); }); test('should reject closing a view by viewId via router if a path is also given', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - // open the test view in a new view tab const routerPage = await workbenchNavigator.openInNewTab(RouterPagePO); - await routerPage.view.tab.click(); - await routerPage.enterPath('test-pages/navigation-test-page'); - await routerPage.enterTarget('view.101'); - await routerPage.clickNavigate(); - - // open the test view in a new view tab - await routerPage.view.tab.click(); - await routerPage.enterPath('test-pages/navigation-test-page'); - await routerPage.enterTarget('view.102'); + await routerPage.enterCommands(['test-pages/navigation-test-page']); + await routerPage.enterTarget('view.100'); + await routerPage.checkActivate(false); await routerPage.clickNavigate(); - // expect the test views to be opened - await expect(appPO.views()).toHaveCount(3); - - // try closing view by providing viewId and path - await routerPage.view.tab.click(); - await routerPage.enterPath('test-pages/navigation-test-page'); - await routerPage.enterTarget('view.101'); + // Expect to error when passing viewId and path + await routerPage.enterCommands(['test-pages/navigation-test-page']); + await routerPage.enterTarget('view.100'); await routerPage.checkClose(true); + await expect(routerPage.clickNavigate()).rejects.toThrow(/\[NavigateError]/); - // expect closing to be rejected - await expect(routerPage.clickNavigate()).rejects.toThrow(/\[WorkbenchRouterError]\[IllegalArgumentError]/); - - const testee1ViewPage = new NavigationTestPagePO(appPO, {viewId: 'view.101'}); - const testee2ViewPage = new NavigationTestPagePO(appPO, {viewId: 'view.102'}); + // Expect to error when passing viewId and hint + await routerPage.enterCommands([]); + await routerPage.enterTarget('view.100'); + await routerPage.enterHint('hint'); + await routerPage.checkClose(true); + await expect(routerPage.clickNavigate()).rejects.toThrow(/\[NavigateError]/); - await expect(appPO.views()).toHaveCount(3); - await expectView(testee1ViewPage).toBeInactive(); - await expectView(testee2ViewPage).toBeInactive(); + // Expect view not to be closed. + const testeeViewPage = new NavigationTestPagePO(appPO, {viewId: 'view.100'}); + await expectView(testeeViewPage).toBeInactive(); }); test('should allow closing all views matching the path `test-pages/navigation-test-page` via router', async ({appPO, workbenchNavigator}) => { @@ -798,28 +743,6 @@ test.describe('Workbench Router', () => { await expectView(testee2ViewPage).toBeInactive(); }); - test('should not destroy the component of the view when it is inactivated', async ({appPO, workbenchNavigator}) => { - await appPO.navigateTo({microfrontendSupport: false}); - - const routerPage = await workbenchNavigator.openInNewTab(RouterPagePO); - const viewPage = await workbenchNavigator.openInNewTab(ViewPagePO); - - const componentInstanceId = await viewPage.getComponentInstanceId(); - - // activate the router test view - await routerPage.view.tab.click(); - await expectView(routerPage).toBeActive(); - await expectView(viewPage).toBeInactive(); - - // activate the test view - await viewPage.view.tab.click(); - await expectView(viewPage).toBeActive(); - await expectView(routerPage).toBeInactive(); - - // expect the component not to be constructed anew - await expect.poll(() => viewPage.getComponentInstanceId()).toEqual(componentInstanceId); - }); - test('should open a new view if no present view can be found [target=auto]', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); @@ -871,9 +794,8 @@ test.describe('Workbench Router', () => { // navigate to the test view 1 const routerPage = await workbenchNavigator.openInNewTab(RouterPagePO); - await routerPage.enterPath('test-view'); + await routerPage.enterCommands(['test-view', {param: 'value1'}]); await routerPage.enterTarget('view.101'); - await routerPage.enterMatrixParams({param: 'value1'}); await routerPage.clickNavigate(); const testee1ViewPage = new ViewPagePO(appPO, {viewId: 'view.101'}); @@ -883,9 +805,8 @@ test.describe('Workbench Router', () => { // navigate to the test view 2 await routerPage.view.tab.click(); - await routerPage.enterPath('test-view'); + await routerPage.enterCommands(['test-view', {param: 'value1'}]); await routerPage.enterTarget('view.102'); - await routerPage.enterMatrixParams({param: 'value1'}); await routerPage.clickNavigate(); const testee2ViewPage = new ViewPagePO(appPO, {viewId: 'view.102'}); @@ -895,9 +816,8 @@ test.describe('Workbench Router', () => { // update all matching present views await routerPage.view.tab.click(); - await routerPage.enterPath('test-view'); + await routerPage.enterCommands(['test-view', {param: 'value2'}]); await routerPage.enterTarget('auto'); - await routerPage.enterMatrixParams({param: 'value2'}); await routerPage.clickNavigate(); // expect the present views to be updated @@ -956,9 +876,8 @@ test.describe('Workbench Router', () => { // navigate to a present view updating its matrix params await routerPage.view.tab.click(); - await routerPage.enterPath('test-view'); + await routerPage.enterCommands(['test-view', {param: 'value1'}]); await routerPage.enterTarget(''); // will be interpreted as undefined - await routerPage.enterMatrixParams({param: 'value1'}); await routerPage.clickNavigate(); // expect the present view to be updated @@ -973,9 +892,8 @@ test.describe('Workbench Router', () => { // navigate to the test view 1 const routerPage = await workbenchNavigator.openInNewTab(RouterPagePO); - await routerPage.enterPath('test-view'); + await routerPage.enterCommands(['test-view', {param: 'value1'}]); await routerPage.enterTarget('view.101'); - await routerPage.enterMatrixParams({param: 'value1'}); await routerPage.clickNavigate(); const testee1ViewPage = new ViewPagePO(appPO, {viewId: 'view.101'}); @@ -985,9 +903,8 @@ test.describe('Workbench Router', () => { // navigate to the test view 2 await routerPage.view.tab.click(); - await routerPage.enterPath('test-view'); + await routerPage.enterCommands(['test-view', {param: 'value1'}]); await routerPage.enterTarget('view.102'); - await routerPage.enterMatrixParams({param: 'value1'}); await routerPage.clickNavigate(); const testee2ViewPage = new ViewPagePO(appPO, {viewId: 'view.102'}); @@ -997,9 +914,8 @@ test.describe('Workbench Router', () => { // update all matching present views await routerPage.view.tab.click(); - await routerPage.enterPath('test-view'); + await routerPage.enterCommands(['test-view', {param: 'value2'}]); await routerPage.enterTarget(''); - await routerPage.enterMatrixParams({param: 'value2'}); await routerPage.clickNavigate(); // expect the present views to be updated @@ -1029,21 +945,284 @@ test.describe('Workbench Router', () => { await expectView(testee1ViewPage).toBeActive(); }); - test('should support app URL to contain view outlets of views in the workbench grid', async ({appPO, workbenchNavigator, page}) => { - await appPO.navigateTo({microfrontendSupport: false, perspectives: ['perspective']}); + test('should navigate views of the same path and hint [target=auto] (1/4)', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); - // Define perspective with a part on the left. - await appPO.switchPerspective('perspective'); - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - await layoutPage.addPart('left', {align: 'left', ratio: .25}); + await workbenchNavigator.modifyLayout(layout => layout + .addPart('right-top', {relativeTo: MAIN_AREA, align: 'right'}) + .addPart('right-bottom', {relativeTo: 'right-top', align: 'bottom'}) + .addView('view.101', {partId: 'right-top', activateView: true}) + .addView('view.102', {partId: 'right-bottom', activateView: true}) + .navigateView('view.101', [], {hint: 'test-view', state: {navigated: 'false'}}) + .navigateView('view.102', [], {hint: 'test-router', state: {navigated: 'false'}}), + ); - // Add view to the left part in the workbench grid. + const testView1 = appPO.view({viewId: 'view.101'}); + const testView2 = appPO.view({viewId: 'view.102'}); + + // Navigate to empty-path route and hint 'test-view'. const routerPage = await workbenchNavigator.openInNewTab(RouterPagePO); - await routerPage.enterPath('test-view'); - await routerPage.enterTarget('blank'); - await routerPage.enterBlankPartId('left'); + await routerPage.enterCommands([]); + await routerPage.enterHint('test-view'); + await routerPage.enterState({navigated: 'true'}); + await routerPage.enterTarget('auto'); + await routerPage.clickNavigate(); + + // Expect view.101 to be navigated. + await expect.poll(() => testView1.getInfo()).toMatchObject( + { + routeData: {path: '', navigationHint: 'test-view'}, + state: {navigated: 'true'}, + } satisfies Partial, + ); + // Expect view.102 not to be navigated. + await expect.poll(() => testView2.getInfo()).toMatchObject( + { + routeData: {path: '', navigationHint: 'test-router'}, + state: {navigated: 'false'}, + } satisfies Partial, + ); + }); + + test('should navigate views of the same path and hint [target=auto] (2/4)', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + await workbenchNavigator.modifyLayout(layout => layout + .addPart('right-top', {relativeTo: MAIN_AREA, align: 'right'}) + .addPart('right-bottom', {relativeTo: 'right-top', align: 'bottom'}) + .addView('view.101', {partId: 'right-top', activateView: true}) + .addView('view.102', {partId: 'right-bottom', activateView: true}) + .navigateView('view.101', ['test-view'], {hint: 'test-view', state: {navigated: 'false'}}) + .navigateView('view.102', ['test-view'], {state: {navigated: 'false'}}), + ); + + const testView1 = appPO.view({viewId: 'view.101'}); + const testView2 = appPO.view({viewId: 'view.102'}); + + // Navigate to 'test-view' route without hint. + const routerPage = await workbenchNavigator.openInNewTab(RouterPagePO); + await routerPage.enterCommands(['test-view']); + await routerPage.enterTarget('auto'); + await routerPage.enterState({navigated: 'true'}); + await routerPage.clickNavigate(); + + // Expect view.101 not to be navigated. + await expect.poll(() => testView1.getInfo()).toMatchObject( + { + routeData: {path: 'test-view', navigationHint: 'test-view'}, + state: {navigated: 'false'}, + } satisfies Partial, + ); + // Expect view.102 to be navigated. + await expect.poll(() => testView2.getInfo()).toMatchObject( + { + routeData: {path: 'test-view', navigationHint: ''}, + state: {navigated: 'true'}, + } satisfies Partial, + ); + }); + + test('should navigate views of the same path and hint [target=auto] (3/4)', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + await workbenchNavigator.modifyLayout(layout => layout + .addPart('right-top', {relativeTo: MAIN_AREA, align: 'right'}) + .addPart('right-bottom', {relativeTo: 'right-top', align: 'bottom'}) + .addView('view.101', {partId: 'right-top', activateView: true}) + .addView('view.102', {partId: 'right-bottom', activateView: true}) + .navigateView('view.101', ['test-view'], {hint: 'test-view', state: {navigated: 'false'}}) + .navigateView('view.102', ['test-view'], {hint: 'test-view', state: {navigated: 'false'}}), + ); + + const testView1 = appPO.view({viewId: 'view.101'}); + const testView2 = appPO.view({viewId: 'view.102'}); + + // Navigate to 'test-view' route without hint. + const routerPage = await workbenchNavigator.openInNewTab(RouterPagePO); + await routerPage.enterCommands(['test-view']); + await routerPage.enterHint('test-view'); + await routerPage.enterTarget('auto'); + await routerPage.enterState({navigated: 'true'}); + await routerPage.clickNavigate(); + + // Expect view.101 to be navigated. + await expect.poll(() => testView1.getInfo()).toMatchObject( + { + routeData: {path: 'test-view', navigationHint: 'test-view'}, + state: {navigated: 'true'}, + } satisfies Partial, + ); + // Expect view.102 to be navigated. + await expect.poll(() => testView2.getInfo()).toMatchObject( + { + routeData: {path: 'test-view', navigationHint: 'test-view'}, + state: {navigated: 'true'}, + } satisfies Partial, + ); + }); + + test('should navigate views of the same path and hint [target=auto] (4/4)', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + await workbenchNavigator.modifyLayout(layout => layout + .addPart('right', {relativeTo: MAIN_AREA, align: 'right'}) + .addView('view.101', {partId: 'right', activateView: true}) + .navigateView('view.101', [], {hint: 'test-router', state: {navigated: 'false'}}), + ); + + const testView1 = appPO.view({viewId: 'view.101'}); + const testView2 = appPO.view({cssClass: 'testee'}); + + // Navigate to empty-path route and hint 'test-view'. + const routerPage = await workbenchNavigator.openInNewTab(RouterPagePO); + await routerPage.enterCommands([]); + await routerPage.enterHint('test-view'); + await routerPage.enterTarget('auto'); + await routerPage.enterState({navigated: 'true'}); + await routerPage.enterCssClass('testee'); + await routerPage.clickNavigate(); + + // Expect view.101 not to be navigated. + await expect.poll(() => testView1.getInfo()).toMatchObject( + { + routeData: {path: '', navigationHint: 'test-router'}, + state: {navigated: 'false'}, + } satisfies Partial, + ); + // Expect testee to be navigated. + await expect.poll(() => testView2.getInfo()).toMatchObject( + { + routeData: {path: '', navigationHint: 'test-view'}, + state: {navigated: 'true'}, + } satisfies Partial, + ); + }); + + test('should close views of the same path and hint [target=auto] (1/4)', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + await workbenchNavigator.modifyLayout(layout => layout + .addPart('right-top', {relativeTo: MAIN_AREA, align: 'right'}) + .addPart('right-bottom', {relativeTo: 'right-top', align: 'bottom'}) + .addView('view.101', {partId: 'right-top', activateView: true}) + .addView('view.102', {partId: 'right-bottom', activateView: true}) + .navigateView('view.101', [], {hint: 'test-view'}) + .navigateView('view.102', [], {hint: 'test-router'}), + ); + + const testView1 = new ViewPagePO(appPO, {viewId: 'view.101'}); + const testView2 = new RouterPagePO(appPO, {viewId: 'view.102'}); + + await expectView(testView1).toBeActive(); + await expectView(testView2).toBeActive(); + + // Close views navigated to empty-path route and hint 'test-view'. + const routerPage = await workbenchNavigator.openInNewTab(RouterPagePO); + await routerPage.enterCommands([]); + await routerPage.enterHint('test-view'); + await routerPage.checkClose(true); + await routerPage.clickNavigate(); + + // Expect view.101 to be closed. + await expectView(testView1).not.toBeAttached(); + // Expect view.102 not to be closed. + await expectView(testView2).toBeActive(); + }); + + test('should close views of the same path and hint [target=auto] (2/4)', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + await workbenchNavigator.modifyLayout(layout => layout + .addPart('right-top', {relativeTo: MAIN_AREA, align: 'right'}) + .addPart('right-bottom', {relativeTo: 'right-top', align: 'bottom'}) + .addView('view.101', {partId: 'right-top', activateView: true}) + .addView('view.102', {partId: 'right-bottom', activateView: true}) + .navigateView('view.101', ['test-view'], {hint: 'test-view'}) + .navigateView('view.102', ['test-view']), + ); + + const testView1 = new ViewPagePO(appPO, {viewId: 'view.101'}); + const testView2 = new ViewPagePO(appPO, {viewId: 'view.102'}); + + await expectView(testView1).toBeActive(); + await expectView(testView2).toBeActive(); + + // Close views navigated to 'test-view' route without hint. + const routerPage = await workbenchNavigator.openInNewTab(RouterPagePO); + await routerPage.enterCommands(['test-view']); + await routerPage.checkClose(true); + await routerPage.clickNavigate(); + + // Expect view.101 not to be closed. + await expectView(testView1).toBeActive(); + // Expect view.102 to be closed. + await expectView(testView2).not.toBeAttached(); + }); + + test('should close views of the same path and hint [target=auto] (3/4)', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + await workbenchNavigator.modifyLayout(layout => layout + .addPart('right-top', {relativeTo: MAIN_AREA, align: 'right'}) + .addPart('right-bottom', {relativeTo: 'right-top', align: 'bottom'}) + .addView('view.101', {partId: 'right-top', activateView: true}) + .addView('view.102', {partId: 'right-bottom', activateView: true}) + .navigateView('view.101', ['test-view'], {hint: 'test-view'}) + .navigateView('view.102', ['test-view'], {hint: 'test-view'}), + ); + + const testView1 = new ViewPagePO(appPO, {viewId: 'view.101'}); + const testView2 = new ViewPagePO(appPO, {viewId: 'view.102'}); + + await expectView(testView1).toBeActive(); + await expectView(testView2).toBeActive(); + + // Close views navigated to 'test-view' route without hint. + const routerPage = await workbenchNavigator.openInNewTab(RouterPagePO); + await routerPage.enterCommands(['test-view']); + await routerPage.enterHint('test-view'); + await routerPage.checkClose(true); await routerPage.clickNavigate(); + // Expect view.101 to be closed. + await expectView(testView1).not.toBeAttached(); + // Expect view.102 to be closed. + await expectView(testView2).not.toBeAttached(); + }); + + test('should close views of the same path and hint [target=auto] (4/4)', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + await workbenchNavigator.modifyLayout(layout => layout + .addPart('right', {relativeTo: MAIN_AREA, align: 'right'}) + .addView('view.101', {partId: 'right', activateView: true}) + .navigateView('view.101', [], {hint: 'test-router'}), + ); + + const testView1 = new RouterPagePO(appPO, {viewId: 'view.101'}); + + // Close views navigated to empty-path route and hint 'test-view'. + const routerPage = await workbenchNavigator.openInNewTab(RouterPagePO); + await routerPage.enterCommands([]); + await routerPage.enterHint('test-view'); + await routerPage.checkClose(true); + await routerPage.clickNavigate(); + + // Expect view.101 not to be closed. + await expectView(testView1).toBeActive(); + }); + + test('should support app URL to contain view outlets of views in the workbench grid', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + await workbenchNavigator.modifyLayout(layout => layout + .addPart('left', {align: 'left', ratio: .25}) + .addView('view.100', {partId: 'left', activateView: true}) + .navigateView('view.100', ['test-view']), + ); + + const viewPage = new ViewPagePO(appPO, {viewId: 'view.100'}); + // Expect the view to be opened in the left part. await expect(appPO.workbench).toEqualWorkbenchLayout({ workbenchGrid: { @@ -1052,30 +1231,16 @@ test.describe('Workbench Router', () => { ratio: .25, child1: new MPart({ id: 'left', - views: [{id: 'view.3'}], // test view page - activeViewId: 'view.3', + views: [{id: 'view.100'}], + activeViewId: 'view.100', }), child2: new MPart({id: MAIN_AREA}), }), }, - mainAreaGrid: { - root: new MPart({ - id: await layoutPage.view.part.getPartId(), - views: [{id: 'view.1'}, {id: 'view.2'}], // layout page, router page - activeViewId: 'view.2', - }), - activePartId: await layoutPage.view.part.getPartId(), - }, }); - // Capture current URL. - const url = page.url(); - - // Clear the browser URL. - await page.goto('about:blank'); - // WHEN: Opening the app with a URL that contains view outlets of views from the workbench grid - await appPO.navigateTo({url, microfrontendSupport: false, perspectives: ['perspective']}); + await appPO.reload(); // THEN: Expect the workbench layout to be restored. await expect(appPO.workbench).toEqualWorkbenchLayout({ @@ -1085,44 +1250,26 @@ test.describe('Workbench Router', () => { ratio: .25, child1: new MPart({ id: 'left', - views: [{id: 'view.3'}], // test view page - activeViewId: 'view.3', + views: [{id: 'view.100'}], + activeViewId: 'view.100', }), child2: new MPart({id: MAIN_AREA}), }), }, - mainAreaGrid: { - root: new MPart({ - id: await layoutPage.view.part.getPartId(), - views: [{id: 'view.1'}, {id: 'view.2'}], // layout page, router page - activeViewId: 'view.2', - }), - activePartId: await layoutPage.view.part.getPartId(), - }, }); // Expect the test view to display. - const viewPage = new ViewPagePO(appPO, {viewId: 'view.3'}); await expectView(viewPage).toBeActive(); }); - test('should allow for navigation to an empty path auxiliary route in the workbench grid', async ({appPO, workbenchNavigator}) => { - await appPO.navigateTo({microfrontendSupport: false, perspectives: ['perspective']}); - - // Define perspective with a part on the left. - await appPO.switchPerspective('perspective'); - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - await layoutPage.addPart('left', {align: 'left', ratio: .25}); - - // Register auxiliary route. - await layoutPage.registerRoute({path: '', outlet: 'testee', component: 'view-page'}); + test('should allow for navigation to an empty-path route in the workbench grid', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); - // Open view in the left part. - const routerPage = await workbenchNavigator.openInNewTab(RouterPagePO); - await routerPage.enterPath(''); - await routerPage.enterTarget('testee'); - await routerPage.enterBlankPartId('left'); - await routerPage.clickNavigate(); + await workbenchNavigator.modifyLayout(layout => layout + .addPart('left', {align: 'left', ratio: .25}) + .addView('view.100', {partId: 'left', activateView: true}) + .navigateView('view.100', [], {hint: 'test-view'}), + ); // Expect the view to be opened in the left part. await expect(appPO.workbench).toEqualWorkbenchLayout({ @@ -1132,57 +1279,314 @@ test.describe('Workbench Router', () => { ratio: .25, child1: new MPart({ id: 'left', - views: [{id: 'testee'}], - activeViewId: 'testee', + views: [{id: 'view.100'}], + activeViewId: 'view.100', }), child2: new MPart({id: MAIN_AREA}), }), }, - mainAreaGrid: { - root: new MPart({ - id: await layoutPage.view.part.getPartId(), - views: [{id: 'view.1'}, {id: 'view.2'}], // layout page, router page - activeViewId: 'view.2', - }), - activePartId: await layoutPage.view.part.getPartId(), - }, }); // Expect the view to display. - const viewPage = new ViewPagePO(appPO, {viewId: 'testee'}); + const viewPage = new ViewPagePO(appPO, {viewId: 'view.100'}); await expectView(viewPage).toBeActive(); }); - test('should allow for navigation to an empty path auxiliary route in the main area', async ({appPO, workbenchNavigator}) => { + test('should allow for navigation to an empty-path route in the main area', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - // Register auxiliary route. - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - await layoutPage.registerRoute({path: '', outlet: 'testee', component: 'view-page'}); - - // Open view in the left part. + // Open view in the main area. const routerPage = await workbenchNavigator.openInNewTab(RouterPagePO); - await routerPage.enterPath(''); - await routerPage.enterTarget('testee'); + await routerPage.enterCommands([]); + await routerPage.enterHint('test-view'); + await routerPage.enterTarget('view.100'); await routerPage.clickNavigate(); + await routerPage.view.tab.close(); - // Expect the view to be opened in the left part. + // Expect the view to be opened in the main area. await expect(appPO.workbench).toEqualWorkbenchLayout({ workbenchGrid: { root: new MPart({id: MAIN_AREA}), }, mainAreaGrid: { root: new MPart({ - id: await layoutPage.view.part.getPartId(), - views: [{id: 'view.1'}, {id: 'view.2'}, {id: 'testee'}], // layout page, router page, testee view - activeViewId: 'testee', + views: [{id: 'view.100'}], + activeViewId: 'view.100', }), - activePartId: await layoutPage.view.part.getPartId(), }, }); // Expect the view to display. - const viewPage = new ViewPagePO(appPO, {viewId: 'testee'}); + const viewPage = new ViewPagePO(appPO, {viewId: 'view.100'}); await expectView(viewPage).toBeActive(); }); + + test('should navigate from path-based route to path-based route', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + // Open router page as path-based route. + await workbenchNavigator.modifyLayout((layout, activePartId) => layout + .addView('view.100', {partId: activePartId}) + .navigateView('view.100', ['test-router']), + ); + + // Navigate to path-based route. + const routerPage = new RouterPagePO(appPO, {viewId: 'view.100'}); + await routerPage.enterCommands(['test-view']); + await routerPage.enterTarget('view.100'); + await routerPage.clickNavigate(); + + const testeeViewPage = new ViewPagePO(appPO, {viewId: 'view.100'}); + + // Expect view to display path-based route. + await expectView(testeeViewPage).toBeActive(); + await expect(appPO.views()).toHaveCount(1); + await expect.poll(() => testeeViewPage.view.getInfo()).toMatchObject( + { + routeData: {path: 'test-view', navigationHint: ''}, + } satisfies Partial, + ); + }); + + test('should navigate from path-based route to empty-path route', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + // Open router page as path-based route. + await workbenchNavigator.modifyLayout((layout, activePartId) => layout + .addView('view.100', {partId: activePartId}) + .navigateView('view.100', ['test-router']), + ); + + // Navigate to empty-path route. + const routerPage = new RouterPagePO(appPO, {viewId: 'view.100'}); + await routerPage.enterCommands([]); + await routerPage.enterHint('test-view'); + await routerPage.enterTarget('view.100'); + await routerPage.clickNavigate(); + + const testeeViewPage = new ViewPagePO(appPO, {viewId: 'view.100'}); + + // Expect view to display empty-path route. + await expectView(testeeViewPage).toBeActive(); + await expect(appPO.views()).toHaveCount(1); + await expect.poll(() => testeeViewPage.view.getInfo()).toMatchObject( + { + routeData: {path: '', navigationHint: 'test-view'}, + } satisfies Partial, + ); + }); + + test('should navigate from empty-path route to empty-path route', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + // Open router page as empty-path route. + await workbenchNavigator.modifyLayout((layout, activePartId) => layout + .addView('view.100', {partId: activePartId}) + .navigateView('view.100', [], {hint: 'test-router'}), + ); + + // Navigate to empty-path route. + const routerPage = new RouterPagePO(appPO, {viewId: 'view.100'}); + await routerPage.enterCommands([]); + await routerPage.enterHint('test-view'); + await routerPage.enterTarget('view.100'); + await routerPage.clickNavigate(); + + const testeeViewPage = new ViewPagePO(appPO, {viewId: 'view.100'}); + + // Expect view to display empty-path route. + await expectView(testeeViewPage).toBeActive(); + await expect(appPO.views()).toHaveCount(1); + await expect.poll(() => testeeViewPage.view.getInfo()).toMatchObject( + { + routeData: {path: '', navigationHint: 'test-view'}, + } satisfies Partial, + ); + }); + + test('should navigate from empty-path route to path-based route', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + // Open router page as empty-path route. + await workbenchNavigator.modifyLayout((layout, activePartId) => layout + .addView('view.100', {partId: activePartId}) + .navigateView('view.100', [], {hint: 'test-router'}), + ); + + // Navigate to path-based route. + const routerPage = new RouterPagePO(appPO, {viewId: 'view.100'}); + await routerPage.enterCommands(['test-view']); + await routerPage.enterTarget('view.100'); + await routerPage.clickNavigate(); + + const testeeViewPage = new ViewPagePO(appPO, {viewId: 'view.100'}); + + // Expect view to display path-based route. + await expectView(testeeViewPage).toBeActive(); + await expect(appPO.views()).toHaveCount(1); + await expect.poll(() => testeeViewPage.view.getInfo()).toMatchObject( + { + routeData: {path: 'test-view', navigationHint: ''}, + } satisfies Partial, + ); + }); + + test('should resolve to correct route', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + // Open test-view with {path: 'test-view'} + const routerPage = await workbenchNavigator.openInNewTab(RouterPagePO); + await routerPage.enterPath('test-view'); + await routerPage.enterHint(''); + await routerPage.enterCssClass('testee'); + await routerPage.clickNavigate(); + + const testeeViewPage = new ViewPagePO(appPO, {cssClass: 'testee'}); + + await expect.poll(() => testeeViewPage.view.getInfo()).toMatchObject( + { + routeData: {path: 'test-view', navigationHint: ''}, + } satisfies Partial, + ); + await testeeViewPage.view.tab.close(); + + // Open test-view with {path: 'test-view', hint: 'test-view'} + await routerPage.view.tab.click(); + await routerPage.enterPath('test-view'); + await routerPage.enterHint('test-view'); + await routerPage.enterCssClass('testee'); + await routerPage.clickNavigate(); + + await expect.poll(() => testeeViewPage.view.getInfo()).toMatchObject( + { + routeData: {path: 'test-view', navigationHint: 'test-view'}, + } satisfies Partial, + ); + await testeeViewPage.view.tab.close(); + + // Open test-view with {path: '', hint: 'test-view'} + await routerPage.view.tab.click(); + await routerPage.enterPath(''); + await routerPage.enterHint('test-view'); + await routerPage.enterCssClass('testee'); + await routerPage.clickNavigate(); + + await expect.poll(() => testeeViewPage.view.getInfo()).toMatchObject( + { + routeData: {path: '', navigationHint: 'test-view'}, + } satisfies Partial, + ); + }); + + test('should reject if no path or hint is set', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + // Open view without specifying path or hint. + const routerPage = await workbenchNavigator.openInNewTab(RouterPagePO); + await routerPage.enterPath(''); + await routerPage.enterHint(''); + + // Expect navigation to be rejected. + await expect(routerPage.clickNavigate()).rejects.toThrow(/\[NavigateError]/); + }); + + test.describe('Navigate by alternativeViewId', () => { + + test('should navigate view by alternative view id', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + await workbenchNavigator.createPerspective(factory => factory + .addPart('left') + .addPart('right', {relativeTo: 'left', align: 'right'}) + .addView('router', {partId: 'left', activateView: true, cssClass: 'router'}) + .addView('testee', {partId: 'right', cssClass: 'testee'}) + .navigateView('router', ['test-router']), + ); + + const routerPage = new RouterPagePO(appPO, {cssClass: 'router'}); + const testeeViewPage = new ViewPagePO(appPO, {cssClass: 'testee'}); + + // Open test view, assigning it an alternative view id + await routerPage.enterCommands(['test-pages/navigation-test-page/1']); + await routerPage.enterTarget('testee'); // alternative view id + await routerPage.clickNavigate(); + await expect.poll(() => testeeViewPage.view.getInfo()).toMatchObject({urlSegments: 'test-pages/navigation-test-page/1'} satisfies Partial); + + // Navigate the test view by its alternative view id + await routerPage.enterCommands(['test-pages/navigation-test-page/2']); + await routerPage.enterTarget('testee'); // alternative view id + await routerPage.clickNavigate(); + await expect.poll(() => testeeViewPage.view.getInfo()).toMatchObject({urlSegments: 'test-pages/navigation-test-page/2'} satisfies Partial); + }); + + test('should close single view by alternative view id', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + await workbenchNavigator.createPerspective(factory => factory + .addPart('left') + .addPart('right', {relativeTo: 'left', align: 'right'}) + .addView('test-router', {partId: 'left', activateView: true, cssClass: 'router'}) + .addView('testee-1', {partId: 'right', cssClass: 'testee-1'}) + .addView('testee-2', {partId: 'right', cssClass: 'testee-2', activateView: true}) + .navigateView('test-router', ['test-router']) + .navigateView('testee-1', ['test-view']) + .navigateView('testee-2', ['test-view']), + ); + + const routerPage = new RouterPagePO(appPO, {cssClass: 'router'}); + const testee1ViewPage = new ViewPagePO(appPO, {cssClass: 'testee-1'}); + const testee2ViewPage = new ViewPagePO(appPO, {cssClass: 'testee-2'}); + + // Expect test views to be opened. + await expect(appPO.views()).toHaveCount(3); + + // Close the view with alternative id 'testee-1'. + await routerPage.enterPath(''); + await routerPage.enterTarget('testee-1'); // alternative view id + await routerPage.checkClose(true); + await routerPage.clickNavigate(); + + // Expect the view with alternative id 'testee-1' to be closed. + await expect(appPO.views()).toHaveCount(2); + await expectView(routerPage).toBeActive(); + await expectView(testee1ViewPage).not.toBeAttached(); + await expectView(testee2ViewPage).toBeActive(); + }); + + test('should close multiple views by alternative view id', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + await workbenchNavigator.createPerspective(factory => factory + .addPart('left') + .addPart('right', {relativeTo: 'left', align: 'right'}) + .addView('test-router', {partId: 'left', activateView: true, cssClass: 'router'}) + .addView('testee-1', {partId: 'right', cssClass: 'testee-1'}) + .addView('testee-1', {partId: 'right', cssClass: 'testee-2'}) + .addView('testee-2', {partId: 'right', cssClass: 'testee-3', activateView: true}) + .navigateView('test-router', ['test-router']) + .navigateView('testee-1', ['test-view']) + .navigateView('testee-2', ['test-view']), + ); + + const routerPage = new RouterPagePO(appPO, {cssClass: 'router'}); + const testee1ViewPage = new ViewPagePO(appPO, {cssClass: 'testee-1'}); + const testee2ViewPage = new ViewPagePO(appPO, {cssClass: 'testee-2'}); + const testee3ViewPage = new ViewPagePO(appPO, {cssClass: 'testee-3'}); + + // Expect test views to be opened + await expect(appPO.views()).toHaveCount(4); + + // Close the views with alternative id 'testee-1'. + await routerPage.enterPath(''); + await routerPage.enterTarget('testee-1'); + await routerPage.checkClose(true); + await routerPage.clickNavigate(); + + // Expect the views with alterantive id 'testee-1' to be closed. + await expect(appPO.views()).toHaveCount(2); + await expectView(testee1ViewPage).not.toBeAttached(); + await expectView(testee2ViewPage).not.toBeAttached(); + await expectView(testee3ViewPage).toBeActive(); + }); + }); }); diff --git a/projects/scion/e2e-testing/src/workbench/view-css-class.e2e-spec.ts b/projects/scion/e2e-testing/src/workbench/view-css-class.e2e-spec.ts index e3519c294..08ad7ae42 100644 --- a/projects/scion/e2e-testing/src/workbench/view-css-class.e2e-spec.ts +++ b/projects/scion/e2e-testing/src/workbench/view-css-class.e2e-spec.ts @@ -12,7 +12,6 @@ import {test} from '../fixtures'; import {ViewPagePO} from './page-object/view-page.po'; import {expect} from '@playwright/test'; import {RouterPagePO} from './page-object/router-page.po'; -import {LayoutPagePO} from './page-object/layout-page.po'; test.describe('Workbench View CSS Class', () => { @@ -67,30 +66,34 @@ test.describe('Workbench View CSS Class', () => { }); }); - test('should associate CSS classes with a navigation (WorkbenchRouter.navigate)', async ({appPO, workbenchNavigator}) => { + test('should associate CSS classes with a view (WorkbenchLayout.addView) and navigation (WorkbenchRouter.navigate)', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - await layoutPage.addPart('right', {align: 'right'}); - await layoutPage.addView('view.100', {partId: 'right', activateView: true}); + await workbenchNavigator.modifyLayout(layout => layout + .addPart('right', {align: 'right'}) + .addView('view.100', {partId: 'right', activateView: true, cssClass: 'testee-layout'}), + ); const viewPage = new ViewPagePO(appPO, {viewId: 'view.100'}); // Navigate to 'test-pages/navigation-test-page/1' passing CSS class 'testee-navigation-1'. const routerPage = await workbenchNavigator.openInNewTab(RouterPagePO); - await routerPage.enterPath('test-pages/navigation-test-page/1'); + await routerPage.enterCommands(['test-pages/navigation-test-page/1']); await routerPage.enterTarget('view.100'); await routerPage.enterCssClass('testee-navigation-1'); await routerPage.clickNavigate(); // Expect CSS classes of the navigation to be set. await expect.poll(() => viewPage.view.getCssClasses()).toContain('testee-navigation-1'); await expect.poll(() => viewPage.view.tab.getCssClasses()).toContain('testee-navigation-1'); + // Expect CSS classes of the layout to be set. + await expect.poll(() => viewPage.view.getCssClasses()).toContain('testee-layout'); + await expect.poll(() => viewPage.view.tab.getCssClasses()).toContain('testee-layout'); // Expect CSS classes of the route to be set. await expect.poll(() => viewPage.view.getCssClasses()).toContain('e2e-navigation-test-page'); await expect.poll(() => viewPage.view.tab.getCssClasses()).toContain('e2e-navigation-test-page'); // Navigate to 'test-pages/navigation-test-page/2' passing CSS class 'testee-navigation-2'. - await routerPage.enterPath('test-pages/navigation-test-page/2'); + await routerPage.enterCommands(['test-pages/navigation-test-page/2']); await routerPage.enterTarget('view.100'); await routerPage.enterCssClass('testee-navigation-2'); await routerPage.clickNavigate(); @@ -100,12 +103,15 @@ test.describe('Workbench View CSS Class', () => { // Expect CSS classes of the previous navigation not to be set. await expect.poll(() => viewPage.view.getCssClasses()).not.toContain('testee-navigation-1'); await expect.poll(() => viewPage.view.tab.getCssClasses()).not.toContain('testee-navigation-1'); + // Expect CSS classes of the layout to be set. + await expect.poll(() => viewPage.view.getCssClasses()).toContain('testee-layout'); + await expect.poll(() => viewPage.view.tab.getCssClasses()).toContain('testee-layout'); // Expect CSS classes of the route to be set. await expect.poll(() => viewPage.view.getCssClasses()).toContain('e2e-navigation-test-page'); await expect.poll(() => viewPage.view.tab.getCssClasses()).toContain('e2e-navigation-test-page'); // Navigate to 'test-pages/navigation-test-page/2' passing CSS class 'testee-navigation-3'. - await routerPage.enterPath('test-pages/navigation-test-page/2'); + await routerPage.enterCommands(['test-pages/navigation-test-page/2']); await routerPage.enterTarget('view.100'); await routerPage.enterCssClass('testee-navigation-3'); await routerPage.clickNavigate(); @@ -117,14 +123,16 @@ test.describe('Workbench View CSS Class', () => { await expect.poll(() => viewPage.view.getCssClasses()).not.toContain('testee-navigation-2'); await expect.poll(() => viewPage.view.tab.getCssClasses()).not.toContain('testee-navigation-1'); await expect.poll(() => viewPage.view.tab.getCssClasses()).not.toContain('testee-navigation-2'); + // Expect CSS classes of the layout to be set. + await expect.poll(() => viewPage.view.getCssClasses()).toContain('testee-layout'); + await expect.poll(() => viewPage.view.tab.getCssClasses()).toContain('testee-layout'); // Expect CSS classes of the route to be set. await expect.poll(() => viewPage.view.getCssClasses()).toContain('e2e-navigation-test-page'); await expect.poll(() => viewPage.view.tab.getCssClasses()).toContain('e2e-navigation-test-page'); // Navigate to 'test-pages/navigation-test-page/1' without passing CSS class. - await routerPage.enterPath('test-pages/navigation-test-page/1'); + await routerPage.enterCommands(['test-pages/navigation-test-page/1']); await routerPage.enterTarget('view.100'); - await routerPage.enterCssClass([]); await routerPage.clickNavigate(); // Expect CSS classes of the previous navigations not to be set. await expect.poll(() => viewPage.view.getCssClasses()).not.toContain('testee-navigation-1'); @@ -133,6 +141,80 @@ test.describe('Workbench View CSS Class', () => { await expect.poll(() => viewPage.view.tab.getCssClasses()).not.toContain('testee-navigation-1'); await expect.poll(() => viewPage.view.tab.getCssClasses()).not.toContain('testee-navigation-2'); await expect.poll(() => viewPage.view.tab.getCssClasses()).not.toContain('testee-navigation-3'); + // Expect CSS classes of the layout to be set. + await expect.poll(() => viewPage.view.getCssClasses()).toContain('testee-layout'); + await expect.poll(() => viewPage.view.tab.getCssClasses()).toContain('testee-layout'); + // Expect CSS classes of the route to be set. + await expect.poll(() => viewPage.view.getCssClasses()).toContain('e2e-navigation-test-page'); + await expect.poll(() => viewPage.view.tab.getCssClasses()).toContain('e2e-navigation-test-page'); + }); + + test('should associate CSS classes with a view (WorkbenchLayout.addView) and navigation (WorkbenchLayout.navigateView)', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + await workbenchNavigator.modifyLayout(layout => layout + .addPart('right', {align: 'right'}) + .addView('view.100', {partId: 'right', activateView: true, cssClass: 'testee-layout'}), + ); + + const viewPage = new ViewPagePO(appPO, {viewId: 'view.100'}); + + // Navigate to 'test-pages/navigation-test-page/1' passing CSS class 'testee-navigation-1'. + await workbenchNavigator.modifyLayout(layout => layout.navigateView('view.100', ['test-pages/navigation-test-page/1'], {cssClass: 'testee-navigation-1'})); + // Expect CSS classes of the navigation to be set. + await expect.poll(() => viewPage.view.getCssClasses()).toContain('testee-navigation-1'); + await expect.poll(() => viewPage.view.tab.getCssClasses()).toContain('testee-navigation-1'); + // Expect CSS class(es) of the view to be set. + await expect.poll(() => viewPage.view.getCssClasses()).toContain('testee-layout'); + await expect.poll(() => viewPage.view.tab.getCssClasses()).toContain('testee-layout'); + // Expect CSS class(es) of the route to be set. + await expect.poll(() => viewPage.view.getCssClasses()).toContain('e2e-navigation-test-page'); + await expect.poll(() => viewPage.view.tab.getCssClasses()).toContain('e2e-navigation-test-page'); + + // Navigate to 'test-pages/navigation-test-page/2' passing CSS class 'testee-navigation-2'. + await workbenchNavigator.modifyLayout(layout => layout.navigateView('view.100', ['test-pages/navigation-test-page/2'], {cssClass: 'testee-navigation-2'})); + // Expect CSS classes of the navigation to be set. + await expect.poll(() => viewPage.view.getCssClasses()).toContain('testee-navigation-2'); + await expect.poll(() => viewPage.view.tab.getCssClasses()).toContain('testee-navigation-2'); + // Expect CSS classes of the previous navigation not to be set. + await expect.poll(() => viewPage.view.getCssClasses()).not.toContain('testee-navigation-1'); + await expect.poll(() => viewPage.view.tab.getCssClasses()).not.toContain('testee-navigation-1'); + // Expect CSS classes of the layout to be set. + await expect.poll(() => viewPage.view.getCssClasses()).toContain('testee-layout'); + await expect.poll(() => viewPage.view.tab.getCssClasses()).toContain('testee-layout'); + // Expect CSS classes of the route to be set. + await expect.poll(() => viewPage.view.getCssClasses()).toContain('e2e-navigation-test-page'); + await expect.poll(() => viewPage.view.tab.getCssClasses()).toContain('e2e-navigation-test-page'); + + // Navigate to 'test-pages/navigation-test-page/2' passing CSS class 'testee-navigation-3'. + await workbenchNavigator.modifyLayout(layout => layout.navigateView('view.100', ['test-pages/navigation-test-page/2'], {cssClass: 'testee-navigation-3'})); + // Expect CSS classes of the navigation to be set. + await expect.poll(() => viewPage.view.getCssClasses()).toContain('testee-navigation-3'); + await expect.poll(() => viewPage.view.tab.getCssClasses()).toContain('testee-navigation-3'); + // Expect CSS classes of the previous navigations not to be set. + await expect.poll(() => viewPage.view.getCssClasses()).not.toContain('testee-navigation-1'); + await expect.poll(() => viewPage.view.getCssClasses()).not.toContain('testee-navigation-2'); + await expect.poll(() => viewPage.view.tab.getCssClasses()).not.toContain('testee-navigation-1'); + await expect.poll(() => viewPage.view.tab.getCssClasses()).not.toContain('testee-navigation-2'); + // Expect CSS classes of the layout to be set. + await expect.poll(() => viewPage.view.getCssClasses()).toContain('testee-layout'); + await expect.poll(() => viewPage.view.tab.getCssClasses()).toContain('testee-layout'); + // Expect CSS classes of the route to be set. + await expect.poll(() => viewPage.view.getCssClasses()).toContain('e2e-navigation-test-page'); + await expect.poll(() => viewPage.view.tab.getCssClasses()).toContain('e2e-navigation-test-page'); + + // Navigate to 'test-pages/navigation-test-page/1' without passing CSS class. + await workbenchNavigator.modifyLayout(layout => layout.navigateView('view.100', ['test-pages/navigation-test-page/1'])); + // Expect CSS classes of the previous navigations not to be set. + await expect.poll(() => viewPage.view.getCssClasses()).not.toContain('testee-navigation-1'); + await expect.poll(() => viewPage.view.getCssClasses()).not.toContain('testee-navigation-2'); + await expect.poll(() => viewPage.view.getCssClasses()).not.toContain('testee-navigation-3'); + await expect.poll(() => viewPage.view.tab.getCssClasses()).not.toContain('testee-navigation-1'); + await expect.poll(() => viewPage.view.tab.getCssClasses()).not.toContain('testee-navigation-2'); + await expect.poll(() => viewPage.view.tab.getCssClasses()).not.toContain('testee-navigation-3'); + // Expect CSS classes of the layout to be set. + await expect.poll(() => viewPage.view.getCssClasses()).toContain('testee-layout'); + await expect.poll(() => viewPage.view.tab.getCssClasses()).toContain('testee-layout'); // Expect CSS classes of the route to be set. await expect.poll(() => viewPage.view.getCssClasses()).toContain('e2e-navigation-test-page'); await expect.poll(() => viewPage.view.tab.getCssClasses()).toContain('e2e-navigation-test-page'); @@ -142,7 +224,7 @@ test.describe('Workbench View CSS Class', () => { await appPO.navigateTo({microfrontendSupport: false}); const routerPage = await workbenchNavigator.openInNewTab(RouterPagePO); - await routerPage.enterPath('test-view'); + await routerPage.enterCommands(['test-view']); await routerPage.enterTarget('view.100'); await routerPage.enterCssClass('testee-navigation'); await routerPage.clickNavigate(); @@ -155,6 +237,83 @@ test.describe('Workbench View CSS Class', () => { await expect.poll(() => viewPage.view.tab.getCssClasses()).toContain('testee-navigation'); }); + test('should retain navigational CSS classes when moving view to new window', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + const routerPage = await workbenchNavigator.openInNewTab(RouterPagePO); + await routerPage.enterCommands(['test-view']); + await routerPage.enterTarget('view.100'); + await routerPage.enterCssClass('testee-navigation'); + await routerPage.clickNavigate(); + + const viewPage = new ViewPagePO(appPO, {viewId: 'view.100'}); + + // Move view to new window. + const newAppPO = await viewPage.view.tab.moveToNewWindow(); + const newViewPage = new ViewPagePO(newAppPO, {viewId: 'view.1'}); + + // Expect CSS classes of the navigation to be retained + await expect.poll(() => newViewPage.view.getCssClasses()).toContain('testee-navigation'); + await expect.poll(() => newViewPage.view.tab.getCssClasses()).toContain('testee-navigation'); + }); + + test('should retain navigational CSS classes when moving view to other window', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + // Open view 1 + const routerPage = await workbenchNavigator.openInNewTab(RouterPagePO); + await routerPage.enterCommands(['test-view']); + await routerPage.enterTarget('view.101'); + await routerPage.enterCssClass('testee-navigation-1'); + await routerPage.checkActivate(false); + await routerPage.clickNavigate(); + + // Open view 2 + await routerPage.enterCommands(['test-view']); + await routerPage.enterTarget('view.102'); + await routerPage.enterCssClass('testee-navigation-2'); + await routerPage.checkActivate(false); + await routerPage.clickNavigate(); + + const viewPage1 = new ViewPagePO(appPO, {viewId: 'view.101'}); + const viewPage2 = new ViewPagePO(appPO, {viewId: 'view.102'}); + + // Move view 1 to new window. + const newAppPO = await viewPage1.view.tab.moveToNewWindow(); + const newViewPage1 = new ViewPagePO(newAppPO, {viewId: 'view.1'}); + + // Move view 2 to the window. + await viewPage2.view.tab.moveTo(await newViewPage1.view.part.getPartId(), {workbenchId: await newAppPO.getWorkbenchId()}); + const newViewPage2 = new ViewPagePO(newAppPO, {viewId: 'view.2'}); + + // Expect CSS classes of the navigation to be retained + await expect.poll(() => newViewPage2.view.getCssClasses()).toContain('testee-navigation-2'); + await expect.poll(() => newViewPage2.view.tab.getCssClasses()).toContain('testee-navigation-2'); + }); + + test('should retain navigational CSS classes when reloading the application', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + const routerPage = await workbenchNavigator.openInNewTab(RouterPagePO); + await routerPage.enterCommands(['test-view']); + await routerPage.enterTarget('view.100'); + await routerPage.enterCssClass('testee-navigation'); + await routerPage.clickNavigate(); + + const viewPage = new ViewPagePO(appPO, {viewId: 'view.100'}); + + // Expect CSS classes of the navigation to be set. + await expect.poll(() => viewPage.view.getCssClasses()).toContain('testee-navigation'); + await expect.poll(() => viewPage.view.tab.getCssClasses()).toContain('testee-navigation'); + + // Reload the application. + await appPO.reload(); + + // Expect CSS classes of the navigation to be set. + await expect.poll(() => viewPage.view.getCssClasses()).toContain('testee-navigation'); + await expect.poll(() => viewPage.view.tab.getCssClasses()).toContain('testee-navigation'); + }); + test('should retain navigational CSS classes when switching view tabs', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); @@ -198,4 +357,16 @@ test.describe('Workbench View CSS Class', () => { const viewPage = new ViewPagePO(appPO, {viewId: 'view.100'}); await expect.poll(() => viewPage.view.tab.getCssClasses()).toContain('testee'); }); + + test('should add CSS classes to inactive view (WorkbenchLayout.navigateView)', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + await workbenchNavigator.modifyLayout((layout, activePartId) => layout + .addView('view.100', {partId: activePartId, activateView: false}) + .navigateView('view.100', ['test-view'], {cssClass: 'testee'}), + ); + + const viewPage = new ViewPagePO(appPO, {viewId: 'view.100'}); + await expect.poll(() => viewPage.view.tab.getCssClasses()).toContain('testee'); + }); }); diff --git a/projects/scion/e2e-testing/src/workbench/view-drag-main-area-grid.e2e-spec.ts b/projects/scion/e2e-testing/src/workbench/view-drag-main-area-grid.e2e-spec.ts index b78517160..21b55936a 100644 --- a/projects/scion/e2e-testing/src/workbench/view-drag-main-area-grid.e2e-spec.ts +++ b/projects/scion/e2e-testing/src/workbench/view-drag-main-area-grid.e2e-spec.ts @@ -11,7 +11,6 @@ import {expect} from '@playwright/test'; import {test} from '../fixtures'; import {ViewPagePO} from './page-object/view-page.po'; -import {LayoutPagePO} from './page-object/layout-page.po'; import {fromRect} from '../helper/testing.util'; import {MAIN_AREA} from '../workbench.model'; import {MPart, MTreeNode} from '../matcher/to-equal-workbench-layout.matcher'; @@ -21,26 +20,31 @@ test.describe('View Drag Main Area', () => { test.describe('should allow dragging a view to the side of the main area', () => { /** - * +-----------------+ +----------+-----------------+ - * | INITIAL | | | INITIAL | - * | [view.1,view.2] | | WEST | [view.1] | - * +-----------------+ => | [view.2] +-----------------+ - * | BOTTOM | | | BOTTOM | - * | [view.3] | | | [view.3] | - * +-----------------+ +----------+-----------------+ + * +----------------------+ +-------------+-------------------+ + * | INITIAL | | | INITIAL | + * | [test-view,view.101] | | WEST | [view.101] | + * +----------------------+ => | [test-view] +-------------------+ + * | BOTTOM | | | BOTTOM | + * | [view.102] | | | [view.102] | + * +----------------------+ +-------------+-------------------+ */ test('should allow dragging a view to the west in the main area', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - await layoutPage.addPart('bottom', {relativeTo: await layoutPage.view.part.getPartId(), align: 'bottom', ratio: .25}); - await layoutPage.addView('view.3', {partId: 'bottom', activateView: true}); + const testView = await workbenchNavigator.openInNewTab(ViewPagePO); + const initialPartId = await testView.view.part.getPartId(); - // Move view 2 to the west of the main area. - const view2 = (await workbenchNavigator.openInNewTab(ViewPagePO)).view; - await view2.tab.dragTo({grid: 'mainArea', region: 'west'}); + await workbenchNavigator.modifyLayout(layout => layout + .addPart('bottom', {relativeTo: initialPartId, align: 'bottom', ratio: .25}) + .addView('view.101', {partId: initialPartId}) + .addView('view.102', {partId: 'bottom', activateView: true}), + ); - // Expect view 2 to be moved to the west of the main area. + // Move test view to the west of the main area. + await testView.view.tab.dragTo({grid: 'mainArea', region: 'west'}); + const testViewInfo = await testView.view.getInfo(); + + // Expect test view to be moved to the west of the main area. await expect(appPO.workbench).toEqualWorkbenchLayout({ workbenchGrid: { root: new MPart({ @@ -52,51 +56,56 @@ test.describe('View Drag Main Area', () => { direction: 'row', ratio: .2, child1: new MPart({ - id: await view2.part.getPartId(), - views: [{id: 'view.2'}], - activeViewId: 'view.2', + id: testViewInfo.partId, + views: [{id: testViewInfo.viewId}], + activeViewId: testViewInfo.viewId, }), child2: new MTreeNode({ direction: 'column', ratio: .75, child1: new MPart({ - id: await layoutPage.view.part.getPartId(), - views: [{id: 'view.1'}], - activeViewId: 'view.1', + id: initialPartId, + views: [{id: 'view.101'}], + activeViewId: 'view.101', }), child2: new MPart({ - id: await appPO.view({viewId: 'view.3'}).part.getPartId(), - views: [{id: 'view.3'}], - activeViewId: 'view.3', + id: 'bottom', + views: [{id: 'view.102'}], + activeViewId: 'view.102', }), }), }), - activePartId: await view2.part.getPartId(), + activePartId: testViewInfo.partId, }, }); }); /** - * +-----------------+ +-----------------+----------+ - * | INITIAL | | INITIAL | | - * | [view.1,view.2] | | [view.1] | EAST | - * +-----------------| => +-----------------+ [view.2] | - * | BOTTOM | | BOTTOM | | - * | [view.3] | | [view.3] | | - * +-----------------+ +-----------------+----------+ + * +-------------------+ +-------------------+----------+ + * | INITIAL | | INITIAL | | + * | [view.1,view.101] | | [view.101] | EAST | + * +-------------------| => +-------------------+ [view.1] | + * | BOTTOM | | BOTTOM | | + * | [view.102] | | [view.102] | | + * +-------------------+ +-------------------+----------+ */ test('should allow dragging a view to the east in the main area', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - await layoutPage.addPart('bottom', {relativeTo: await layoutPage.view.part.getPartId(), align: 'bottom', ratio: .25}); - await layoutPage.addView('view.3', {partId: 'bottom', activateView: true}); + const testView = await workbenchNavigator.openInNewTab(ViewPagePO); + const initialPartId = await testView.view.part.getPartId(); + + await workbenchNavigator.modifyLayout(layout => layout + .addPart('bottom', {relativeTo: initialPartId, align: 'bottom', ratio: .25}) + .addView('view.101', {partId: initialPartId}) + .addView('view.102', {partId: 'bottom', activateView: true}), + ); - // Move view 2 to the east of the main area. - const view2 = (await workbenchNavigator.openInNewTab(ViewPagePO)).view; - await view2.tab.dragTo({grid: 'mainArea', region: 'east'}); + // Move test view to the east of the main area. + await testView.view.tab.dragTo({grid: 'mainArea', region: 'east'}); + const testViewInfo = await testView.view.getInfo(); - // Expect view 2 to be moved to the east of the main area. + // Expect test view to be moved to the east of the main area. await expect(appPO.workbench).toEqualWorkbenchLayout({ workbenchGrid: { root: new MPart({ @@ -111,48 +120,53 @@ test.describe('View Drag Main Area', () => { direction: 'column', ratio: .75, child1: new MPart({ - id: await layoutPage.view.part.getPartId(), - views: [{id: 'view.1'}], - activeViewId: 'view.1', + id: initialPartId, + views: [{id: 'view.101'}], + activeViewId: 'view.101', }), child2: new MPart({ - id: await appPO.view({viewId: 'view.3'}).part.getPartId(), - views: [{id: 'view.3'}], - activeViewId: 'view.3', + id: 'bottom', + views: [{id: 'view.102'}], + activeViewId: 'view.102', }), }), child2: new MPart({ - id: await view2.part.getPartId(), - views: [{id: 'view.2'}], - activeViewId: 'view.2', + id: testViewInfo.partId, + views: [{id: testViewInfo.viewId}], + activeViewId: testViewInfo.viewId, }), }), - activePartId: await view2.part.getPartId(), + activePartId: testViewInfo.partId, }, }); }); /** - * +-----------------+----------+ +-------------+----------+ - * | | | | INITIAL | RIGHT | - * | INITIAL | RIGHT | | [view.1] | [view.3] | - * | [view.1,view.2] | [view.3] | => +-------------+----------+ - * | | | | SOUTH | - * | | | | [view.2] | - * +-----------------+----------+ +------------------------+ + * +-------------------+------------+ +-------------+------------+ + * | | | | INITIAL | RIGHT | + * | INITIAL | RIGHT | | [view.101] | [view.102] | + * | [view.1,view.101] | [view.102] | => +-------------+------------+ + * | | | | SOUTH | + * | | | | [view.1] | + * +-------------------+------------+ +--------------------------+ */ test('should allow dragging a view to the south in the main area', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - await layoutPage.addPart('right', {relativeTo: await layoutPage.view.part.getPartId(), align: 'right', ratio: .25}); - await layoutPage.addView('view.3', {partId: 'right', activateView: true}); + const testView = await workbenchNavigator.openInNewTab(ViewPagePO); + const initialPartId = await testView.view.part.getPartId(); - // Move view 2 to the south of the main area. - const view2 = (await workbenchNavigator.openInNewTab(ViewPagePO)).view; - await view2.tab.dragTo({grid: 'mainArea', region: 'south'}); + await workbenchNavigator.modifyLayout(layout => layout + .addPart('right', {relativeTo: initialPartId, align: 'right', ratio: .25}) + .addView('view.101', {partId: initialPartId}) + .addView('view.102', {partId: 'right', activateView: true}), + ); - // Expect view 2 to be moved to the south of the main area. + // Move test view to the south of the main area. + await testView.view.tab.dragTo({grid: 'mainArea', region: 'south'}); + const testViewInfo = await testView.view.getInfo(); + + // Expect test view to be moved to the south of the main area. await expect(appPO.workbench).toEqualWorkbenchLayout({ workbenchGrid: { root: new MPart({ @@ -167,44 +181,48 @@ test.describe('View Drag Main Area', () => { direction: 'row', ratio: .75, child1: new MPart({ - id: await layoutPage.view.part.getPartId(), - views: [{id: 'view.1'}], - activeViewId: 'view.1', + id: initialPartId, + views: [{id: 'view.101'}], + activeViewId: 'view.101', }), child2: new MPart({ - id: await appPO.view({viewId: 'view.3'}).part.getPartId(), - views: [{id: 'view.3'}], - activeViewId: 'view.3', + id: 'right', + views: [{id: 'view.102'}], + activeViewId: 'view.102', }), }), child2: new MPart({ - id: await view2.part.getPartId(), - views: [{id: 'view.2'}], - activeViewId: 'view.2', + id: testViewInfo.partId, + views: [{id: testViewInfo.viewId}], + activeViewId: testViewInfo.viewId, }), }), - activePartId: await view2.part.getPartId(), + activePartId: testViewInfo.partId, }, }); }); /** - * +-----------------+----------+ - * | | | - * | INITIAL | RIGHT | - * | [view.1,view.2] | [view.3] | - * | | | - * +-----------------+----------+ + * +-------------------+------------+ + * | | | + * | INITIAL | RIGHT | + * | [view.1,view.101] | [view.102] | + * | | | + * +-------------------+------------+ */ test('should NOT allow dragging a view to the north or a fully adjacent side of the main area (1)', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - await layoutPage.addPart('right', {relativeTo: await layoutPage.view.part.getPartId(), align: 'right'}); - await layoutPage.addView('view.3', {partId: 'right', activateView: true}); + const testView = await workbenchNavigator.openInNewTab(ViewPagePO); + const initialPartId = await testView.view.part.getPartId(); + + await workbenchNavigator.modifyLayout(layout => layout + .addPart('right', {relativeTo: initialPartId, align: 'right'}) + .addView('view.101', {partId: initialPartId}) + .addView('view.102', {partId: 'right', activateView: true}), + ); - const view2 = (await workbenchNavigator.openInNewTab(ViewPagePO)).view; - await view2.tab.activateDropZones({grid: 'mainArea'}); + await testView.view.tab.activateDropZones({grid: 'mainArea'}); await expect.poll(() => appPO.isDropZoneActive({grid: 'mainArea', region: 'north'})).toBe(false); await expect.poll(() => appPO.isDropZoneActive({grid: 'mainArea', region: 'south'})).toBe(true); @@ -213,23 +231,27 @@ test.describe('View Drag Main Area', () => { }); /** - * +-----------------+ - * | INITIAL | - * | [view.1,view.2] | - * +-----------------| - * | BOTTOM | - * | [view.3] | - * +-----------------+ + * +-------------------+ + * | INITIAL | + * | [view.1,view.101] | + * +-------------------| + * | BOTTOM | + * | [view.102] | + * +-------------------+ */ test('should NOT allow dragging a view to the north or a fully adjacent side of the main area (2)', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - await layoutPage.addPart('bottom', {relativeTo: await layoutPage.view.part.getPartId(), align: 'bottom'}); - await layoutPage.addView('view.3', {partId: 'bottom', activateView: true}); + const testView = await workbenchNavigator.openInNewTab(ViewPagePO); + const initialPartId = await testView.view.part.getPartId(); - const view2 = (await workbenchNavigator.openInNewTab(ViewPagePO)).view; - await view2.tab.activateDropZones({grid: 'mainArea'}); + await workbenchNavigator.modifyLayout(layout => layout + .addPart('bottom', {relativeTo: initialPartId, align: 'bottom'}) + .addView('view.101', {partId: initialPartId}) + .addView('view.102', {partId: 'bottom', activateView: true}), + ); + + await testView.view.tab.activateDropZones({grid: 'mainArea'}); await expect.poll(() => appPO.isDropZoneActive({grid: 'mainArea', region: 'north'})).toBe(false); await expect.poll(() => appPO.isDropZoneActive({grid: 'mainArea', region: 'south'})).toBe(false); @@ -238,25 +260,29 @@ test.describe('View Drag Main Area', () => { }); /** - * +-----------------+----------+ - * | INITIAL | | - * | [view.1,view.2] | RIGHT | - * +-----------------| [view.4] | - * | BOTTOM-LEFT | | - * | [view.3] | | - * +-----------------+----------+ + * +-------------------+------------+ + * | INITIAL | | + * | [view.1,view.101] | RIGHT | + * +-------------------| [view.103] | + * | BOTTOM-LEFT | | + * | [view.102] | | + * +-------------------+------------+ */ test('should NOT allow dragging a view to the north or a fully adjacent side of the main area (3)', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - await layoutPage.addPart('right', {relativeTo: await layoutPage.view.part.getPartId(), align: 'right'}); - await layoutPage.addPart('bottom-left', {relativeTo: await layoutPage.view.part.getPartId(), align: 'bottom'}); - await layoutPage.addView('view.3', {partId: 'bottom-left', activateView: true}); - await layoutPage.addView('view.4', {partId: 'right', activateView: true}); + const testView = await workbenchNavigator.openInNewTab(ViewPagePO); + const initialPartId = await testView.view.part.getPartId(); + + await workbenchNavigator.modifyLayout(layout => layout + .addPart('right', {relativeTo: initialPartId, align: 'right'}) + .addPart('bottom-left', {relativeTo: initialPartId, align: 'bottom'}) + .addView('view.101', {partId: initialPartId}) + .addView('view.102', {partId: 'bottom-left', activateView: true}) + .addView('view.103', {partId: 'right', activateView: true}), + ); - const view2 = (await workbenchNavigator.openInNewTab(ViewPagePO)).view; - await view2.tab.activateDropZones({grid: 'mainArea'}); + await testView.view.tab.activateDropZones({grid: 'mainArea'}); await expect.poll(() => appPO.isDropZoneActive({grid: 'mainArea', region: 'north'})).toBe(false); await expect.poll(() => appPO.isDropZoneActive({grid: 'mainArea', region: 'south'})).toBe(true); @@ -265,25 +291,29 @@ test.describe('View Drag Main Area', () => { }); /** - * +----------+-----------------+ - * | | INITIAL | - * | LEFT | [view.1,view.2] | - * | [view.4] +-----------------| - * | | BOTTOM-RIGHT | - * | | [view.3] | - * +----------+-----------------+ + * +------------+-------------------+ + * | | INITIAL | + * | LEFT | [view.1,view.101] | + * | [view.103] +-------------------| + * | | BOTTOM-RIGHT | + * | | [view.102] | + * +------------+-------------------+ */ test('should NOT allow dragging a view to the north or a fully adjacent side of the main area (4)', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - await layoutPage.addPart('left', {relativeTo: await layoutPage.view.part.getPartId(), align: 'left'}); - await layoutPage.addPart('bottom-right', {relativeTo: await layoutPage.view.part.getPartId(), align: 'bottom'}); - await layoutPage.addView('view.3', {partId: 'bottom-right', activateView: true}); - await layoutPage.addView('view.4', {partId: 'left', activateView: true}); + const testView = await workbenchNavigator.openInNewTab(ViewPagePO); + const initialPartId = await testView.view.part.getPartId(); + + await workbenchNavigator.modifyLayout(layout => layout + .addPart('left', {relativeTo: initialPartId, align: 'left'}) + .addPart('bottom-right', {relativeTo: initialPartId, align: 'bottom'}) + .addView('view.101', {partId: initialPartId}) + .addView('view.102', {partId: 'bottom-right', activateView: true}) + .addView('view.103', {partId: 'left', activateView: true}), + ); - const view2 = (await workbenchNavigator.openInNewTab(ViewPagePO)).view; - await view2.tab.activateDropZones({grid: 'mainArea'}); + await testView.view.tab.activateDropZones({grid: 'mainArea'}); await expect.poll(() => appPO.isDropZoneActive({grid: 'mainArea', region: 'north'})).toBe(false); await expect.poll(() => appPO.isDropZoneActive({grid: 'mainArea', region: 'south'})).toBe(true); @@ -292,30 +322,34 @@ test.describe('View Drag Main Area', () => { }); /** - * +----------+-----------------+ - * | | INITIAL | - * | LEFT | [view.1,view.2] | - * | [view.4] +-----------------| - * | | BOTTOM-RIGHT | - * | | [view.3] | - * +----------+-----------------+ - * | BOTTOM | - * | [view.5] | - * +----------------------------+ + * +------------+-------------------+ + * | | INITIAL | + * | LEFT | [view.1,view.101] | + * | [view.103] +-------------------| + * | | BOTTOM-RIGHT | + * | | [view.102] | + * +------------+-------------------+ + * | BOTTOM | + * | [view.104] | + * +--------------------------------+ */ test('should NOT allow dragging a view to the north or a fully adjacent side of the main area (5)', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - await layoutPage.addPart('bottom', {relativeTo: await layoutPage.view.part.getPartId(), align: 'bottom'}); - await layoutPage.addPart('left', {relativeTo: await layoutPage.view.part.getPartId(), align: 'left'}); - await layoutPage.addPart('bottom-right', {relativeTo: await layoutPage.view.part.getPartId(), align: 'bottom'}); - await layoutPage.addView('view.3', {partId: 'bottom-right', activateView: true}); - await layoutPage.addView('view.4', {partId: 'left', activateView: true}); - await layoutPage.addView('view.5', {partId: 'bottom', activateView: true}); + const testView = await workbenchNavigator.openInNewTab(ViewPagePO); + const initialPartId = await testView.view.part.getPartId(); - const view2 = (await workbenchNavigator.openInNewTab(ViewPagePO)).view; - await view2.tab.activateDropZones({grid: 'mainArea'}); + await workbenchNavigator.modifyLayout(layout => layout + .addPart('bottom', {relativeTo: initialPartId, align: 'bottom'}) + .addPart('left', {relativeTo: initialPartId, align: 'left'}) + .addPart('bottom-right', {relativeTo: initialPartId, align: 'bottom'}) + .addView('view.101', {partId: initialPartId}) + .addView('view.102', {partId: 'bottom-right', activateView: true}) + .addView('view.103', {partId: 'left', activateView: true}) + .addView('view.104', {partId: 'bottom', activateView: true}), + ); + + await testView.view.tab.activateDropZones({grid: 'mainArea'}); await expect.poll(() => appPO.isDropZoneActive({grid: 'mainArea', region: 'north'})).toBe(false); await expect.poll(() => appPO.isDropZoneActive({grid: 'mainArea', region: 'south'})).toBe(false); @@ -324,32 +358,36 @@ test.describe('View Drag Main Area', () => { }); /** - * +----------+-----------------+----------+ - * | | INITIAL | | - * | LEFT | [view.1,view.2] | | - * | [view.4] +-----------------| | - * | | MIDDLE | RIGHT | - * | | [view.3] | [view.6] | - * +----------+-----------------+ | - * | BOTTOM | | - * | [view.5] | | - * +----------------------------+----------+ + * +------------+-------------------+------------+ + * | | INITIAL | | + * | LEFT | [view.1,view.101] | | + * | [view.103] +-------------------| | + * | | MIDDLE | RIGHT | + * | | [view.102] | [view.105] | + * +------------+-------------------+ | + * | BOTTOM | | + * | [view.104] | | + * +--------------------------------+------------+ */ test('should NOT allow dragging a view to the north or a fully adjacent side of the main area (6)', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - await layoutPage.addPart('right', {relativeTo: await layoutPage.view.part.getPartId(), align: 'right'}); - await layoutPage.addPart('bottom', {relativeTo: await layoutPage.view.part.getPartId(), align: 'bottom'}); - await layoutPage.addPart('left', {relativeTo: await layoutPage.view.part.getPartId(), align: 'left'}); - await layoutPage.addPart('middle', {relativeTo: await layoutPage.view.part.getPartId(), align: 'bottom'}); - await layoutPage.addView('view.3', {partId: 'middle', activateView: true}); - await layoutPage.addView('view.4', {partId: 'left', activateView: true}); - await layoutPage.addView('view.5', {partId: 'bottom', activateView: true}); - await layoutPage.addView('view.6', {partId: 'right', activateView: true}); + const testView = await workbenchNavigator.openInNewTab(ViewPagePO); + const initialPartId = await testView.view.part.getPartId(); + + await workbenchNavigator.modifyLayout(layout => layout + .addPart('right', {relativeTo: initialPartId, align: 'right'}) + .addPart('bottom', {relativeTo: initialPartId, align: 'bottom'}) + .addPart('left', {relativeTo: initialPartId, align: 'left'}) + .addPart('middle', {relativeTo: initialPartId, align: 'bottom'}) + .addView('view.101', {partId: initialPartId}) + .addView('view.102', {partId: 'middle', activateView: true}) + .addView('view.103', {partId: 'left', activateView: true}) + .addView('view.104', {partId: 'bottom', activateView: true}) + .addView('view.105', {partId: 'right', activateView: true}), + ); - const view2 = (await workbenchNavigator.openInNewTab(ViewPagePO)).view; - await view2.tab.activateDropZones({grid: 'mainArea'}); + await testView.view.tab.activateDropZones({grid: 'mainArea'}); await expect.poll(() => appPO.isDropZoneActive({grid: 'mainArea', region: 'north'})).toBe(false); await expect.poll(() => appPO.isDropZoneActive({grid: 'mainArea', region: 'south'})).toBe(true); @@ -358,25 +396,29 @@ test.describe('View Drag Main Area', () => { }); /** - * +------------+-----------------+----------+ - * | | INITIAL | | - * | LEFT | [view.1,view.2] | RIGHT | - * | +-----------------|| - * | | BOTTOM-MIDDLE | | - * | | [view.3] | | - * +------------+-----------------+----------+ + * +------------+-------------------+----------+ + * | | INITIAL | | + * | LEFT | [view.1,view.101] | RIGHT | + * | +-------------------|| + * | | BOTTOM-MIDDLE | | + * | | [view.102] | | + * +------------+-------------------+----------+ */ test('should NOT allow dragging a view to the north or a fully adjacent side of the main area (7)', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - await layoutPage.addPart('left', {relativeTo: await layoutPage.view.part.getPartId(), align: 'left'}); - await layoutPage.addPart('right', {relativeTo: await layoutPage.view.part.getPartId(), align: 'right'}); - await layoutPage.addPart('bottom-middle', {relativeTo: await layoutPage.view.part.getPartId(), align: 'bottom'}); - await layoutPage.addView('view.3', {partId: 'bottom-middle', activateView: true}); + const testView = await workbenchNavigator.openInNewTab(ViewPagePO); + const initialPartId = await testView.view.part.getPartId(); - const view2 = (await workbenchNavigator.openInNewTab(ViewPagePO)).view; - await view2.tab.activateDropZones({grid: 'mainArea'}); + await workbenchNavigator.modifyLayout(layout => layout + .addPart('left', {relativeTo: initialPartId, align: 'left'}) + .addPart('right', {relativeTo: initialPartId, align: 'right'}) + .addPart('bottom-middle', {relativeTo: initialPartId, align: 'bottom'}) + .addView('view.101', {partId: initialPartId}) + .addView('view.102', {partId: 'bottom-middle', activateView: true}), + ); + + await testView.view.tab.activateDropZones({grid: 'mainArea'}); await expect.poll(() => appPO.isDropZoneActive({grid: 'mainArea', region: 'north'})).toBe(false); await expect.poll(() => appPO.isDropZoneActive({grid: 'mainArea', region: 'south'})).toBe(false); @@ -385,26 +427,30 @@ test.describe('View Drag Main Area', () => { }); /** - * +-------------+-----------------+--------------+ - * | LEFT-TOP | | RIGHT-TOP | - * | | INITIAL | | - * +-------------+ [view.1,view.2] +--------------+ - * | LEFT-BOTTOM | | RIGHT-BOTTOM | - * | | | [view.3] | - * +-------------+-----------------+--------------+ + * +-------------+-------------------+--------------+ + * | LEFT-TOP | | RIGHT-TOP | + * | | INITIAL | | + * +-------------+ [view.1,view.101] +--------------+ + * | LEFT-BOTTOM | | RIGHT-BOTTOM | + * | | | [view.102] | + * +-------------+-------------------+--------------+ */ test('should NOT allow dragging a view to the north or a fully adjacent side of the main area (8)', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - await layoutPage.addPart('left-top', {relativeTo: await layoutPage.view.part.getPartId(), align: 'left'}); - await layoutPage.addPart('right-top', {relativeTo: await layoutPage.view.part.getPartId(), align: 'right'}); - await layoutPage.addPart('left-bottom', {relativeTo: 'left-top', align: 'bottom'}); - await layoutPage.addPart('right-bottom', {relativeTo: 'right-top', align: 'bottom'}); - await layoutPage.addView('view.3', {partId: 'right-bottom', activateView: true}); + const testView = await workbenchNavigator.openInNewTab(ViewPagePO); + const initialPartId = await testView.view.part.getPartId(); + + await workbenchNavigator.modifyLayout(layout => layout + .addPart('left-top', {relativeTo: initialPartId, align: 'left'}) + .addPart('right-top', {relativeTo: initialPartId, align: 'right'}) + .addPart('left-bottom', {relativeTo: 'left-top', align: 'bottom'}) + .addPart('right-bottom', {relativeTo: 'right-top', align: 'bottom'}) + .addView('view.101', {partId: initialPartId}) + .addView('view.102', {partId: 'right-bottom', activateView: true}), + ); - const view2 = (await workbenchNavigator.openInNewTab(ViewPagePO)).view; - await view2.tab.activateDropZones({grid: 'mainArea'}); + await testView.view.tab.activateDropZones({grid: 'mainArea'}); await expect.poll(() => appPO.isDropZoneActive({grid: 'mainArea', region: 'north'})).toBe(false); await expect.poll(() => appPO.isDropZoneActive({grid: 'mainArea', region: 'south'})).toBe(true); @@ -415,13 +461,16 @@ test.describe('View Drag Main Area', () => { test('should NOT allow dragging a view to the north or a fully adjacent side of the main area (9)', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - await layoutPage.addPart('bottom', {relativeTo: await layoutPage.view.part.getPartId(), align: 'bottom'}); - await layoutPage.addPart('right', {relativeTo: await layoutPage.view.part.getPartId(), align: 'right'}); - await layoutPage.addView('view.3', {partId: 'right', activateView: true}); + const testView = await workbenchNavigator.openInNewTab(ViewPagePO); + const initialPartId = await testView.view.part.getPartId(); - const view2 = (await workbenchNavigator.openInNewTab(ViewPagePO)).view; - await view2.tab.activateDropZones({grid: 'mainArea'}); + await workbenchNavigator.modifyLayout(layout => layout + .addPart('bottom', {relativeTo: initialPartId, align: 'bottom'}) + .addPart('right', {relativeTo: initialPartId, align: 'right'}) + .addView('view.101', {partId: 'right', activateView: true}), + ); + + await testView.view.tab.activateDropZones({grid: 'mainArea'}); await expect.poll(() => appPO.isDropZoneActive({grid: 'mainArea', region: 'north'})).toBe(false); await expect.poll(() => appPO.isDropZoneActive({grid: 'mainArea', region: 'south'})).toBe(true); @@ -430,20 +479,25 @@ test.describe('View Drag Main Area', () => { }); /** - * +------------------+ - * | INITIAL | - * | [view.1, view.2] | - * +------------------+ - * | BOTTOM | - * | [view.3] | - * +------------------+ + * +--------------------+ + * | INITIAL | + * | [view.1, view.101] | + * +--------------------+ + * | BOTTOM | + * | [view.102] | + * +--------------------+ */ test('should disable drop zone when dragging a view into the tabbar', async ({page, appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - await layoutPage.addPart('bottom', {relativeTo: await layoutPage.view.part.getPartId(), align: 'bottom', ratio: .25}); - await layoutPage.addView('view.3', {partId: 'bottom', activateView: true}); + const testView = await workbenchNavigator.openInNewTab(ViewPagePO); + const initialPartId = await testView.view.part.getPartId(); + + await workbenchNavigator.modifyLayout(layout => layout + .addPart('bottom', {relativeTo: initialPartId, align: 'bottom', ratio: .25}) + .addView('view.101', {partId: initialPartId}) + .addView('view.102', {partId: 'bottom', activateView: true}), + ); const workbenchGridDropZoneSize = 50; const mainAreaGridDropZoneSize = 100; @@ -468,20 +522,25 @@ test.describe('View Drag Main Area', () => { }); /** - * +------------------+ - * | INITIAL | - * | [view.1, view.2] | - * +------------------+ - * | BOTTOM | - * | [view.3] | - * +------------------+ + * +--------------------+ + * | INITIAL | + * | [view.1, view.101] | + * +--------------------+ + * | BOTTOM | + * | [view.102] | + * +--------------------+ */ test('should not disable drop zone when entering tabbar while dragging a view over the drop zone', async ({page, appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - await layoutPage.addPart('bottom', {relativeTo: await layoutPage.view.part.getPartId(), align: 'bottom', ratio: .25}); - await layoutPage.addView('view.3', {partId: 'bottom', activateView: true}); + const testView = await workbenchNavigator.openInNewTab(ViewPagePO); + const initialPartId = await testView.view.part.getPartId(); + + await workbenchNavigator.modifyLayout(layout => layout + .addPart('bottom', {relativeTo: initialPartId, align: 'bottom', ratio: .25}) + .addView('view.101', {partId: initialPartId}) + .addView('view.102', {partId: 'bottom', activateView: true}), + ); // Get bounding box of the tabbar of the 'bottom' part. const bottomPart = appPO.part({partId: 'bottom'}); diff --git a/projects/scion/e2e-testing/src/workbench/view-drag-workbench-grid.e2e-spec.ts b/projects/scion/e2e-testing/src/workbench/view-drag-workbench-grid.e2e-spec.ts index 0aa8e75c7..fa5dda26b 100644 --- a/projects/scion/e2e-testing/src/workbench/view-drag-workbench-grid.e2e-spec.ts +++ b/projects/scion/e2e-testing/src/workbench/view-drag-workbench-grid.e2e-spec.ts @@ -11,7 +11,6 @@ import {expect} from '@playwright/test'; import {test} from '../fixtures'; import {ViewPagePO} from './page-object/view-page.po'; -import {LayoutPagePO} from './page-object/layout-page.po'; import {fromRect} from '../helper/testing.util'; import {MAIN_AREA} from '../workbench.model'; import {MPart, MTreeNode} from '../matcher/to-equal-workbench-layout.matcher'; @@ -21,77 +20,87 @@ test.describe('View Drag Workbench Grid', () => { test.describe('should allow dragging a view to the side of the workbench grid', () => { /** - * +------------------+ +----------+-----------+ - * | MAIN-AREA | => | WEST | MAIN-AREA | - * | [view.1, view.2] | | [view.2] | [view.1] | - * +------------------+ +----------+-----------+ + * +--------------------+ +----------+------------+ + * | MAIN-AREA | => | WEST | MAIN-AREA | + * | [view.1, view.101] | | [view.1] | [view.101] | + * +--------------------+ +----------+------------+ */ test('should allow dragging a view to the west in the workbench grid (1)', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - const view2 = (await workbenchNavigator.openInNewTab(ViewPagePO)).view; + const testView = await workbenchNavigator.openInNewTab(ViewPagePO); + const initialPartId = await testView.view.part.getPartId(); - // Move view 2 to the west of the workbench grid. - await view2.tab.dragTo({grid: 'workbench', region: 'west'}); + await workbenchNavigator.modifyLayout(layout => layout + .addView('view.101', {partId: initialPartId}), + ); - // Expect view 2 to be moved to the west of the workbench grid. + // Move test view to the west of the workbench grid. + await testView.view.tab.dragTo({grid: 'workbench', region: 'west'}); + const testViewInfo = await testView.view.getInfo(); + + // Expect test view to be moved to the west of the workbench grid. await expect(appPO.workbench).toEqualWorkbenchLayout({ workbenchGrid: { root: new MTreeNode({ direction: 'row', ratio: .2, child1: new MPart({ - id: await view2.part.getPartId(), - views: [{id: 'view.2'}], - activeViewId: 'view.2', + id: testViewInfo.partId, + views: [{id: testViewInfo.viewId}], + activeViewId: testViewInfo.viewId, }), child2: new MPart({id: MAIN_AREA}), }), }, mainAreaGrid: { root: new MPart({ - id: await layoutPage.view.part.getPartId(), - views: [{id: 'view.1'}], - activeViewId: 'view.1', + id: initialPartId, + views: [{id: 'view.101'}], + activeViewId: 'view.101', }), - activePartId: await layoutPage.view.part.getPartId(), + activePartId: initialPartId, }, }); }); /** - * +-------------+----------------+ +----------+-------------+------------+ - * | LEFT-TOP | | | | LEFT-TOP | | - * | [view.3] | | | | [view.3] | | - * +-------------+ MAIN-AREA | => | WEST +-------------+ MAIN-AREA | - * | LEFT-BOTTOM |[view.1, view.2]| | [view.2] | LEFT-BOTTOM | [view.1] | - * | [view.4] | | | | [view.4] | | - * +-------------+----------------+ +----------+-------------+------------+ + * +---------------+------------------+ +----------+---------------+------------+ + * | LEFT-TOP | | | | LEFT-TOP | | + * | [view.102] | | | | [view.102] | | + * +---------------+ MAIN-AREA | => | WEST +---------------+ MAIN-AREA | + * | LEFT-BOTTOM |[view.1, view.101]| | [view.1] | LEFT-BOTTOM | [view.101] | + * | [view.103] | | | | [view.103] | | + * +---------------+------------------+ +----------+---------------+------------+ */ test('should allow dragging a view to the west in the workbench grid (2)', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - await layoutPage.addPart('left-top', {relativeTo: MAIN_AREA, align: 'left', ratio: .25}); - await layoutPage.addPart('left-bottom', {relativeTo: 'left-top', align: 'bottom', ratio: .25}); - await layoutPage.addView('view.3', {partId: 'left-top', activateView: true}); - await layoutPage.addView('view.4', {partId: 'left-bottom', activateView: true}); + const testView = await workbenchNavigator.openInNewTab(ViewPagePO); + const initialPartId = await testView.view.part.getPartId(); + + await workbenchNavigator.modifyLayout(layout => layout + .addPart('left-top', {relativeTo: MAIN_AREA, align: 'left', ratio: .25}) + .addPart('left-bottom', {relativeTo: 'left-top', align: 'bottom', ratio: .25}) + .addView('view.101', {partId: initialPartId}) + .addView('view.102', {partId: 'left-top', activateView: true}) + .addView('view.103', {partId: 'left-bottom', activateView: true}), + ); - // Move view 2 to the west of the workbench grid. - const view2 = (await workbenchNavigator.openInNewTab(ViewPagePO)).view; - await view2.tab.dragTo({grid: 'workbench', region: 'west'}); + // Move test view to the west of the workbench grid. + await testView.view.tab.dragTo({grid: 'workbench', region: 'west'}); + const testViewInfo = await testView.view.getInfo(); - // Expect view 2 to be moved to the west of the workbench grid. + // Expect test view to be moved to the west of the workbench grid. await expect(appPO.workbench).toEqualWorkbenchLayout({ workbenchGrid: { root: new MTreeNode({ direction: 'row', ratio: .2, child1: new MPart({ - id: await view2.part.getPartId(), - views: [{id: 'view.2'}], - activeViewId: 'view.2', + id: testViewInfo.partId, + views: [{id: testViewInfo.viewId}], + activeViewId: testViewInfo.viewId, }), child2: new MTreeNode({ direction: 'row', @@ -101,13 +110,13 @@ test.describe('View Drag Workbench Grid', () => { ratio: .75, child1: new MPart({ id: 'left-top', - views: [{id: 'view.3'}], - activeViewId: 'view.3', + views: [{id: 'view.102'}], + activeViewId: 'view.102', }), child2: new MPart({ id: 'left-bottom', - views: [{id: 'view.4'}], - activeViewId: 'view.4', + views: [{id: 'view.103'}], + activeViewId: 'view.103', }), }), child2: new MPart({id: MAIN_AREA}), @@ -116,31 +125,36 @@ test.describe('View Drag Workbench Grid', () => { }, mainAreaGrid: { root: new MPart({ - id: await layoutPage.view.part.getPartId(), - views: [{id: 'view.1'}], - activeViewId: 'view.1', + id: initialPartId, + views: [{id: 'view.101'}], + activeViewId: 'view.101', }), - activePartId: await layoutPage.view.part.getPartId(), + activePartId: initialPartId, }, }); }); /** - * +------------------+ +----------+-----------+ - * | MAIN-AREA | => | WEST | MAIN-AREA | - * | [view.1, view.2] | | [view.2] | [view.1] | - * +------------------+ +----------+-----------+ + * +--------------------+ +------------+----------+ + * | MAIN-AREA | => | MAIN-AREA | EAST | + * | [view.1, view.101] | | [view.101] | [view.1] | + * +--------------------+ +------------+----------+ */ test('should allow dragging a view to the east in the workbench grid (1)', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - const view2 = (await workbenchNavigator.openInNewTab(ViewPagePO)).view; + const testView = await workbenchNavigator.openInNewTab(ViewPagePO); + const initialPartId = await testView.view.part.getPartId(); + + await workbenchNavigator.modifyLayout(layout => layout + .addView('view.101', {partId: initialPartId}), + ); - // Move view 2 to the east of the workbench grid. - await view2.tab.dragTo({grid: 'workbench', region: 'east'}); + // Move test view to the east of the workbench grid. + await testView.view.tab.dragTo({grid: 'workbench', region: 'east'}); + const testViewInfo = await testView.view.getInfo(); - // Expect view 2 to be moved to the east of the workbench grid. + // Expect test view to be moved to the east of the workbench grid. await expect(appPO.workbench).toEqualWorkbenchLayout({ workbenchGrid: { root: new MTreeNode({ @@ -148,46 +162,51 @@ test.describe('View Drag Workbench Grid', () => { ratio: .8, child1: new MPart({id: MAIN_AREA}), child2: new MPart({ - id: await view2.part.getPartId(), - views: [{id: 'view.2'}], - activeViewId: 'view.2', + id: testViewInfo.partId, + views: [{id: testViewInfo.viewId}], + activeViewId: testViewInfo.viewId, }), }), }, mainAreaGrid: { root: new MPart({ - id: await layoutPage.view.part.getPartId(), - views: [{id: 'view.1'}], - activeViewId: 'view.1', + id: initialPartId, + views: [{id: 'view.101'}], + activeViewId: 'view.101', }), - activePartId: await layoutPage.view.part.getPartId(), + activePartId: initialPartId, }, }); }); /** - * +----------------+--------------+ +-----------+--------------+----------+ - * | | RIGHT-TOP | | | RIGHT-TOP | | - * | | [view.3] | | | [view.3] | | - * | MAIN-AREA +--------------+ => | MAIN-AREA +--------------+ EAST + - * |[view.1, view.2]| RIGHT-BOTTOM | | [view.1] | RIGHT-BOTTOM | [view.2] | - * | | [view.4] | | | [view.4] | | - * +----------------+--------------+ +-----------+--------------+----------+ + * +------------------+--------------+ +-------------+--------------+----------+ + * | | RIGHT-TOP | | | RIGHT-TOP | | + * | | [view.102] | | | [view.102] | | + * | MAIN-AREA +--------------+ => | MAIN-AREA +--------------+ EAST + + * |[view.1, view.101]| RIGHT-BOTTOM | | [view.101] | RIGHT-BOTTOM | [view.1] | + * | | [view.103] | | | [view.103] | | + * +------------------+--------------+ +-------------+--------------+----------+ */ test('should allow dragging a view to the east in the workbench grid (2)', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - await layoutPage.addPart('right-top', {relativeTo: MAIN_AREA, align: 'right', ratio: .25}); - await layoutPage.addPart('right-bottom', {relativeTo: 'right-top', align: 'bottom', ratio: .25}); - await layoutPage.addView('view.3', {partId: 'right-top', activateView: true}); - await layoutPage.addView('view.4', {partId: 'right-bottom', activateView: true}); + const testView = await workbenchNavigator.openInNewTab(ViewPagePO); + const initialPartId = await testView.view.part.getPartId(); - // Move view 2 to the east of the workbench grid. - const view2 = (await workbenchNavigator.openInNewTab(ViewPagePO)).view; - await view2.tab.dragTo({grid: 'workbench', region: 'east'}); + await workbenchNavigator.modifyLayout(layout => layout + .addPart('right-top', {relativeTo: MAIN_AREA, align: 'right', ratio: .25}) + .addPart('right-bottom', {relativeTo: 'right-top', align: 'bottom', ratio: .25}) + .addView('view.101', {partId: initialPartId}) + .addView('view.102', {partId: 'right-top', activateView: true}) + .addView('view.103', {partId: 'right-bottom', activateView: true}), + ); - // Expect view 2 to be moved to the east of the workbench grid. + // Move test view to the east of the workbench grid. + await testView.view.tab.dragTo({grid: 'workbench', region: 'east'}); + const testViewInfo = await testView.view.getInfo(); + + // Expect test view to be moved to the east of the workbench grid. await expect(appPO.workbench).toEqualWorkbenchLayout({ workbenchGrid: { root: new MTreeNode({ @@ -202,53 +221,58 @@ test.describe('View Drag Workbench Grid', () => { ratio: .75, child1: new MPart({ id: 'right-top', - views: [{id: 'view.3'}], - activeViewId: 'view.3', + views: [{id: 'view.102'}], + activeViewId: 'view.102', }), child2: new MPart({ id: 'right-bottom', - views: [{id: 'view.4'}], - activeViewId: 'view.4', + views: [{id: 'view.103'}], + activeViewId: 'view.103', }), }), }), child2: new MPart({ - id: await view2.part.getPartId(), - views: [{id: 'view.2'}], - activeViewId: 'view.2', + id: testViewInfo.partId, + views: [{id: testViewInfo.viewId}], + activeViewId: testViewInfo.viewId, }), }), }, mainAreaGrid: { root: new MPart({ - id: await layoutPage.view.part.getPartId(), - views: [{id: 'view.1'}], - activeViewId: 'view.1', + id: initialPartId, + views: [{id: 'view.101'}], + activeViewId: 'view.101', }), - activePartId: await layoutPage.view.part.getPartId(), + activePartId: initialPartId, }, }); }); /** - * +------------------+ +-----------+ - * | | | MAIN-AREA | - * | MAIN-AREA | | [view.1] | - * | [view.1, view.2] | => +-----------+ - * | | | SOUTH | - * | | | [view.2] | - * +------------------+ +-----------+ + * +--------------------+ +-------------+ + * | | | MAIN-AREA | + * | MAIN-AREA | | [view.101] | + * | [view.1, view.101] | => +-------------+ + * | | | SOUTH | + * | | | [view.1] | + * +--------------------+ +-------------+ */ test('should allow dragging a view to the south in the workbench grid (1)', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - const view2 = (await workbenchNavigator.openInNewTab(ViewPagePO)).view; + const testView = await workbenchNavigator.openInNewTab(ViewPagePO); + const initialPartId = await testView.view.part.getPartId(); + + await workbenchNavigator.modifyLayout(layout => layout + .addView('view.101', {partId: initialPartId}), + ); - // Move view 2 to the south of the workbench grid. - await view2.tab.dragTo({grid: 'workbench', region: 'south'}); + // Move test view to the south of the workbench grid. + await testView.view.tab.dragTo({grid: 'workbench', region: 'south'}); + const testViewInfo = await testView.view.getInfo(); - // Expect view 2 to be moved to the south of the workbench grid. + // Expect test view to be moved to the south of the workbench grid. await expect(appPO.workbench).toEqualWorkbenchLayout({ workbenchGrid: { root: new MTreeNode({ @@ -256,19 +280,19 @@ test.describe('View Drag Workbench Grid', () => { ratio: .8, child1: new MPart({id: MAIN_AREA}), child2: new MPart({ - id: await view2.part.getPartId(), - views: [{id: 'view.2'}], - activeViewId: 'view.2', + id: testViewInfo.partId, + views: [{id: testViewInfo.viewId}], + activeViewId: testViewInfo.viewId, }), }), }, mainAreaGrid: { root: new MPart({ - id: await layoutPage.view.part.getPartId(), - views: [{id: 'view.1'}], - activeViewId: 'view.1', + id: initialPartId, + views: [{id: 'view.101'}], + activeViewId: 'view.101', }), - activePartId: await layoutPage.view.part.getPartId(), + activePartId: initialPartId, }, }); }); @@ -276,29 +300,34 @@ test.describe('View Drag Workbench Grid', () => { /** * +----------------------------+ +----------------------------+ * | | | MAIN-AREA | - * | MAIN-AREA | | [view.1] | - * | [view.1, view.2] | => +-------------+--------------+ + * | MAIN-AREA | | [view.101] | + * | [view.1, view.101] | => +-------------+--------------+ * | | | BOTTOM-LEFT | BOTTOM-RIGHT | - * | | | [view.3] | [view.4 | + * | | | [view.102] | [view.103] | * +-------------+--------------+ +-------------+--------------+ * | BOTTOM-LEFT | BOTTOM-RIGHT | | SOUTH | - * | [view.3] | [view.4] | | [view.2] | + * | [view.102] | [view.103] | | [view.1] | * +----------------------------+ +----------------------------+ */ test('should allow dragging a view to the south in the workbench grid (2)', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - await layoutPage.addPart('bottom-left', {relativeTo: MAIN_AREA, align: 'bottom', ratio: .25}); - await layoutPage.addPart('bottom-right', {relativeTo: 'bottom-left', align: 'right', ratio: .6}); - await layoutPage.addView('view.3', {partId: 'bottom-left', activateView: true}); - await layoutPage.addView('view.4', {partId: 'bottom-right', activateView: true}); + const testView = await workbenchNavigator.openInNewTab(ViewPagePO); + const initialPartId = await testView.view.part.getPartId(); - // Move view 2 to the south of the workbench grid. - const view2 = (await workbenchNavigator.openInNewTab(ViewPagePO)).view; - await view2.tab.dragTo({grid: 'workbench', region: 'south'}); + await workbenchNavigator.modifyLayout(layout => layout + .addPart('bottom-left', {relativeTo: MAIN_AREA, align: 'bottom', ratio: .25}) + .addPart('bottom-right', {relativeTo: 'bottom-left', align: 'right', ratio: .6}) + .addView('view.101', {partId: initialPartId}) + .addView('view.102', {partId: 'bottom-left', activateView: true}) + .addView('view.103', {partId: 'bottom-right', activateView: true}), + ); - // Expect view 2 to be moved to the south of the workbench grid. + // Move test view to the south of the workbench grid. + await testView.view.tab.dragTo({grid: 'workbench', region: 'south'}); + const testViewInfo = await testView.view.getInfo(); + + // Expect test view to be moved to the south of the workbench grid. await expect(appPO.workbench).toEqualWorkbenchLayout({ workbenchGrid: { root: new MTreeNode({ @@ -313,51 +342,55 @@ test.describe('View Drag Workbench Grid', () => { ratio: .4, child1: new MPart({ id: 'bottom-left', - views: [{id: 'view.3'}], - activeViewId: 'view.3', + views: [{id: 'view.102'}], + activeViewId: 'view.102', }), child2: new MPart({ id: 'bottom-right', - views: [{id: 'view.4'}], - activeViewId: 'view.4', + views: [{id: 'view.103'}], + activeViewId: 'view.103', }), }), }), child2: new MPart({ - id: await view2.part.getPartId(), - views: [{id: 'view.2'}], - activeViewId: 'view.2', + id: testViewInfo.partId, + views: [{id: testViewInfo.viewId}], + activeViewId: testViewInfo.viewId, }), }), }, mainAreaGrid: { root: new MPart({ - id: await layoutPage.view.part.getPartId(), - views: [{id: 'view.1'}], - activeViewId: 'view.1', + id: initialPartId, + views: [{id: 'view.101'}], + activeViewId: 'view.101', }), - activePartId: await layoutPage.view.part.getPartId(), + activePartId: initialPartId, }, }); }); /** - * +-----------------+----------+ - * | | | - * | MAIN-AREA | RIGHT | - * | [view.1,view.2] | [view.3] | - * | | | - * +-----------------+----------+ + * +-------------------+------------+ + * | | | + * | MAIN-AREA | RIGHT | + * | [view.1,view.101] | [view.102] | + * | | | + * +-------------------+------------+ */ test('should NOT allow dragging a view to the north or a fully adjacent side of the workbench grid (1)', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - await layoutPage.addPart('right', {relativeTo: MAIN_AREA, align: 'right'}); - await layoutPage.addView('view.3', {partId: 'right', activateView: true}); + const testView = await workbenchNavigator.openInNewTab(ViewPagePO); + const initialPartId = await testView.view.part.getPartId(); + + await workbenchNavigator.modifyLayout(layout => layout + .addPart('right', {relativeTo: MAIN_AREA, align: 'right'}) + .addView('view.101', {partId: initialPartId}) + .addView('view.102', {partId: 'right', activateView: true}), + ); - const view2 = (await workbenchNavigator.openInNewTab(ViewPagePO)).view; - await view2.tab.activateDropZones({grid: 'workbench'}); + await testView.view.tab.activateDropZones({grid: 'workbench'}); await expect.poll(() => appPO.isDropZoneActive({grid: 'workbench', region: 'north'})).toBe(false); await expect.poll(() => appPO.isDropZoneActive({grid: 'workbench', region: 'south'})).toBe(true); @@ -366,23 +399,27 @@ test.describe('View Drag Workbench Grid', () => { }); /** - * +-----------------+ - * | MAIN-AREA | - * | [view.1,view.2] | - * +-----------------| - * | BOTTOM | - * | [view.3] | - * +-----------------+ + * +-------------------+ + * | MAIN-AREA | + * | [view.1,view.101] | + * +-------------------| + * | BOTTOM | + * | [view.102] | + * +-------------------+ */ test('should NOT allow dragging a view to the north or a fully adjacent side of the workbench grid (2)', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - await layoutPage.addPart('bottom', {relativeTo: MAIN_AREA, align: 'bottom'}); - await layoutPage.addView('view.3', {partId: 'bottom', activateView: true}); + const testView = await workbenchNavigator.openInNewTab(ViewPagePO); + const initialPartId = await testView.view.part.getPartId(); + + await workbenchNavigator.modifyLayout(layout => layout + .addPart('bottom', {relativeTo: MAIN_AREA, align: 'bottom'}) + .addView('view.101', {partId: initialPartId}) + .addView('view.102', {partId: 'bottom', activateView: true}), + ); - const view2 = (await workbenchNavigator.openInNewTab(ViewPagePO)).view; - await view2.tab.activateDropZones({grid: 'workbench'}); + await testView.view.tab.activateDropZones({grid: 'workbench'}); await expect.poll(() => appPO.isDropZoneActive({grid: 'workbench', region: 'north'})).toBe(false); await expect.poll(() => appPO.isDropZoneActive({grid: 'workbench', region: 'south'})).toBe(false); @@ -391,25 +428,29 @@ test.describe('View Drag Workbench Grid', () => { }); /** - * +-----------------+----------+ - * | MAIN-AREA | | - * | [view.1,view.2] | RIGHT | - * +-----------------| [view.4] | - * | BOTTOM-LEFT | | - * | [view.3] | | - * +-----------------+----------+ + * +-------------------+------------+ + * | MAIN-AREA | | + * | [view.1,view.101] | RIGHT | + * +-------------------| [view.103] | + * | BOTTOM-LEFT | | + * | [view.102] | | + * +-------------------+------------+ */ test('should NOT allow dragging a view to the north or a fully adjacent side of the workbench grid (3)', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - await layoutPage.addPart('right', {relativeTo: MAIN_AREA, align: 'right'}); - await layoutPage.addPart('bottom-left', {relativeTo: MAIN_AREA, align: 'bottom'}); - await layoutPage.addView('view.3', {partId: 'bottom-left', activateView: true}); - await layoutPage.addView('view.4', {partId: 'right', activateView: true}); + const testView = await workbenchNavigator.openInNewTab(ViewPagePO); + const initialPartId = await testView.view.part.getPartId(); - const view2 = (await workbenchNavigator.openInNewTab(ViewPagePO)).view; - await view2.tab.activateDropZones({grid: 'workbench'}); + await workbenchNavigator.modifyLayout(layout => layout + .addPart('right', {relativeTo: MAIN_AREA, align: 'right'}) + .addPart('bottom-left', {relativeTo: MAIN_AREA, align: 'bottom'}) + .addView('view.101', {partId: initialPartId}) + .addView('view.102', {partId: 'bottom-left', activateView: true}) + .addView('view.103', {partId: 'right', activateView: true}), + ); + + await testView.view.tab.activateDropZones({grid: 'workbench'}); await expect.poll(() => appPO.isDropZoneActive({grid: 'workbench', region: 'north'})).toBe(false); await expect.poll(() => appPO.isDropZoneActive({grid: 'workbench', region: 'south'})).toBe(true); @@ -418,25 +459,29 @@ test.describe('View Drag Workbench Grid', () => { }); /** - * +----------+-----------------+ - * | | MAIN-AREA | - * | LEFT | [view.1,view.2] | - * | [view.4] +-----------------| - * | | BOTTOM-RIGHT | - * | | [view.3] | - * +----------+-----------------+ + * +------------+-------------------+ + * | | MAIN-AREA | + * | LEFT | [view.1,view.101] | + * | [view.103] +-------------------| + * | | BOTTOM-RIGHT | + * | | [view.102] | + * +------------+-------------------+ */ test('should NOT allow dragging a view to the north or a fully adjacent side of the workbench grid (4)', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - await layoutPage.addPart('left', {relativeTo: MAIN_AREA, align: 'left'}); - await layoutPage.addPart('bottom-right', {relativeTo: MAIN_AREA, align: 'bottom'}); - await layoutPage.addView('view.3', {partId: 'bottom-right', activateView: true}); - await layoutPage.addView('view.4', {partId: 'left', activateView: true}); + const testView = await workbenchNavigator.openInNewTab(ViewPagePO); + const initialPartId = await testView.view.part.getPartId(); + + await workbenchNavigator.modifyLayout(layout => layout + .addPart('left', {relativeTo: MAIN_AREA, align: 'left'}) + .addPart('bottom-right', {relativeTo: MAIN_AREA, align: 'bottom'}) + .addView('view.101', {partId: initialPartId}) + .addView('view.102', {partId: 'bottom-right', activateView: true}) + .addView('view.103', {partId: 'left', activateView: true}), + ); - const view2 = (await workbenchNavigator.openInNewTab(ViewPagePO)).view; - await view2.tab.activateDropZones({grid: 'workbench'}); + await testView.view.tab.activateDropZones({grid: 'workbench'}); await expect.poll(() => appPO.isDropZoneActive({grid: 'workbench', region: 'north'})).toBe(false); await expect.poll(() => appPO.isDropZoneActive({grid: 'workbench', region: 'south'})).toBe(true); @@ -445,30 +490,34 @@ test.describe('View Drag Workbench Grid', () => { }); /** - * +----------+-----------------+ - * | | MAIN-AREA | - * | LEFT | [view.1,view.2] | - * | [view.4] +-----------------| - * | | BOTTOM-RIGHT | - * | | [view.3] | - * +----------+-----------------+ - * | BOTTOM | - * | [view.5] | - * +----------------------------+ + * +------------+-------------------+ + * | | MAIN-AREA | + * | LEFT | [view.1,view.101] | + * | [view.103] +-------------------| + * | | BOTTOM-RIGHT | + * | | [view.102] | + * +------------+-------------------+ + * | BOTTOM | + * | [view.104] | + * +--------------------------------+ */ test('should NOT allow dragging a view to the north or a fully adjacent side of the workbench grid (5)', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - await layoutPage.addPart('bottom', {relativeTo: MAIN_AREA, align: 'bottom'}); - await layoutPage.addPart('left', {relativeTo: MAIN_AREA, align: 'left'}); - await layoutPage.addPart('bottom-right', {relativeTo: MAIN_AREA, align: 'bottom'}); - await layoutPage.addView('view.3', {partId: 'bottom-right', activateView: true}); - await layoutPage.addView('view.4', {partId: 'left', activateView: true}); - await layoutPage.addView('view.5', {partId: 'bottom', activateView: true}); + const testView = await workbenchNavigator.openInNewTab(ViewPagePO); + const initialPartId = await testView.view.part.getPartId(); - const view2 = (await workbenchNavigator.openInNewTab(ViewPagePO)).view; - await view2.tab.activateDropZones({grid: 'workbench'}); + await workbenchNavigator.modifyLayout(layout => layout + .addPart('bottom', {relativeTo: MAIN_AREA, align: 'bottom'}) + .addPart('left', {relativeTo: MAIN_AREA, align: 'left'}) + .addPart('bottom-right', {relativeTo: MAIN_AREA, align: 'bottom'}) + .addView('view.101', {partId: initialPartId}) + .addView('view.102', {partId: 'bottom-right', activateView: true}) + .addView('view.103', {partId: 'left', activateView: true}) + .addView('view.104', {partId: 'bottom', activateView: true}), + ); + + await testView.view.tab.activateDropZones({grid: 'workbench'}); await expect.poll(() => appPO.isDropZoneActive({grid: 'workbench', region: 'north'})).toBe(false); await expect.poll(() => appPO.isDropZoneActive({grid: 'workbench', region: 'south'})).toBe(false); @@ -477,32 +526,36 @@ test.describe('View Drag Workbench Grid', () => { }); /** - * +----------+-----------------+----------+ - * | | MAIN-AREA | | - * | LEFT | [view.1,view.2] | | - * | [view.4] +-----------------| | - * | | MIDDLE | RIGHT | - * | | [view.3] | [view.6] | - * +----------+-----------------+ | - * | BOTTOM | | - * | [view.5] | | - * +----------------------------+----------+ + * +------------+-----------------+------------+ + * | | MAIN-AREA | | + * | LEFT | [view.1,view.2] | | + * | [view.103] +-----------------| | + * | | MIDDLE | RIGHT | + * | | [view.102] | [view.105] | + * +------------+-----------------+ | + * | BOTTOM | | + * | [view.104] | | + * +------------------------------+------------+ */ test('should NOT allow dragging a view to the north or a fully adjacent side of the workbench grid (6)', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - await layoutPage.addPart('right', {relativeTo: MAIN_AREA, align: 'right'}); - await layoutPage.addPart('bottom', {relativeTo: MAIN_AREA, align: 'bottom'}); - await layoutPage.addPart('left', {relativeTo: MAIN_AREA, align: 'left'}); - await layoutPage.addPart('middle', {relativeTo: MAIN_AREA, align: 'bottom'}); - await layoutPage.addView('view.3', {partId: 'middle', activateView: true}); - await layoutPage.addView('view.4', {partId: 'left', activateView: true}); - await layoutPage.addView('view.5', {partId: 'bottom', activateView: true}); - await layoutPage.addView('view.6', {partId: 'right', activateView: true}); + const testView = await workbenchNavigator.openInNewTab(ViewPagePO); + const initialPartId = await testView.view.part.getPartId(); + + await workbenchNavigator.modifyLayout(layout => layout + .addPart('right', {relativeTo: MAIN_AREA, align: 'right'}) + .addPart('bottom', {relativeTo: MAIN_AREA, align: 'bottom'}) + .addPart('left', {relativeTo: MAIN_AREA, align: 'left'}) + .addPart('middle', {relativeTo: MAIN_AREA, align: 'bottom'}) + .addView('view.101', {partId: initialPartId}) + .addView('view.102', {partId: 'middle', activateView: true}) + .addView('view.103', {partId: 'left', activateView: true}) + .addView('view.104', {partId: 'bottom', activateView: true}) + .addView('view.105', {partId: 'right', activateView: true}), + ); - const view2 = (await workbenchNavigator.openInNewTab(ViewPagePO)).view; - await view2.tab.activateDropZones({grid: 'workbench'}); + await testView.view.tab.activateDropZones({grid: 'workbench'}); await expect.poll(() => appPO.isDropZoneActive({grid: 'workbench', region: 'north'})).toBe(false); await expect.poll(() => appPO.isDropZoneActive({grid: 'workbench', region: 'south'})).toBe(true); @@ -511,25 +564,29 @@ test.describe('View Drag Workbench Grid', () => { }); /** - * +------------+-----------------+----------+ - * | | MAIN-AREA | | - * | LEFT | [view.1,view.2] | RIGHT | - * | +-----------------|| - * | | BOTTOM-MIDDLE | | - * | | [view.3] | | - * +------------+-----------------+----------+ + * +------------+-------------------+----------+ + * | | MAIN-AREA | | + * | LEFT | [view.1,view.101] | RIGHT | + * | +-------------------|| + * | | BOTTOM-MIDDLE | | + * | | [view.102] | | + * +------------+-------------------+----------+ */ test('should NOT allow dragging a view to the north or a fully adjacent side of the workbench grid (7)', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - await layoutPage.addPart('left', {relativeTo: MAIN_AREA, align: 'left'}); - await layoutPage.addPart('right', {relativeTo: MAIN_AREA, align: 'right'}); - await layoutPage.addPart('bottom-middle', {relativeTo: MAIN_AREA, align: 'bottom'}); - await layoutPage.addView('view.3', {partId: 'bottom-middle', activateView: true}); + const testView = await workbenchNavigator.openInNewTab(ViewPagePO); + const initialPartId = await testView.view.part.getPartId(); + + await workbenchNavigator.modifyLayout(layout => layout + .addPart('left', {relativeTo: MAIN_AREA, align: 'left'}) + .addPart('right', {relativeTo: MAIN_AREA, align: 'right'}) + .addPart('bottom-middle', {relativeTo: MAIN_AREA, align: 'bottom'}) + .addView('view.101', {partId: initialPartId}) + .addView('view.102', {partId: 'bottom-middle', activateView: true}), + ); - const view2 = (await workbenchNavigator.openInNewTab(ViewPagePO)).view; - await view2.tab.activateDropZones({grid: 'workbench'}); + await testView.view.tab.activateDropZones({grid: 'workbench'}); await expect.poll(() => appPO.isDropZoneActive({grid: 'workbench', region: 'north'})).toBe(false); await expect.poll(() => appPO.isDropZoneActive({grid: 'workbench', region: 'south'})).toBe(false); @@ -538,26 +595,30 @@ test.describe('View Drag Workbench Grid', () => { }); /** - * +-------------+-----------------+--------------+ - * | LEFT-TOP | | RIGHT-TOP | - * | | MAIN-AREA | | - * +-------------+ [view.1,view.2] +--------------+ - * | LEFT-BOTTOM | | RIGHT-BOTTOM | - * | | | [view.3] | - * +-------------+-----------------+--------------+ + * +-------------+-------------------+--------------+ + * | LEFT-TOP | | RIGHT-TOP | + * | | MAIN-AREA | | + * +-------------+ [view.1,view.101] +--------------+ + * | LEFT-BOTTOM | | RIGHT-BOTTOM | + * | | | [view.102] | + * +-------------+-------------------+--------------+ */ test('should NOT allow dragging a view to the north or a fully adjacent side of the workbench grid (8)', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - await layoutPage.addPart('left-top', {relativeTo: MAIN_AREA, align: 'left'}); - await layoutPage.addPart('right-top', {relativeTo: MAIN_AREA, align: 'right'}); - await layoutPage.addPart('left-bottom', {relativeTo: 'left-top', align: 'bottom'}); - await layoutPage.addPart('right-bottom', {relativeTo: 'right-top', align: 'bottom'}); - await layoutPage.addView('view.3', {partId: 'right-bottom', activateView: true}); + const testView = await workbenchNavigator.openInNewTab(ViewPagePO); + const initialPartId = await testView.view.part.getPartId(); - const view2 = (await workbenchNavigator.openInNewTab(ViewPagePO)).view; - await view2.tab.activateDropZones({grid: 'workbench'}); + await workbenchNavigator.modifyLayout(layout => layout + .addPart('left-top', {relativeTo: MAIN_AREA, align: 'left'}) + .addPart('right-top', {relativeTo: MAIN_AREA, align: 'right'}) + .addPart('left-bottom', {relativeTo: 'left-top', align: 'bottom'}) + .addPart('right-bottom', {relativeTo: 'right-top', align: 'bottom'}) + .addView('view.101', {partId: initialPartId}) + .addView('view.102', {partId: 'right-bottom', activateView: true}), + ); + + await testView.view.tab.activateDropZones({grid: 'workbench'}); await expect.poll(() => appPO.isDropZoneActive({grid: 'workbench', region: 'north'})).toBe(false); await expect.poll(() => appPO.isDropZoneActive({grid: 'workbench', region: 'south'})).toBe(true); @@ -566,24 +627,28 @@ test.describe('View Drag Workbench Grid', () => { }); /** - * +------------------+----------+ - * | MAIN-AREA | RIGHT | - * | [view.1, view.2] | [view.3] | - * +------------------+----------+ - * | BOTTOM | - * | | - * +-----------------------------+ + * +--------------------+------------+ + * | MAIN-AREA | RIGHT | + * | [view.1, view.101] | [view.102] | + * +--------------------+------------+ + * | BOTTOM | + * | | + * +---------------------------------+ */ test('should NOT allow dragging a view to the north or a fully adjacent side of the workbench grid (9)', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - await layoutPage.addPart('bottom', {relativeTo: MAIN_AREA, align: 'bottom'}); - await layoutPage.addPart('right', {relativeTo: MAIN_AREA, align: 'right'}); - await layoutPage.addView('view.3', {partId: 'right', activateView: true}); + const testView = await workbenchNavigator.openInNewTab(ViewPagePO); + const initialPartId = await testView.view.part.getPartId(); + + await workbenchNavigator.modifyLayout(layout => layout + .addPart('bottom', {relativeTo: MAIN_AREA, align: 'bottom'}) + .addPart('right', {relativeTo: MAIN_AREA, align: 'right'}) + .addView('view.101', {partId: initialPartId}) + .addView('view.102', {partId: 'right', activateView: true}), + ); - const view2 = (await workbenchNavigator.openInNewTab(ViewPagePO)).view; - await view2.tab.activateDropZones({grid: 'workbench'}); + await testView.view.tab.activateDropZones({grid: 'workbench'}); await expect.poll(() => appPO.isDropZoneActive({grid: 'workbench', region: 'north'})).toBe(false); await expect.poll(() => appPO.isDropZoneActive({grid: 'workbench', region: 'south'})).toBe(true); @@ -592,30 +657,32 @@ test.describe('View Drag Workbench Grid', () => { }); /** - * +------------------+ - * | MAIN-AREA | - * | [view.1, view.2] | - * +------------------+ - * | BOTTOM | - * | [view.3] | - * +------------------+ + * +--------------------+ + * | MAIN-AREA | + * | [view.1, view.101] | + * +--------------------+ + * | BOTTOM | + * | [view.102] | + * +--------------------+ */ test('should disable drop zone when dragging a view into the tabbar', async ({page, appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - await layoutPage.addPart('bottom', {relativeTo: MAIN_AREA, align: 'bottom', ratio: .25}); - await layoutPage.addView('view.3', {partId: 'bottom', activateView: true}); + const testView = await workbenchNavigator.openInNewTab(ViewPagePO); + const initialPartId = await testView.view.part.getPartId(); + + await workbenchNavigator.modifyLayout(layout => layout + .addPart('bottom', {relativeTo: MAIN_AREA, align: 'bottom', ratio: .25}) + .addView('view.101', {partId: initialPartId}) + .addView('view.102', {partId: 'bottom', activateView: true}), + ); // Get bounding box of the tabbar of the 'bottom' part. const bottomPart = appPO.part({partId: 'bottom'}); const bottomTabbarBounds = fromRect(await bottomPart.getPartBarBoundingBox()); - // Open view in the initial part. - const viewPage2 = await workbenchNavigator.openInNewTab(ViewPagePO); - // Press mouse button on the view tab. - await viewPage2.view.tab.mousedown(); + await testView.view.tab.mousedown(); // Move tab to the center of the tabbar of the 'bottom' part. await page.mouse.move(bottomTabbarBounds.hcenter, bottomTabbarBounds.vcenter, {steps: 100}); @@ -628,30 +695,32 @@ test.describe('View Drag Workbench Grid', () => { }); /** - * +------------------+ - * | MAIN-AREA | - * | [view.1, view.2] | - * +------------------+ - * | BOTTOM | - * | [view.3] | - * +------------------+ + * +--------------------+ + * | MAIN-AREA | + * | [view.1, view.101] | + * +--------------------+ + * | BOTTOM | + * | [view.102] | + * +--------------------+ */ test('should not disable drop zone when entering tabbar while dragging a view over the drop zone', async ({page, appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - await layoutPage.addPart('bottom', {relativeTo: MAIN_AREA, align: 'bottom', ratio: .25}); - await layoutPage.addView('view.3', {partId: 'bottom', activateView: true}); + const testView = await workbenchNavigator.openInNewTab(ViewPagePO); + const initialPartId = await testView.view.part.getPartId(); + + await workbenchNavigator.modifyLayout(layout => layout + .addPart('bottom', {relativeTo: MAIN_AREA, align: 'bottom', ratio: .25}) + .addView('view.101', {partId: initialPartId}) + .addView('view.102', {partId: 'bottom', activateView: true}), + ); // Get bounding box of the tabbar of the 'bottom' part. const bottomPart = appPO.part({partId: 'bottom'}); const bottomTabbarBounds = fromRect(await bottomPart.getPartBarBoundingBox()); - // Open view in the initial part. - const viewPage2 = await workbenchNavigator.openInNewTab(ViewPagePO); - // Press mouse button on the view tab. - await viewPage2.view.tab.mousedown(); + await testView.view.tab.mousedown(); // Move tab into drop zone. await page.mouse.move(0, 0, {steps: 1}); diff --git a/projects/scion/e2e-testing/src/workbench/view-drag.e2e-spec.ts b/projects/scion/e2e-testing/src/workbench/view-drag.e2e-spec.ts index d6a84d94c..dda28b0d3 100644 --- a/projects/scion/e2e-testing/src/workbench/view-drag.e2e-spec.ts +++ b/projects/scion/e2e-testing/src/workbench/view-drag.e2e-spec.ts @@ -11,12 +11,9 @@ import {expect} from '@playwright/test'; import {test} from '../fixtures'; import {ViewPagePO} from './page-object/view-page.po'; -import {LayoutPagePO} from './page-object/layout-page.po'; import {MPart, MTreeNode} from '../matcher/to-equal-workbench-layout.matcher'; import {expectView} from '../matcher/view-matcher'; -import {PerspectivePagePO} from './page-object/perspective-page.po'; import {MAIN_AREA} from '../workbench.model'; -import {RouterPagePO} from './page-object/router-page.po'; test.describe('View Drag', () => { @@ -330,72 +327,75 @@ test.describe('View Drag', () => { test.describe('drag to another part', () => { /** - * +----------+------------------+ +------------------+----------+ - * | INITIAL | XYZ | => | INITIAL | XYZ | - * | [view.1] | [view.2, view.3] | | [view.1, view.2] | [view.3] | - * +----------+------------------+ +------------------+----------+ + * +-----------+----------------------+ +--------------------+------------+ + * | INITIAL | RIGHT | => | INITIAL | RIGHT | + * | [view.1] | [view.101, view.102] | | [view.1, view.101] | [view.102] | + * +-----------+----------------------+ +--------------------+------------+ */ test('should allow dragging a view to the center of another part', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - // Open view in the initial part. - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - const view2 = appPO.view({viewId: 'view.2'}); - const view3 = appPO.view({viewId: 'view.3'}); + const initialPartView = await workbenchNavigator.openInNewTab(ViewPagePO); + const initialPartId = await initialPartView.view.part.getPartId(); // Open two views in another part. - await layoutPage.addPart('xyz', {relativeTo: await layoutPage.view.part.getPartId(), align: 'right', ratio: .5}); - await layoutPage.addView('view.2', {partId: 'xyz', activateView: true}); - await layoutPage.addView('view.3', {partId: 'xyz'}); + await workbenchNavigator.modifyLayout(layout => layout + .addPart('right', {relativeTo: initialPartId, align: 'right', ratio: .5}) + .addView('view.101', {partId: 'right', activateView: true}) + .addView('view.102', {partId: 'right'}), + ); - // Move view 2 to the center of the initial part. - await view2.tab.dragTo({partId: await layoutPage.view.part.getPartId(), region: 'center'}); + // Move view to the center of the initial part. + const testView = appPO.view({viewId: 'view.101'}); + await testView.tab.dragTo({partId: initialPartId, region: 'center'}); - // Expect view 2 to be moved to the initial part. + // Expect view to be moved to the initial part. await expect(appPO.workbench).toEqualWorkbenchLayout({ mainAreaGrid: { root: new MTreeNode({ direction: 'row', ratio: .5, child1: new MPart({ - id: await layoutPage.view.part.getPartId(), - views: [{id: 'view.1'}, {id: 'view.2'}], - activeViewId: 'view.2', + id: initialPartId, + views: [{id: 'view.1'}, {id: 'view.101'}], + activeViewId: 'view.101', }), child2: new MPart({ - id: await view3.part.getPartId(), - views: [{id: 'view.3'}], - activeViewId: 'view.3', + id: 'right', + views: [{id: 'view.102'}], + activeViewId: 'view.102', }), }), - activePartId: await layoutPage.view.part.getPartId(), + activePartId: initialPartId, }, }); }); /** - * +----------+------------------+ +----------+----------+----------+ - * | INITIAL | XYZ | => | WEST | INITIAL | XYZ | - * | [view.1] | [view.2, view.3] | | [view.2] | [view.1] | [view.3] | - * +----------+------------------+ +----------+----------+----------+ + * +-----------+----------------------+ +------------+------------+------------+ + * | INITIAL | RIGHT | => | WEST | INITIAL | RIGHT | + * | [view.1] | [view.101, view.102] | | [view.101] | [view.1] | [view.102] | + * +-----------+----------------------+ +------------+------------+------------+ */ test('should allow dragging a view to the west of another part', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - // Open view in the initial part. - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - const view2 = appPO.view({viewId: 'view.2'}); - const view3 = appPO.view({viewId: 'view.3'}); + const initialPartView = await workbenchNavigator.openInNewTab(ViewPagePO); + const initialPartId = await initialPartView.view.part.getPartId(); // Open two views in another part. - await layoutPage.addPart('xyz', {relativeTo: await layoutPage.view.part.getPartId(), align: 'right', ratio: .5}); - await layoutPage.addView('view.2', {partId: 'xyz', activateView: true}); - await layoutPage.addView('view.3', {partId: 'xyz'}); - - // Move view 2 to a new part in the west of the initial part. - await view2.tab.dragTo({partId: await layoutPage.view.part.getPartId(), region: 'west'}); - - // Expect view 2 to be moved to a new part in the west of the initial part. + await workbenchNavigator.modifyLayout(layout => layout + .addPart('right', {relativeTo: initialPartId, align: 'right', ratio: .5}) + .addView('view.101', {partId: 'right', activateView: true}) + .addView('view.102', {partId: 'right'}), + ); + + // Move view to a new part in the west of the initial part. + const testView = appPO.view({viewId: 'view.101'}); + await testView.tab.dragTo({partId: initialPartId, region: 'west'}); + const testViewInfo = await testView.getInfo(); + + // Expect view to be moved to a new part in the west of the initial part. await expect(appPO.workbench).toEqualWorkbenchLayout({ mainAreaGrid: { root: new MTreeNode({ @@ -405,50 +405,52 @@ test.describe('View Drag', () => { direction: 'row', ratio: .5, child1: new MPart({ - id: await view2.part.getPartId(), - views: [{id: 'view.2'}], - activeViewId: 'view.2', + id: testViewInfo.partId, + views: [{id: 'view.101'}], + activeViewId: 'view.101', }), child2: new MPart({ - id: await layoutPage.view.part.getPartId(), + id: initialPartId, views: [{id: 'view.1'}], activeViewId: 'view.1', }), }), child2: new MPart({ - id: await view3.part.getPartId(), - views: [{id: 'view.3'}], - activeViewId: 'view.3', + id: 'right', + views: [{id: 'view.102'}], + activeViewId: 'view.102', }), }), - activePartId: await view2.part.getPartId(), + activePartId: testViewInfo.partId, }, }); }); /** - * +----------+------------------+ +----------+----------+----------+ - * | INITIAL | XYZ | => | INITIAL | EAST | XYZ | - * | [view.1] | [view.2, view.3] | | [view.1] | [view.2] | [view.3] | - * +----------+------------------+ +----------+----------+----------+ + * +----------+----------------------+ +----------+------------+------------+ + * | INITIAL | RIGHT | => | INITIAL | EAST | RIGHT | + * | [view.1] | [view.101, view.102] | | [view.1] | [view.101] | [view.102] | + * +----------+----------------------+ +----------+------------+------------+ */ test('should allow dragging a view to the east of another part', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - // Open view in the initial part. - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - const view2 = appPO.view({viewId: 'view.2'}); - const view3 = appPO.view({viewId: 'view.3'}); + const initialPartView = await workbenchNavigator.openInNewTab(ViewPagePO); + const initialPartId = await initialPartView.view.part.getPartId(); // Open two views in another part. - await layoutPage.addPart('xyz', {relativeTo: await layoutPage.view.part.getPartId(), align: 'right', ratio: .5}); - await layoutPage.addView('view.2', {partId: 'xyz', activateView: true}); - await layoutPage.addView('view.3', {partId: 'xyz'}); - - // Move view 2 to a new part in the east of the initial part. - await view2.tab.dragTo({partId: await layoutPage.view.part.getPartId(), region: 'east'}); - - // Expect view 2 to be moved to a new part in the east of the initial part. + await workbenchNavigator.modifyLayout(layout => layout + .addPart('right', {relativeTo: initialPartId, align: 'right', ratio: .5}) + .addView('view.101', {partId: 'right', activateView: true}) + .addView('view.102', {partId: 'right'}), + ); + + // Move view to a new part in the east of the initial part. + const testView = appPO.view({viewId: 'view.101'}); + await testView.tab.dragTo({partId: initialPartId, region: 'east'}); + const testViewInfo = await testView.getInfo(); + + // Expect view to be moved to a new part in the east of the initial part. await expect(appPO.workbench).toEqualWorkbenchLayout({ mainAreaGrid: { root: new MTreeNode({ @@ -458,53 +460,55 @@ test.describe('View Drag', () => { direction: 'row', ratio: .5, child1: new MPart({ - id: await layoutPage.view.part.getPartId(), + id: initialPartId, views: [{id: 'view.1'}], activeViewId: 'view.1', }), child2: new MPart({ - id: await view2.part.getPartId(), - views: [{id: 'view.2'}], - activeViewId: 'view.2', + id: testViewInfo.partId, + views: [{id: 'view.101'}], + activeViewId: 'view.101', }), }), child2: new MPart({ - id: await view3.part.getPartId(), - views: [{id: 'view.3'}], - activeViewId: 'view.3', + id: 'right', + views: [{id: 'view.102'}], + activeViewId: 'view.102', }), }), - activePartId: await view2.part.getPartId(), + activePartId: testViewInfo.partId, }, }); }); /** - * +----------+------------------+ +----------+----------+ - * | | | | NORTH | | - * | INITIAL | XYZ | | [view.2] | XYZ | - * | [view.1] | [view.2, view.3] | => +----------+ [view.3] | - * | | | | INITIAL | | - * | | | | [view.1] | | - * +----------+------------------+ +----------+----------+ + * +----------+----------------------+ +------------+------------+ + * | | | | NORTH | | + * | INITIAL | RIGHT | | [view.101] | RIGHT | + * | [view.1] | [view.101, view.102] | => +------------+ [view.102] | + * | | | | INITIAL | | + * | | | | [view.1] | | + * +----------+----------------------+ +------------+------------+ */ test('should allow dragging a view to the north of another part', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - // Open view in the initial part. - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - const view2 = appPO.view({viewId: 'view.2'}); - const view3 = appPO.view({viewId: 'view.3'}); + const initialPartView = await workbenchNavigator.openInNewTab(ViewPagePO); + const initialPartId = await initialPartView.view.part.getPartId(); // Open two views in another part. - await layoutPage.addPart('xyz', {relativeTo: await layoutPage.view.part.getPartId(), align: 'right', ratio: .5}); - await layoutPage.addView('view.2', {partId: 'xyz', activateView: true}); - await layoutPage.addView('view.3', {partId: 'xyz'}); - - // Move view 2 to a new part in the north of the initial part. - await view2.tab.dragTo({partId: await layoutPage.view.part.getPartId(), region: 'north'}); - - // Expect view 2 to be moved to a new part in the north of the initial part. + await workbenchNavigator.modifyLayout(layout => layout + .addPart('right', {relativeTo: initialPartId, align: 'right', ratio: .5}) + .addView('view.101', {partId: 'right', activateView: true}) + .addView('view.102', {partId: 'right'}), + ); + + // Move view to a new part in the north of the initial part. + const testView = appPO.view({viewId: 'view.101'}); + await testView.tab.dragTo({partId: initialPartId, region: 'north'}); + const testViewInfo = await testView.getInfo(); + + // Expect view to be moved to a new part in the north of the initial part. await expect(appPO.workbench).toEqualWorkbenchLayout({ mainAreaGrid: { root: new MTreeNode({ @@ -514,53 +518,55 @@ test.describe('View Drag', () => { direction: 'column', ratio: .5, child1: new MPart({ - id: await view2.part.getPartId(), - views: [{id: 'view.2'}], - activeViewId: 'view.2', + id: testViewInfo.partId, + views: [{id: 'view.101'}], + activeViewId: 'view.101', }), child2: new MPart({ - id: await layoutPage.view.part.getPartId(), + id: initialPartId, views: [{id: 'view.1'}], activeViewId: 'view.1', }), }), child2: new MPart({ - id: await view3.part.getPartId(), - views: [{id: 'view.3'}], - activeViewId: 'view.3', + id: 'right', + views: [{id: 'view.102'}], + activeViewId: 'view.102', }), }), - activePartId: await view2.part.getPartId(), + activePartId: testViewInfo.partId, }, }); }); /** - * +----------+------------------+ +----------+----------+ - * | | | | INITIAL | | - * | INITIAL | XYZ | | [view.1] | XYZ | - * | [view.1] | [view.2, view.3] | => +----------+ [view.3] | - * | | | | SOUTH | | - * | | | | [view.2] | | - * +----------+------------------+ +----------+----------+ + * +----------+----------------------+ +------------+------------+ + * | | | | INITIAL | | + * | INITIAL | RIGHT | | [view.1] | RIGHT | + * | [view.1] | [view.101, view.102] | => +------------+ [view.102] | + * | | | | SOUTH | | + * | | | | [view.101] | | + * +----------+----------------------+ +------------+------------+ */ test('should allow dragging a view to the south of another part', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - // Open view in the initial part. - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - const view2 = appPO.view({viewId: 'view.2'}); - const view3 = appPO.view({viewId: 'view.3'}); + const initialPartView = await workbenchNavigator.openInNewTab(ViewPagePO); + const initialPartId = await initialPartView.view.part.getPartId(); // Open two views in another part. - await layoutPage.addPart('another', {relativeTo: await layoutPage.view.part.getPartId(), align: 'right', ratio: .5}); - await layoutPage.addView('view.2', {partId: 'another', activateView: true}); - await layoutPage.addView('view.3', {partId: 'another'}); - - // Move view 2 to a new part in the south of the initial part. - await view2.tab.dragTo({partId: await layoutPage.view.part.getPartId(), region: 'south'}); - - // Expect view 2 to be moved to a new part in the south of the initial part. + await workbenchNavigator.modifyLayout(layout => layout + .addPart('right', {relativeTo: initialPartId, align: 'right', ratio: .5}) + .addView('view.101', {partId: 'right', activateView: true}) + .addView('view.102', {partId: 'right'}), + ); + + // Move view to a new part in the south of the initial part. + const testView = appPO.view({viewId: 'view.101'}); + await testView.tab.dragTo({partId: initialPartId, region: 'south'}); + const testViewInfo = await testView.getInfo(); + + // Expect view to be moved to a new part in the south of the initial part. await expect(appPO.workbench).toEqualWorkbenchLayout({ mainAreaGrid: { root: new MTreeNode({ @@ -570,23 +576,23 @@ test.describe('View Drag', () => { direction: 'column', ratio: .5, child1: new MPart({ - id: await layoutPage.view.part.getPartId(), + id: initialPartId, views: [{id: 'view.1'}], activeViewId: 'view.1', }), child2: new MPart({ - id: await view2.part.getPartId(), - views: [{id: 'view.2'}], - activeViewId: 'view.2', + id: testViewInfo.partId, + views: [{id: 'view.101'}], + activeViewId: 'view.101', }), }), child2: new MPart({ - id: await view3.part.getPartId(), - views: [{id: 'view.3'}], - activeViewId: 'view.3', + id: 'right', + views: [{id: 'view.102'}], + activeViewId: 'view.102', }), }), - activePartId: await view2.part.getPartId(), + activePartId: testViewInfo.partId, }, }); }); @@ -597,26 +603,12 @@ test.describe('View Drag', () => { test('should drop view on start page of the main area (grid root is MPart)', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - // Create perspective with a left and right part. - const perspectivePage = await workbenchNavigator.openInNewTab(PerspectivePagePO); - await perspectivePage.registerPerspective({ - id: 'perspective', - parts: [ - {id: MAIN_AREA}, - {id: 'left', align: 'left'}, - ], - }); - await perspectivePage.view.tab.close(); - await appPO.switchPerspective('perspective'); - - // TODO [WB-LAYOUT] Open test view via perspective definition. - const routerPage = await workbenchNavigator.openInNewTab(RouterPagePO); - await routerPage.enterPath('test-view'); - await routerPage.enterCssClass('testee'); - await routerPage.enterTarget('blank'); - await routerPage.enterBlankPartId('left'); - await routerPage.clickNavigate(); - await routerPage.view.tab.close(); + await workbenchNavigator.createPerspective(factory => factory + .addPart(MAIN_AREA) + .addPart('left', {align: 'left'}) + .addView('testee', {partId: 'left', cssClass: 'testee'}) + .navigateView('testee', ['test-view']), + ); // Drop view on the start page of the main area. const testeeViewPage = new ViewPagePO(appPO, {cssClass: 'testee'}); @@ -630,33 +622,18 @@ test.describe('View Drag', () => { test('should drop view on start page of the main area (grid root is MTreeNode)', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - // Create perspective with a part left to the main area. - const perspectivePage = await workbenchNavigator.openInNewTab(PerspectivePagePO); - await perspectivePage.registerPerspective({ - id: 'perspective', - parts: [ - {id: MAIN_AREA}, - {id: 'left', align: 'left'}, - ], - }); - await perspectivePage.view.tab.close(); - await appPO.switchPerspective('perspective'); + await workbenchNavigator.createPerspective(factory => factory + .addPart(MAIN_AREA) + .addPart('left', {align: 'left'}) + .addView('testee', {partId: 'left', cssClass: 'testee'}) + .navigateView('testee', ['test-view']), + ); // Change the grid root of the main area to a `MTreeNode`. - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - const mainAreaActivePartId = await layoutPage.view.part.getPartId(); - await layoutPage.addPart('main-left', {relativeTo: mainAreaActivePartId, align: 'left'}); - await layoutPage.addPart('main-right', {relativeTo: mainAreaActivePartId, align: 'right'}); - await layoutPage.view.tab.close(); - - // TODO [WB-LAYOUT] Open test view via perspective definition. - const routerPage = await workbenchNavigator.openInNewTab(RouterPagePO); - await routerPage.enterPath('test-view'); - await routerPage.enterCssClass('testee'); - await routerPage.enterTarget('blank'); - await routerPage.enterBlankPartId('left'); - await routerPage.clickNavigate(); - await routerPage.view.tab.close(); + await workbenchNavigator.modifyLayout((layout, activePartId) => layout + .addPart('main-left', {relativeTo: activePartId, align: 'left'}) + .addPart('main-right', {relativeTo: activePartId, align: 'right'}), + ); // Drop view on the start page of the main area. const testeeViewPage = new ViewPagePO(appPO, {cssClass: 'testee'}); diff --git a/projects/scion/e2e-testing/src/workbench/view-not-found.e2e-spec.ts b/projects/scion/e2e-testing/src/workbench/view-not-found.e2e-spec.ts new file mode 100644 index 000000000..54044d562 --- /dev/null +++ b/projects/scion/e2e-testing/src/workbench/view-not-found.e2e-spec.ts @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2018-2024 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 {test} from '../fixtures'; +import {expect} from '@playwright/test'; +import {PageNotFoundPagePO} from './page-object/page-not-found-page.po'; +import {expectView} from '../matcher/view-matcher'; +import {BlankViewPagePO} from './page-object/blank-view-page.po'; +import {MAIN_AREA} from '../workbench.model'; +import {ConsoleLogs} from '../helper/console-logs'; +import {ViewPagePO} from './page-object/view-page.po'; + +test.describe('Workbench Page Not Found', () => { + + test('should display blank page when adding a view but not navigating it', async ({appPO, workbenchNavigator, consoleLogs}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + // Add view.101 in peripheral area + // Add view.102 in main area + await workbenchNavigator.modifyLayout((layout, activePartId) => layout + .addPart('left', {align: 'left'}) + .addView('view.101', {partId: 'left', activateView: true}) + .addView('view.102', {partId: activePartId}), + ); + + const viewPage1 = new BlankViewPagePO(appPO, {viewId: 'view.101'}); + const viewPage2 = new BlankViewPagePO(appPO, {viewId: 'view.102'}); + + await expectView(viewPage1).toBeActive(); + await expectView(viewPage2).toBeActive(); + + // Reload the application and expect the blank page to still be displayed. + await test.step('Reloading the application', async () => { + await appPO.reload(); + await expectView(viewPage1).toBeActive(); + await expectView(viewPage2).toBeActive(); + }); + + // Expect Angular router not to error. + await expect.poll(() => consoleLogs.get({severity: 'error'})).toHaveLength(0); + }); + + test('should display "Not Found Page" when navigating to an unknown path', async ({appPO, workbenchNavigator, consoleLogs}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + // Add view.101 in peripheral area + // Add view.102 in main area + await workbenchNavigator.modifyLayout((layout, activePartId) => layout + .addPart('left', {align: 'left'}) + .addView('view.101', {partId: 'left', activateView: true}) + .addView('view.102', {partId: activePartId}) + .navigateView('view.101', ['does/not/exist']) + .navigateView('view.102', ['does/not/exist']), + ); + + const viewPage1 = new PageNotFoundPagePO(appPO, {viewId: 'view.101'}); + const viewPage2 = new PageNotFoundPagePO(appPO, {viewId: 'view.102'}); + + await expectView(viewPage1).toBeActive(); + await expectView(viewPage2).toBeActive(); + + // Reload the application and expect the "Not Found Page" to still be displayed. + await test.step('Reloading the application', async () => { + await appPO.reload(); + await expectView(viewPage1).toBeActive(); + await expectView(viewPage2).toBeActive(); + }); + + // Expect Angular router not to error. + await expect.poll(() => consoleLogs.get({severity: 'error'})).toHaveLength(0); + }); + + test('should display "Not Found Page" when navigating with a hint that matches no route', async ({appPO, workbenchNavigator, consoleLogs}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + // Add view.101 in peripheral area + // Add view.102 in main area + await workbenchNavigator.modifyLayout((layout, activePartId) => layout + .addPart('left', {align: 'left'}) + .addView('view.101', {partId: 'left', activateView: true}) + .addView('view.102', {partId: activePartId}) + .navigateView('view.101', [], {hint: 'does-not-match'}) + .navigateView('view.102', [], {hint: 'does-not-match'}), + ); + + const viewPage1 = new PageNotFoundPagePO(appPO, {viewId: 'view.101'}); + const viewPage2 = new PageNotFoundPagePO(appPO, {viewId: 'view.102'}); + + await expectView(viewPage1).toBeActive(); + await expectView(viewPage2).toBeActive(); + + // Reload the application and expect the "Not Found Page" to still be displayed. + await test.step('Reloading the application', async () => { + await appPO.reload(); + await expectView(viewPage1).toBeActive(); + await expectView(viewPage2).toBeActive(); + }); + + // Expect Angular router not to error. + await expect.poll(() => consoleLogs.get({severity: 'error'})).toHaveLength(0); + }); + + test('should drag "Not Found Page" to another part', async ({appPO, workbenchNavigator, consoleLogs}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + const initialPartView = await workbenchNavigator.openInNewTab(ViewPagePO); + const initialPartId = await initialPartView.view.part.getPartId(); + + await workbenchNavigator.modifyLayout(layout => layout + .addPart('left', {align: 'left'}) + .addPart('right', {align: 'right'}) + .addView('view.101', {partId: 'left', activateView: true}) + .addView('view.102', {partId: 'right', activateView: true}) + .navigateView('view.101', ['does/not/exist']), + ); + + const viewPage = new PageNotFoundPagePO(appPO, {viewId: 'view.101'}); + + // Drag view to right part. + await viewPage.view.tab.dragTo({partId: 'right', region: 'center'}); + + // Except view to be moved to right part. + await expectView(viewPage).toBeActive(); + await expect.poll(() => viewPage.view.part.getPartId()).toEqual('right'); + + // Drag view to main area part + await viewPage.view.tab.dragTo({partId: initialPartId, region: 'center'}); + await expectView(viewPage).toBeActive(); + await expect.poll(() => viewPage.view.part.getPartId()).toEqual(initialPartId); + + // Expect Angular router not to error. + await expect.poll(() => consoleLogs.get({severity: 'error'})).toHaveLength(0); + }); + + test('should drag "Not Found Page" to a new window', async ({appPO, workbenchNavigator, consoleLogs}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + await workbenchNavigator.modifyLayout((layout, activePartId) => layout + .addPart('left', {align: 'left'}) + .addPart('right', {align: 'right'}) + .addView('testee-1', {partId: 'left', activateView: true, cssClass: 'testee-1'}) + .addView('testee-2', {partId: activePartId, cssClass: 'testee-2'}) + .addView('testee-3', {partId: 'right', cssClass: 'testee-3'}) + .navigateView('testee-1', ['does/not/exist']) + .navigateView('testee-2', ['does/not/exist']) + .navigateView('testee-3', [], {hint: 'does-not-match'}), + ); + + const viewPage1 = new PageNotFoundPagePO(appPO, {cssClass: 'testee-1'}); + const viewPage2 = new PageNotFoundPagePO(appPO, {cssClass: 'testee-2'}); + const viewPage3 = new PageNotFoundPagePO(appPO, {cssClass: 'testee-3'}); + + // Move testee-1 view to new window (into main area). + const newAppPO = await viewPage1.view.tab.moveToNewWindow(); + + // Expect testee-1 view to be moved to new window. + const newWindowViewPage1 = new PageNotFoundPagePO(newAppPO, {cssClass: 'testee-1'}); + await expectView(newWindowViewPage1).toBeActive(); + + // Move testee-2 view to existing window (into peripheral area). + await viewPage2.view.tab.moveTo(MAIN_AREA, {region: 'west', workbenchId: await newAppPO.getWorkbenchId()}); + + // Expect testee-2 view to be moved to existing window. + const newWindowViewPage2 = new PageNotFoundPagePO(newAppPO, {cssClass: 'testee-2'}); + await expectView(newWindowViewPage2).toBeActive(); + + // Move testee-3 view to existing window (into peripheral area). + await viewPage3.view.tab.moveTo(MAIN_AREA, {region: 'east', workbenchId: await newAppPO.getWorkbenchId()}); + + // Expect testee-3 view to be moved to existing window. + const newWindowViewPage3 = new PageNotFoundPagePO(newAppPO, {cssClass: 'testee-3'}); + await expectView(newWindowViewPage3).toBeActive(); + + // Expect Angular router not to error. + await expect.poll(() => consoleLogs.get({severity: 'error'})).toHaveLength(0); + await expect.poll(() => new ConsoleLogs(newAppPO.page).get({severity: 'error'})).toHaveLength(0); + }); +}); diff --git a/projects/scion/e2e-testing/src/workbench/view-tab-bar.e2e-spec.ts b/projects/scion/e2e-testing/src/workbench/view-tab-bar.e2e-spec.ts index 9434cc108..fc77743ef 100644 --- a/projects/scion/e2e-testing/src/workbench/view-tab-bar.e2e-spec.ts +++ b/projects/scion/e2e-testing/src/workbench/view-tab-bar.e2e-spec.ts @@ -14,9 +14,7 @@ import {StartPagePO} from '../start-page.po'; import {RouterPagePO} from './page-object/router-page.po'; import {ViewPagePO} from './page-object/view-page.po'; import {expectView} from '../matcher/view-matcher'; -import {PerspectivePagePO} from './page-object/perspective-page.po'; import {MPart, MTreeNode} from '../matcher/to-equal-workbench-layout.matcher'; -import {LayoutPagePO} from './page-object/layout-page.po'; test.describe('View Tabbar', () => { @@ -104,44 +102,32 @@ test.describe('View Tabbar', () => { test('should open new view to the right of the active view', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - // Register Angular routes. - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - await layoutPage.registerRoute({path: '', component: 'router-page', outlet: 'router'}); - - const perspectivePage = await workbenchNavigator.openInNewTab(PerspectivePagePO); - await perspectivePage.registerPerspective({ - id: 'perspective', - parts: [ - {id: 'left'}, - {id: 'right', align: 'right', activate: true}, - ], - views: [ - // Add views to the left part. - {id: 'view.1', partId: 'left'}, - {id: 'router', partId: 'left', activateView: true}, // TODO [WB-LAYOUT] Change to view.2 and navigate to router page - {id: 'view.3', partId: 'left'}, - {id: 'view.4', partId: 'left'}, - // Add views to the right part. - {id: 'view.5', partId: 'right', activateView: true}, - {id: 'view.6', partId: 'right'}, - ], - }); - await appPO.switchPerspective('perspective'); + await workbenchNavigator.createPerspective(factory => factory + .addPart('left') + .addPart('right', {align: 'right'}, {activate: true}) + .addView('view.1', {partId: 'left'}) + .addView('view.2', {partId: 'left', activateView: true}) + .addView('view.3', {partId: 'left'}) + .addView('view.4', {partId: 'left'}) + .addView('view.5', {partId: 'right', activateView: true}) + .addView('view.6', {partId: 'right'}) + .navigateView('view.2', ['test-router']), + ); // Open view in the active part (left part). - const routerPage = new RouterPagePO(appPO, {viewId: 'router'}); + const routerPage = new RouterPagePO(appPO, {viewId: 'view.2'}); await routerPage.enterPath('test-view'); await routerPage.enterTarget('blank'); await routerPage.clickNavigate(); - // Expect view.2 to be opened to the right of the active view. + // Expect view.7 (new view) to be opened to the right of the active view. await expect(appPO.workbench).toEqualWorkbenchLayout({ workbenchGrid: { root: new MTreeNode({ child1: new MPart({ id: 'left', - views: [{id: 'view.1'}, {id: 'router'}, {id: 'view.2'}, {id: 'view.3'}, {id: 'view.4'}], - activeViewId: 'view.2', + views: [{id: 'view.1'}, {id: 'view.2'}, {id: 'view.7'}, {id: 'view.3'}, {id: 'view.4'}], + activeViewId: 'view.7', }), child2: new MPart({ id: 'right', @@ -162,19 +148,19 @@ test.describe('View Tabbar', () => { await routerPage.enterBlankPartId('right'); await routerPage.clickNavigate(); - // Expect view.7 to be opened to the right of the active view. + // Expect view.8 (new view) to be opened to the right of the active view. await expect(appPO.workbench).toEqualWorkbenchLayout({ workbenchGrid: { root: new MTreeNode({ child1: new MPart({ id: 'left', - views: [{id: 'view.1'}, {id: 'router'}, {id: 'view.2'}, {id: 'view.3'}, {id: 'view.4'}], - activeViewId: 'router', + views: [{id: 'view.1'}, {id: 'view.2'}, {id: 'view.7'}, {id: 'view.3'}, {id: 'view.4'}], + activeViewId: 'view.2', }), child2: new MPart({ id: 'right', - views: [{id: 'view.5'}, {id: 'view.7'}, {id: 'view.6'}], - activeViewId: 'view.7', + views: [{id: 'view.5'}, {id: 'view.8'}, {id: 'view.6'}], + activeViewId: 'view.8', }), direction: 'row', ratio: .5, @@ -187,25 +173,16 @@ test.describe('View Tabbar', () => { test('should open view moved via drag & drop after the active view', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - const perspectivePage = await workbenchNavigator.openInNewTab(PerspectivePagePO); - await perspectivePage.registerPerspective({ - id: 'perspective', - parts: [ - {id: 'left'}, - {id: 'right', align: 'right', activate: true}, - ], - views: [ - // Add views to the left part. - {id: 'view.1', partId: 'left'}, - {id: 'view.2', partId: 'left'}, - {id: 'view.3', partId: 'left', activateView: true}, - {id: 'view.4', partId: 'left'}, - // Add views to the right part. - {id: 'view.5', partId: 'right', activateView: true}, - {id: 'view.6', partId: 'right'}, - ], - }); - await appPO.switchPerspective('perspective'); + await workbenchNavigator.createPerspective(factory => factory + .addPart('left') + .addPart('right', {align: 'right'}, {activate: true}) + .addView('view.1', {partId: 'left'}) + .addView('view.2', {partId: 'left'}) + .addView('view.3', {partId: 'left', activateView: true}) + .addView('view.4', {partId: 'left'}) + .addView('view.5', {partId: 'right', activateView: true}) + .addView('view.6', {partId: 'right'}), + ); // Move view.5 to the left part const view5 = appPO.view({viewId: 'view.5'}); @@ -236,20 +213,13 @@ test.describe('View Tabbar', () => { test('should activate the view to the left of the view that is dragged out of the tab bar', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - const perspectivePage = await workbenchNavigator.openInNewTab(PerspectivePagePO); - await perspectivePage.registerPerspective({ - id: 'perspective', - parts: [ - {id: 'part'}, - ], - views: [ - {id: 'view.1', partId: 'part'}, - {id: 'view.2', partId: 'part'}, - {id: 'view.3', partId: 'part', activateView: true}, - {id: 'view.4', partId: 'part'}, - ], - }); - await appPO.switchPerspective('perspective'); + await workbenchNavigator.createPerspective(factory => factory + .addPart('part') + .addView('view.1', {partId: 'part'}) + .addView('view.2', {partId: 'part'}) + .addView('view.3', {partId: 'part', activateView: true}) + .addView('view.4', {partId: 'part'}), + ); // Drag view.3 out of the tabbar. const view3 = appPO.view({viewId: 'view.3'}); @@ -271,20 +241,13 @@ test.describe('View Tabbar', () => { test('should not change the view order when dragging a view to its own part (noop)', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - const perspectivePage = await workbenchNavigator.openInNewTab(PerspectivePagePO); - await perspectivePage.registerPerspective({ - id: 'perspective', - parts: [ - {id: 'part'}, - ], - views: [ - {id: 'view.1', partId: 'part'}, - {id: 'view.2', partId: 'part'}, - {id: 'view.3', partId: 'part', activateView: true}, - {id: 'view.4', partId: 'part'}, - ], - }); - await appPO.switchPerspective('perspective'); + await workbenchNavigator.createPerspective(factory => factory + .addPart('part') + .addView('view.1', {partId: 'part'}) + .addView('view.2', {partId: 'part'}) + .addView('view.3', {partId: 'part', activateView: true}) + .addView('view.4', {partId: 'part'}), + ); // Drag view.3 to its own part. const view3 = appPO.view({viewId: 'view.3'}); @@ -306,20 +269,13 @@ test.describe('View Tabbar', () => { test('should cancel drag operation if pressing escape', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); - const perspectivePage = await workbenchNavigator.openInNewTab(PerspectivePagePO); - await perspectivePage.registerPerspective({ - id: 'perspective', - parts: [ - {id: 'part'}, - ], - views: [ - {id: 'view.1', partId: 'part'}, - {id: 'view.2', partId: 'part'}, - {id: 'view.3', partId: 'part', activateView: true}, - {id: 'view.4', partId: 'part'}, - ], - }); - await appPO.switchPerspective('perspective'); + await workbenchNavigator.createPerspective(factory => factory + .addPart('part') + .addView('view.1', {partId: 'part'}) + .addView('view.2', {partId: 'part'}) + .addView('view.3', {partId: 'part', activateView: true}) + .addView('view.4', {partId: 'part'}), + ); // Drag view.3 out of the tabbar. const view3 = appPO.view({viewId: 'view.3'}); 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 9e7234eae..bfc243b00 100644 --- a/projects/scion/e2e-testing/src/workbench/view.e2e-spec.ts +++ b/projects/scion/e2e-testing/src/workbench/view.e2e-spec.ts @@ -13,6 +13,7 @@ import {test} from '../fixtures'; import {RouterPagePO} from './page-object/router-page.po'; import {ViewPagePO} from './page-object/view-page.po'; import {expectView} from '../matcher/view-matcher'; +import {NavigationTestPagePO} from './page-object/test-pages/navigation-test-page.po'; test.describe('Workbench View', () => { @@ -41,6 +42,34 @@ test.describe('Workbench View', () => { await expect(viewPage.view.tab.title).toHaveText('title'); }); + test('should show title of inactive views when reloading the application', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + // open test view 1 + const routerPage = await workbenchNavigator.openInNewTab(RouterPagePO); + await routerPage.enterCommands(['test-pages/navigation-test-page', {title: 'view-1-title'}]); + await routerPage.enterTarget('view.101'); + await routerPage.clickNavigate(); + + // open test view 2 + await routerPage.view.tab.click(); + await routerPage.enterCommands(['test-pages/navigation-test-page', {title: 'view-2-title'}]); + await routerPage.enterTarget('view.102'); + await routerPage.clickNavigate(); + + const testee1ViewPage = new NavigationTestPagePO(appPO, {viewId: 'view.101'}); + const testee2ViewPage = new NavigationTestPagePO(appPO, {viewId: 'view.102'}); + + // reload the application + await appPO.reload(); + + await expectView(testee1ViewPage).toBeInactive(); + await expect(testee1ViewPage.view.tab.title).toHaveText('view-1-title'); + + await expectView(testee2ViewPage).toBeActive(); + await expect(testee2ViewPage.view.tab.title).toHaveText('view-2-title'); + }); + test('should allow updating the view tab heading', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); await appPO.setDesignToken('--sci-workbench-tab-height', '3.5rem'); @@ -129,9 +158,8 @@ test.describe('Workbench View', () => { // Update matrix params (does not affect routing) await routerPage.view.tab.click(); - await routerPage.enterPath('test-view'); + await routerPage.enterCommands(['test-view', {matrixParam: 'value'}]); await routerPage.enterTarget(await viewPage.view.getViewId()); - await routerPage.enterMatrixParams({matrixParam: 'value'}); await routerPage.clickNavigate(); // Expect the view to still be dirty @@ -153,9 +181,8 @@ test.describe('Workbench View', () => { // Update matrix params (does not affect routing) await routerPage.view.tab.click(); - await routerPage.enterPath('test-view'); + await routerPage.enterCommands(['test-view', {matrixParam: 'value'}]); await routerPage.enterTarget(await viewPage.view.getViewId()); - await routerPage.enterMatrixParams({matrixParam: 'value'}); await routerPage.clickNavigate(); // Expect the title has not changed @@ -178,8 +205,7 @@ test.describe('Workbench View', () => { // Update matrix params (does not affect routing) await routerPage.view.tab.click(); - await routerPage.enterPath('test-view'); - await routerPage.enterMatrixParams({matrixParam: 'value'}); + await routerPage.enterCommands(['test-view', {matrixParam: 'value'}]); await routerPage.clickNavigate(); // Expect the heading has not changed @@ -368,4 +394,26 @@ test.describe('Workbench View', () => { // Expect view 2 not to be instantiated anew await expect.poll(() => viewPage2.getComponentInstanceId()).toEqual(view2ComponentId); }); + + test('should not destroy the component of the view when it is inactivated', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + const routerPage = await workbenchNavigator.openInNewTab(RouterPagePO); + const viewPage = await workbenchNavigator.openInNewTab(ViewPagePO); + + const componentInstanceId = await viewPage.getComponentInstanceId(); + + // activate the router test view + await routerPage.view.tab.click(); + await expectView(routerPage).toBeActive(); + await expectView(viewPage).toBeInactive(); + + // activate the test view + await viewPage.view.tab.click(); + await expectView(viewPage).toBeActive(); + await expectView(routerPage).toBeInactive(); + + // expect the component not to be constructed anew + await expect.poll(() => viewPage.getComponentInstanceId()).toEqual(componentInstanceId); + }); }); diff --git a/projects/scion/e2e-testing/src/workbench/workbench-layout-migration.e2e-spec.ts b/projects/scion/e2e-testing/src/workbench/workbench-layout-migration.e2e-spec.ts index da8893159..4b97c47e0 100644 --- a/projects/scion/e2e-testing/src/workbench/workbench-layout-migration.e2e-spec.ts +++ b/projects/scion/e2e-testing/src/workbench/workbench-layout-migration.e2e-spec.ts @@ -10,9 +10,12 @@ import {expect} from '@playwright/test'; import {test} from '../fixtures'; -import {WorkenchStartupQueryParams} from '../app.po'; import {MPart, MTreeNode} from '../matcher/to-equal-workbench-layout.matcher'; import {MAIN_AREA} from '../workbench.model'; +import {expectView} from '../matcher/view-matcher'; +import {ViewPagePO} from './page-object/view-page.po'; +import {RouterPagePO} from './page-object/router-page.po'; +import {ViewInfo} from './page-object/view-info-dialog.po'; test.describe('Workbench Layout Migration', () => { @@ -23,9 +26,11 @@ test.describe('Workbench Layout Migration', () => { * | Active View: view.1 | Active View: view.3 | * +--------------------------------------------+--------------------------------------------+ */ - test('should migrate workbench layout v1 to the latest version', async ({page, appPO}) => { - await page.goto(`/?${WorkenchStartupQueryParams.STANDALONE}=true/#/(view.3:test-view//view.2:test-view//view.1:test-view)?parts=eyJyb290Ijp7Im5vZGVJZCI6IjhkMWQ4MzA1LTgxYzItNDllOC05NWE3LWFlYjNlODM1ODFhMSIsImNoaWxkMSI6eyJ2aWV3SWRzIjpbInZpZXcuMSJdLCJwYXJ0SWQiOiIzOGY5MTU0MS03ZmRjLTRjNzEtYmVjMi0xZDVhZDc1MjNiZWUiLCJhY3RpdmVWaWV3SWQiOiJ2aWV3LjEifSwiY2hpbGQyIjp7InZpZXdJZHMiOlsidmlldy4yIiwidmlldy4zIl0sInBhcnRJZCI6ImZlZDM4MDExLTY2YjctNDZjZC1iYjQyLTMwY2U2ZjBmODA3MSIsImFjdGl2ZVZpZXdJZCI6InZpZXcuMyJ9LCJkaXJlY3Rpb24iOiJyb3ciLCJyYXRpbyI6MC41fSwiYWN0aXZlUGFydElkIjoiMzhmOTE1NDEtN2ZkYy00YzcxLWJlYzItMWQ1YWQ3NTIzYmVlIiwidXVpZCI6IjFlMjIzN2U1LWE3MzAtNDk1NC1iYWJmLWNkMzRjMjM3OWI1ZSJ9`); - await appPO.waitUntilWorkbenchStarted(); + test('should migrate workbench layout v1 to the latest version', async ({appPO}) => { + await appPO.navigateTo({ + url: '#/(view.3:test-view//view.2:test-view//view.1:test-view)?parts=eyJyb290Ijp7Im5vZGVJZCI6IjhkMWQ4MzA1LTgxYzItNDllOC05NWE3LWFlYjNlODM1ODFhMSIsImNoaWxkMSI6eyJ2aWV3SWRzIjpbInZpZXcuMSJdLCJwYXJ0SWQiOiIzOGY5MTU0MS03ZmRjLTRjNzEtYmVjMi0xZDVhZDc1MjNiZWUiLCJhY3RpdmVWaWV3SWQiOiJ2aWV3LjEifSwiY2hpbGQyIjp7InZpZXdJZHMiOlsidmlldy4yIiwidmlldy4zIl0sInBhcnRJZCI6ImZlZDM4MDExLTY2YjctNDZjZC1iYjQyLTMwY2U2ZjBmODA3MSIsImFjdGl2ZVZpZXdJZCI6InZpZXcuMyJ9LCJkaXJlY3Rpb24iOiJyb3ciLCJyYXRpbyI6MC41fSwiYWN0aXZlUGFydElkIjoiMzhmOTE1NDEtN2ZkYy00YzcxLWJlYzItMWQ1YWQ3NTIzYmVlIiwidXVpZCI6IjFlMjIzN2U1LWE3MzAtNDk1NC1iYWJmLWNkMzRjMjM3OWI1ZSJ9', + microfrontendSupport: false, + }); await expect(appPO.workbench).toEqualWorkbenchLayout({ workbenchGrid: { @@ -54,5 +59,174 @@ test.describe('Workbench Layout Migration', () => { activePartId: '38f91541-7fdc-4c71-bec2-1d5ad7523bee', }, }); + + const viewPage1 = new ViewPagePO(appPO, {viewId: 'view.1'}); + await expectView(viewPage1).toBeActive(); + + const viewPage2 = new ViewPagePO(appPO, {viewId: 'view.2'}); + await expectView(viewPage2).toBeInactive(); + + const viewPage3 = new ViewPagePO(appPO, {viewId: 'view.3'}); + await expectView(viewPage3).toBeActive(); + + await expect.poll(() => viewPage1.view.getInfo()).toMatchObject( + { + routeData: {path: 'test-view', navigationHint: ''}, + } satisfies Partial, + ); + + await expect.poll(() => viewPage2.view.getInfo()).toMatchObject( + { + routeData: {path: 'test-view', navigationHint: ''}, + } satisfies Partial, + ); + + await expect.poll(() => viewPage3.view.getInfo()).toMatchObject( + { + routeData: {path: 'test-view', navigationHint: ''}, + } satisfies Partial, + ); + }); + + /** + * ## Given layout in version 2: + * + * PERIPHERAL AREA MAIN AREA PERIPHERAL AREA + * +--------------------------------------------+ +--------------------------------------------+ +--------------------------------------------+ + * | Part: 33b22f60-bf34-4704-885d-7de0d707430f | | Part: a25eb4cf-9da7-43e7-8db2-302fd38e59a1 | | Part: 9bc4c09f-67a7-4c69-a28b-532781a1c98f | + * | Views: [view.3] | | Views: [view.1, test-view] | | Views: [test-router] | + * | Active View: view.3 | | Active View: test-view | | Active View: test-router | + * | | +--------------------------------------------+ | | + * | | | Part: 2b534d97-ed7d-43b3-bb2c-0e59d9766e86 | | | + * | | | Views: [view.2] | | | + * | | | Active View: view.2 | | | + * +--------------------------------------------+ +--------------------------------------------+ +--------------------------------------------+ + * view.1: [path='test-view'] + * view.2: [path='test-view'] + * test-view: [path='', outlet='test-view'] + * test-router: [path='', outlet='test-router'] + * view.3: [path='test-view'] + * + * ## Migrated layout: + * + * PERIPHERAL AREA MAIN AREA PERIPHERAL AREA + * +--------------------------------------------+ +--------------------------------------------+ +--------------------------------------------+ + * | Part: 33b22f60-bf34-4704-885d-7de0d707430f | | Part: a25eb4cf-9da7-43e7-8db2-302fd38e59a1 | | Part: 9bc4c09f-67a7-4c69-a28b-532781a1c98f | + * | Views: [view.3] | | Views: [view.1, view.4] | | Views: [view.5] | + * | Active View: view.3 | | Active View: view.4 | | Active View: view.5 | + * | | +--------------------------------------------+ | | + * | | | Part: 2b534d97-ed7d-43b3-bb2c-0e59d9766e86 | | | + * | | | Views: [view.2] | | | + * | | | Active View: view.2 | | | + * +--------------------------------------------+ +--------------------------------------------+ +--------------------------------------------+ + * view.1: [path='test-view'] + * view.2: [path='test-view'] + * view.3: [path='test-view'] + * view.4: [path='', navigationHint='test-view'] + * view.5: [path='', navigationHint='test-router'] + */ + test('should migrate workbench layout v2 to the latest version', async ({appPO}) => { + await appPO.navigateTo({ + url: '#/(view.1:test-view//view.2:test-view//view.3:test-view)?main_area=eyJyb290Ijp7InR5cGUiOiJNVHJlZU5vZGUiLCJjaGlsZDEiOnsidHlwZSI6Ik1QYXJ0Iiwidmlld3MiOlt7ImlkIjoidmlldy4xIn0seyJpZCI6InRlc3QtdmlldyJ9XSwiaWQiOiJhMjVlYjRjZi05ZGE3LTQzZTctOGRiMi0zMDJmZDM4ZTU5YTEiLCJzdHJ1Y3R1cmFsIjpmYWxzZSwiYWN0aXZlVmlld0lkIjoidGVzdC12aWV3In0sImNoaWxkMiI6eyJ0eXBlIjoiTVBhcnQiLCJ2aWV3cyI6W3siaWQiOiJ2aWV3LjIifV0sImlkIjoiMmI1MzRkOTctZWQ3ZC00M2IzLWJiMmMtMGU1OWQ5NzY2ZTg2Iiwic3RydWN0dXJhbCI6ZmFsc2UsImFjdGl2ZVZpZXdJZCI6InZpZXcuMiJ9LCJkaXJlY3Rpb24iOiJjb2x1bW4iLCJyYXRpbyI6MC41fSwiYWN0aXZlUGFydElkIjoiYTI1ZWI0Y2YtOWRhNy00M2U3LThkYjItMzAyZmQzOGU1OWExIn0vLzI%3D', + microfrontendSupport: false, + localStorage: { + 'scion.workbench.perspective': 'empty', + 'scion.workbench.perspectives.empty': 'eyJpbml0aWFsV29ya2JlbmNoR3JpZCI6ImV5SnliMjkwSWpwN0luUjVjR1VpT2lKTlVHRnlkQ0lzSW5acFpYZHpJanBiWFN3aWFXUWlPaUp0WVdsdUxXRnlaV0VpTENKemRISjFZM1IxY21Gc0lqcDBjblZsZlN3aVlXTjBhWFpsVUdGeWRFbGtJam9pYldGcGJpMWhjbVZoSW4wdkx6ST0iLCJ3b3JrYmVuY2hHcmlkIjoiZXlKeWIyOTBJanA3SW5SNWNHVWlPaUpOVkhKbFpVNXZaR1VpTENKamFHbHNaREVpT25zaWRIbHdaU0k2SWsxVWNtVmxUbTlrWlNJc0ltTm9hV3hrTVNJNmV5SjBlWEJsSWpvaVRWQmhjblFpTENKMmFXVjNjeUk2VzNzaWFXUWlPaUoyYVdWM0xqTWlmVjBzSW1sa0lqb2lNek5pTWpKbU5qQXRZbVl6TkMwME56QTBMVGc0TldRdE4yUmxNR1EzTURjME16Qm1JaXdpYzNSeWRXTjBkWEpoYkNJNlptRnNjMlVzSW1GamRHbDJaVlpwWlhkSlpDSTZJblpwWlhjdU15SjlMQ0pqYUdsc1pESWlPbnNpZEhsd1pTSTZJazFRWVhKMElpd2lkbWxsZDNNaU9sdGRMQ0pwWkNJNkltMWhhVzR0WVhKbFlTSXNJbk4wY25WamRIVnlZV3dpT25SeWRXVjlMQ0prYVhKbFkzUnBiMjRpT2lKeWIzY2lMQ0p5WVhScGJ5STZNQzR5ZlN3aVkyaHBiR1F5SWpwN0luUjVjR1VpT2lKTlVHRnlkQ0lzSW5acFpYZHpJanBiZXlKcFpDSTZJblJsYzNRdGNtOTFkR1Z5SW4xZExDSnBaQ0k2SWpsaVl6UmpNRGxtTFRZM1lUY3ROR00yT1MxaE1qaGlMVFV6TWpjNE1XRXhZems0WmlJc0luTjBjblZqZEhWeVlXd2lPbVpoYkhObExDSmhZM1JwZG1WV2FXVjNTV1FpT2lKMFpYTjBMWEp2ZFhSbGNpSjlMQ0prYVhKbFkzUnBiMjRpT2lKeWIzY2lMQ0p5WVhScGJ5STZNQzQ0ZlN3aVlXTjBhWFpsVUdGeWRFbGtJam9pTXpOaU1qSm1OakF0WW1Zek5DMDBOekEwTFRnNE5XUXROMlJsTUdRM01EYzBNekJtSW4wdkx6ST0iLCJ2aWV3T3V0bGV0cyI6eyJ2aWV3LjMiOlsidGVzdC12aWV3Il19fQ==', + }, + }); + + await expect(appPO.workbench).toEqualWorkbenchLayout({ + workbenchGrid: { + root: new MTreeNode({ + direction: 'row', + ratio: .8, + child1: new MTreeNode({ + direction: 'row', + ratio: .2, + child1: new MPart({ + id: '33b22f60-bf34-4704-885d-7de0d707430f', + views: [{id: 'view.3'}], + activeViewId: 'view.3', + }), + child2: new MPart({ + id: MAIN_AREA, + }), + }), + child2: new MPart({ + id: '9bc4c09f-67a7-4c69-a28b-532781a1c98f', + views: [{id: 'view.5'}], + activeViewId: 'view.5', + }), + }), + activePartId: '33b22f60-bf34-4704-885d-7de0d707430f', + }, + mainAreaGrid: { + root: new MTreeNode({ + direction: 'column', + ratio: .5, + child1: new MPart({ + id: 'a25eb4cf-9da7-43e7-8db2-302fd38e59a1', + views: [{id: 'view.1'}, {id: 'view.4'}], + activeViewId: 'view.4', + }), + child2: new MPart({ + id: '2b534d97-ed7d-43b3-bb2c-0e59d9766e86', + views: [{id: 'view.2'}], + activeViewId: 'view.2', + }), + }), + activePartId: 'a25eb4cf-9da7-43e7-8db2-302fd38e59a1', + }, + }); + + const viewPage1 = new ViewPagePO(appPO, {viewId: 'view.1'}); + await viewPage1.view.tab.click(); + await expectView(viewPage1).toBeActive(); + await expect.poll(() => viewPage1.view.getInfo()).toMatchObject( + { + routeData: {path: 'test-view', navigationHint: ''}, + urlSegments: 'test-view', + } satisfies Partial, + ); + + const viewPage2 = new ViewPagePO(appPO, {viewId: 'view.2'}); + await viewPage2.view.tab.click(); + await expectView(viewPage2).toBeActive(); + await expect.poll(() => viewPage2.view.getInfo()).toMatchObject( + { + routeData: {path: 'test-view', navigationHint: ''}, + urlSegments: 'test-view', + } satisfies Partial, + ); + + const viewPage3 = new ViewPagePO(appPO, {viewId: 'view.3'}); + await viewPage3.view.tab.click(); + await expectView(viewPage3).toBeActive(); + await expect.poll(() => viewPage3.view.getInfo()).toMatchObject( + { + routeData: {path: 'test-view', navigationHint: ''}, + urlSegments: 'test-view', + } satisfies Partial, + ); + + const viewPage4 = new ViewPagePO(appPO, {viewId: 'view.4'}); + await viewPage4.view.tab.click(); + await expectView(viewPage4).toBeActive(); + await expect.poll(() => viewPage4.view.getInfo()).toMatchObject( + { + routeData: {path: '', navigationHint: 'test-view'}, + urlSegments: '', + } satisfies Partial, + ); + + const viewPage5 = new RouterPagePO(appPO, {viewId: 'view.5'}); + await viewPage5.view.tab.click(); + await expectView(viewPage5).toBeActive(); + await expect.poll(() => viewPage5.view.getInfo()).toMatchObject( + { + routeData: {path: '', navigationHint: 'test-router'}, + urlSegments: '', + } satisfies Partial, + ); }); }); diff --git a/projects/scion/e2e-testing/src/workbench/workbench-navigator.ts b/projects/scion/e2e-testing/src/workbench/workbench-navigator.ts index 6b2d92208..e7da36999 100644 --- a/projects/scion/e2e-testing/src/workbench/workbench-navigator.ts +++ b/projects/scion/e2e-testing/src/workbench/workbench-navigator.ts @@ -14,9 +14,9 @@ import {NotificationOpenerPagePO} from './page-object/notification-opener-page.p import {PopupOpenerPagePO} from './page-object/popup-opener-page.po'; 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 {LayoutPagePO} from './page-object/layout-page/layout-page.po'; import {DialogOpenerPagePO} from './page-object/dialog-opener-page.po'; +import {WorkbenchLayout, WorkbenchLayoutFactory} from '@scion/workbench'; export interface Type extends Function { // eslint-disable-line @typescript-eslint/ban-types new(...args: any[]): T; @@ -54,10 +54,6 @@ export class WorkbenchNavigator { * Opens the page to change the layout in a new workbench tab. */ public openInNewTab(page: Type): Promise; - /** - * Opens the page to register a perspective in a new workbench tab. - */ - public openInNewTab(page: Type): Promise; /** * Opens the page to inspect view properties in a new workbench tab. */ @@ -92,10 +88,6 @@ export class WorkbenchNavigator { await startPage.openWorkbenchView('e2e-test-layout'); return new LayoutPagePO(this._appPO, {viewId, cssClass: 'e2e-test-layout'}); } - case PerspectivePagePO: { - await startPage.openWorkbenchView('e2e-test-perspective'); - return new PerspectivePagePO(this._appPO, {viewId, cssClass: 'e2e-test-perspective'}); - } case ViewPagePO: { await startPage.openWorkbenchView('e2e-test-view'); return new ViewPagePO(this._appPO, {viewId, cssClass: 'e2e-test-view'}); @@ -105,4 +97,30 @@ export class WorkbenchNavigator { } } } + + /** + * Creates a perspective and activates it. + * + * @see WorkbenchService.registerPerspective + * @see WorkbenchService.switchPerspective + */ + public async createPerspective(defineLayoutFn: (factory: WorkbenchLayoutFactory) => WorkbenchLayout): Promise { + const id = crypto.randomUUID(); + const layoutPage = await this.openInNewTab(LayoutPagePO); + await layoutPage.createPerspective(id, {layout: defineLayoutFn}); + await layoutPage.view.tab.close(); + await this._appPO.switchPerspective(id); + return id; + } + + /** + * Modifies the current workbench layout. + * + * @see WorkbenchRouter.ɵnavigate + */ + public async modifyLayout(modifyLayoutFn: (layout: WorkbenchLayout, activePartId: string) => WorkbenchLayout): Promise { + const layoutPage = await this.openInNewTab(LayoutPagePO); + await layoutPage.modifyLayout(modifyLayoutFn); + await layoutPage.view.tab.close(); + } } diff --git a/projects/scion/e2e-testing/src/workbench/workbench-part-action.e2e-spec.ts b/projects/scion/e2e-testing/src/workbench/workbench-part-action.e2e-spec.ts index 38016ece0..9ab45719a 100644 --- a/projects/scion/e2e-testing/src/workbench/workbench-part-action.e2e-spec.ts +++ b/projects/scion/e2e-testing/src/workbench/workbench-part-action.e2e-spec.ts @@ -11,31 +11,23 @@ import {expect} from '@playwright/test'; import {test} from '../fixtures'; import {ViewPagePO} from './page-object/view-page.po'; -import {LayoutPagePO} from './page-object/layout-page.po'; +import {LayoutPagePO} from './page-object/layout-page/layout-page.po'; test.describe('Workbench Part Action', () => { test('should contribute action to every part', async ({appPO, workbenchNavigator}) => { - await appPO.navigateTo({microfrontendSupport: false, perspectives: ['perspective']}); - - // Switch perspective - await appPO.switchPerspective('perspective'); + await appPO.navigateTo({microfrontendSupport: false}); - // Prepare layout - // +------+-----------+-------+ - // | left | main-area | right | - // +------+-----------+-------+ - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - await layoutPage.addPart('left', {align: 'left', ratio: .25}); - await layoutPage.addPart('right', {align: 'right', ratio: .25}); - await layoutPage.addView('view-1', {partId: 'left', activateView: true}); - await layoutPage.addView('view-2', {partId: 'right', activateView: true}); - await layoutPage.registerRoute({path: '', outlet: 'view-1', component: 'view-page'}, {title: 'View 1'}); - await layoutPage.registerRoute({path: '', outlet: 'view-2', component: 'view-page'}, {title: 'View 2'}); + await workbenchNavigator.modifyLayout(layout => layout + .addPart('left', {align: 'left', ratio: .25}) + .addPart('right', {align: 'right', ratio: .25}) + .addView('view.101', {partId: 'left', activateView: true}) + .addView('view.102', {partId: 'right', activateView: true}), + ); // Open page in main area - const viewPage = await workbenchNavigator.openInNewTab(ViewPagePO); - const mainPartId = await viewPage.view.part.getPartId(); + const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); + const initialPartId = await layoutPage.view.part.getPartId(); // Register action await layoutPage.registerPartAction('Action', {cssClass: 'e2e-action'}); @@ -43,30 +35,22 @@ test.describe('Workbench Part Action', () => { // Expect the action to be displayed in every part await expect(appPO.part({partId: 'left'}).action({cssClass: 'e2e-action'}).locator).toBeVisible(); await expect(appPO.part({partId: 'right'}).action({cssClass: 'e2e-action'}).locator).toBeVisible(); - await expect(appPO.part({partId: mainPartId}).action({cssClass: 'e2e-action'}).locator).toBeVisible(); + await expect(appPO.part({partId: initialPartId}).action({cssClass: 'e2e-action'}).locator).toBeVisible(); }); test('should contribute action to parts in the workbench grid', async ({appPO, workbenchNavigator}) => { - await appPO.navigateTo({microfrontendSupport: false, perspectives: ['perspective']}); - - // Switch perspective - await appPO.switchPerspective('perspective'); + await appPO.navigateTo({microfrontendSupport: false}); - // Prepare layout - // +------+-----------+-------+ - // | left | main-area | right | - // +------+-----------+-------+ - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - await layoutPage.addPart('left', {align: 'left', ratio: .25}); - await layoutPage.addPart('right', {align: 'right', ratio: .25}); - await layoutPage.addView('view-1', {partId: 'left', activateView: true}); - await layoutPage.addView('view-2', {partId: 'right', activateView: true}); - await layoutPage.registerRoute({path: '', outlet: 'view-1', component: 'view-page'}, {title: 'View 1'}); - await layoutPage.registerRoute({path: '', outlet: 'view-2', component: 'view-page'}, {title: 'View 2'}); + await workbenchNavigator.modifyLayout(layout => layout + .addPart('left', {align: 'left', ratio: .25}) + .addPart('right', {align: 'right', ratio: .25}) + .addView('view.101', {partId: 'left', activateView: true}) + .addView('view.102', {partId: 'right', activateView: true}), + ); // Open page in main area - const viewPage = await workbenchNavigator.openInNewTab(ViewPagePO); - const mainPartId = await viewPage.view.part.getPartId(); + const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); + const initialPartId = await layoutPage.view.part.getPartId(); // Register action await layoutPage.registerPartAction('Action', {grid: 'workbench', cssClass: 'e2e-action'}); @@ -74,30 +58,22 @@ test.describe('Workbench Part Action', () => { // Expect the action to be displayed in all parts of the workbench grid await expect(appPO.part({partId: 'left'}).action({cssClass: 'e2e-action'}).locator).toBeVisible(); await expect(appPO.part({partId: 'right'}).action({cssClass: 'e2e-action'}).locator).toBeVisible(); - await expect(appPO.part({partId: mainPartId}).action({cssClass: 'e2e-action'}).locator).not.toBeAttached(); + await expect(appPO.part({partId: initialPartId}).action({cssClass: 'e2e-action'}).locator).not.toBeAttached(); }); test('should contribute action to specific part(s)', async ({appPO, workbenchNavigator}) => { - await appPO.navigateTo({microfrontendSupport: false, perspectives: ['perspective']}); + await appPO.navigateTo({microfrontendSupport: false}); - // Switch perspective - await appPO.switchPerspective('perspective'); - - // Prepare layout - // +------+-----------+-------+ - // | left | main-area | right | - // +------+-----------+-------+ - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - await layoutPage.addPart('left', {align: 'left', ratio: .25}); - await layoutPage.addPart('right', {align: 'right', ratio: .25}); - await layoutPage.addView('view-1', {partId: 'left', activateView: true}); - await layoutPage.addView('view-2', {partId: 'right', activateView: true}); - await layoutPage.registerRoute({path: '', outlet: 'view-1', component: 'view-page'}, {title: 'View 1'}); - await layoutPage.registerRoute({path: '', outlet: 'view-2', component: 'view-page'}, {title: 'View 2'}); + await workbenchNavigator.modifyLayout(layout => layout + .addPart('left', {align: 'left', ratio: .25}) + .addPart('right', {align: 'right', ratio: .25}) + .addView('view.101', {partId: 'left', activateView: true}) + .addView('view.102', {partId: 'right', activateView: true}), + ); // Open page in main area - const viewPage = await workbenchNavigator.openInNewTab(ViewPagePO); - const mainPartId = await viewPage.view.part.getPartId(); + const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); + const initialPartId = await layoutPage.view.part.getPartId(); await test.step('register action in left part', async () => { await layoutPage.registerPartAction('Action 1', {partId: 'left', cssClass: 'e2e-action-1'}); @@ -105,7 +81,7 @@ test.describe('Workbench Part Action', () => { // Expect the action-1 to be displayed only in the left part await expect(appPO.part({partId: 'left'}).action({cssClass: 'e2e-action-1'}).locator).toBeVisible(); await expect(appPO.part({partId: 'right'}).action({cssClass: 'e2e-action-1'}).locator).not.toBeAttached(); - await expect(appPO.part({partId: mainPartId}).action({cssClass: 'e2e-action-1'}).locator).not.toBeAttached(); + await expect(appPO.part({partId: initialPartId}).action({cssClass: 'e2e-action-1'}).locator).not.toBeAttached(); }); await test.step('register action in left and right part', async () => { @@ -114,192 +90,175 @@ test.describe('Workbench Part Action', () => { // Expect the action-1 to be displayed only in the left part await expect(appPO.part({partId: 'left'}).action({cssClass: 'e2e-action-1'}).locator).toBeVisible(); await expect(appPO.part({partId: 'right'}).action({cssClass: 'e2e-action-1'}).locator).not.toBeAttached(); - await expect(appPO.part({partId: mainPartId}).action({cssClass: 'e2e-action-1'}).locator).not.toBeAttached(); + await expect(appPO.part({partId: initialPartId}).action({cssClass: 'e2e-action-1'}).locator).not.toBeAttached(); // Expect the action-2 to be displayed in the left and right parts await expect(appPO.part({partId: 'left'}).action({cssClass: 'e2e-action-2'}).locator).toBeVisible(); await expect(appPO.part({partId: 'right'}).action({cssClass: 'e2e-action-2'}).locator).toBeVisible(); - await expect(appPO.part({partId: mainPartId}).action({cssClass: 'e2e-action-2'}).locator).not.toBeAttached(); + await expect(appPO.part({partId: initialPartId}).action({cssClass: 'e2e-action-2'}).locator).not.toBeAttached(); }); - await test.step('register action in main part', async () => { - await layoutPage.registerPartAction('Action 3', {partId: mainPartId, cssClass: 'e2e-action-3'}); + await test.step('register action in initial part', async () => { + await layoutPage.registerPartAction('Action 3', {partId: initialPartId, cssClass: 'e2e-action-3'}); // Expect the action-1 to be displayed only in the left part await expect(appPO.part({partId: 'left'}).action({cssClass: 'e2e-action-1'}).locator).toBeVisible(); await expect(appPO.part({partId: 'right'}).action({cssClass: 'e2e-action-1'}).locator).not.toBeAttached(); - await expect(appPO.part({partId: mainPartId}).action({cssClass: 'e2e-action-1'}).locator).not.toBeAttached(); + await expect(appPO.part({partId: initialPartId}).action({cssClass: 'e2e-action-1'}).locator).not.toBeAttached(); // Expect the action-2 to be displayed in the left and right parts await expect(appPO.part({partId: 'left'}).action({cssClass: 'e2e-action-2'}).locator).toBeVisible(); await expect(appPO.part({partId: 'right'}).action({cssClass: 'e2e-action-2'}).locator).toBeVisible(); - await expect(appPO.part({partId: mainPartId}).action({cssClass: 'e2e-action-2'}).locator).not.toBeAttached(); + await expect(appPO.part({partId: initialPartId}).action({cssClass: 'e2e-action-2'}).locator).not.toBeAttached(); - // Expect the action-3 to be displayed only in the main part + // Expect the action-3 to be displayed only in the initial part await expect(appPO.part({partId: 'left'}).action({cssClass: 'e2e-action-3'}).locator).not.toBeAttached(); await expect(appPO.part({partId: 'right'}).action({cssClass: 'e2e-action-3'}).locator).not.toBeAttached(); - await expect(appPO.part({partId: mainPartId}).action({cssClass: 'e2e-action-3'}).locator).toBeVisible(); + await expect(appPO.part({partId: initialPartId}).action({cssClass: 'e2e-action-3'}).locator).toBeVisible(); }); }); test('should contribute action to specific views(s)', async ({appPO, workbenchNavigator}) => { - await appPO.navigateTo({microfrontendSupport: false, perspectives: ['perspective']}); + await appPO.navigateTo({microfrontendSupport: false}); - // Switch perspective - await appPO.switchPerspective('perspective'); + await workbenchNavigator.modifyLayout(layout => layout + .addPart('left', {align: 'left', ratio: .25}) + .addPart('right', {align: 'right', ratio: .25}) + .addView('view.101', {partId: 'left', activateView: true}) + .addView('view.102', {partId: 'left'}) + .addView('view.103', {partId: 'right', activateView: true}) + .addView('view.104', {partId: 'right'}), + ); - // Prepare layout - // +------+-----------+-------+ - // | left | main-area | right | - // +------+-----------+-------+ + // Open pages in main area const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - await layoutPage.addPart('left', {align: 'left', ratio: .25}); - await layoutPage.addPart('right', {align: 'right', ratio: .25}); - await layoutPage.addView('view-1', {partId: 'left', activateView: true}); - await layoutPage.addView('view-2', {partId: 'left'}); - await layoutPage.addView('view-3', {partId: 'right', activateView: true}); - await layoutPage.addView('view-4', {partId: 'right'}); - await layoutPage.registerRoute({path: '', outlet: 'view-1', component: 'view-page'}, {title: 'View 1'}); - await layoutPage.registerRoute({path: '', outlet: 'view-2', component: 'view-page'}, {title: 'View 2'}); - await layoutPage.registerRoute({path: '', outlet: 'view-3', component: 'view-page'}, {title: 'View 3'}); - await layoutPage.registerRoute({path: '', outlet: 'view-4', component: 'view-page'}, {title: 'View 4'}); - - // Open page in main area - const mainPage1 = await workbenchNavigator.openInNewTab(ViewPagePO); - const mainPage2 = await workbenchNavigator.openInNewTab(ViewPagePO); - const mainPartId = await mainPage1.view.part.getPartId(); + const viewPage1 = await workbenchNavigator.openInNewTab(ViewPagePO); + const viewPage2 = await workbenchNavigator.openInNewTab(ViewPagePO); + const initialPartId = await layoutPage.view.part.getPartId(); - await test.step('register action in view-1', async () => { - await layoutPage.registerPartAction('Action 1', {viewId: 'view-1', cssClass: 'e2e-action-1'}); + await test.step('register action in view.101', async () => { + await layoutPage.registerPartAction('Action 1', {viewId: 'view.101', cssClass: 'e2e-action-1'}); // Expect the action-1 to be displayed only in the left part await expect(appPO.part({partId: 'left'}).action({cssClass: 'e2e-action-1'}).locator).toBeVisible(); await expect(appPO.part({partId: 'right'}).action({cssClass: 'e2e-action-1'}).locator).not.toBeAttached(); - await expect(appPO.part({partId: mainPartId}).action({cssClass: 'e2e-action-1'}).locator).not.toBeAttached(); + await expect(appPO.part({partId: initialPartId}).action({cssClass: 'e2e-action-1'}).locator).not.toBeAttached(); }); - await test.step('register action in view-1 and view-3', async () => { - await layoutPage.registerPartAction('Action 2', {viewId: ['view-1', 'view-3'], cssClass: 'e2e-action-2'}); + await test.step('register action in view.101 and view.103', async () => { + await layoutPage.registerPartAction('Action 2', {viewId: ['view.101', 'view.103'], cssClass: 'e2e-action-2'}); // Expect the action-1 to be displayed only in the left part await expect(appPO.part({partId: 'left'}).action({cssClass: 'e2e-action-1'}).locator).toBeVisible(); await expect(appPO.part({partId: 'right'}).action({cssClass: 'e2e-action-1'}).locator).not.toBeAttached(); - await expect(appPO.part({partId: mainPartId}).action({cssClass: 'e2e-action-1'}).locator).not.toBeAttached(); + await expect(appPO.part({partId: initialPartId}).action({cssClass: 'e2e-action-1'}).locator).not.toBeAttached(); // Expect the action-2 to be displayed in the left and right parts await expect(appPO.part({partId: 'left'}).action({cssClass: 'e2e-action-2'}).locator).toBeVisible(); await expect(appPO.part({partId: 'right'}).action({cssClass: 'e2e-action-2'}).locator).toBeVisible(); - await expect(appPO.part({partId: mainPartId}).action({cssClass: 'e2e-action-2'}).locator).not.toBeAttached(); + await expect(appPO.part({partId: initialPartId}).action({cssClass: 'e2e-action-2'}).locator).not.toBeAttached(); }); - await test.step('register action in main part', async () => { - await layoutPage.registerPartAction('Action 3', {viewId: await mainPage1.view.getViewId(), cssClass: 'e2e-action-3'}); - await mainPage1.view.tab.click(); + await test.step('register action in initial part', async () => { + await layoutPage.registerPartAction('Action 3', {viewId: await viewPage1.view.getViewId(), cssClass: 'e2e-action-3'}); + await viewPage1.view.tab.click(); // Expect the action-1 to be displayed only in the left part await expect(appPO.part({partId: 'left'}).action({cssClass: 'e2e-action-1'}).locator).toBeVisible(); await expect(appPO.part({partId: 'right'}).action({cssClass: 'e2e-action-1'}).locator).not.toBeAttached(); - await expect(appPO.part({partId: mainPartId}).action({cssClass: 'e2e-action-1'}).locator).not.toBeAttached(); + await expect(appPO.part({partId: initialPartId}).action({cssClass: 'e2e-action-1'}).locator).not.toBeAttached(); // Expect the action-2 to be displayed in the left and right parts await expect(appPO.part({partId: 'left'}).action({cssClass: 'e2e-action-2'}).locator).toBeVisible(); await expect(appPO.part({partId: 'right'}).action({cssClass: 'e2e-action-2'}).locator).toBeVisible(); - await expect(appPO.part({partId: mainPartId}).action({cssClass: 'e2e-action-2'}).locator).not.toBeAttached(); + await expect(appPO.part({partId: initialPartId}).action({cssClass: 'e2e-action-2'}).locator).not.toBeAttached(); - // Expect the action-3 to be displayed only in the main part + // Expect the action-3 to be displayed only in the initial part await expect(appPO.part({partId: 'left'}).action({cssClass: 'e2e-action-3'}).locator).not.toBeAttached(); await expect(appPO.part({partId: 'right'}).action({cssClass: 'e2e-action-3'}).locator).not.toBeAttached(); - await expect(appPO.part({partId: mainPartId}).action({cssClass: 'e2e-action-3'}).locator).toBeVisible(); + await expect(appPO.part({partId: initialPartId}).action({cssClass: 'e2e-action-3'}).locator).toBeVisible(); }); await test.step('change active view tabs', async () => { - await appPO.view({viewId: 'view-2'}).tab.click(); - await appPO.view({viewId: 'view-4'}).tab.click(); - await appPO.view({viewId: await mainPage2.view.getViewId()}).tab.click(); + await appPO.view({viewId: 'view.102'}).tab.click(); + await appPO.view({viewId: 'view.104'}).tab.click(); + await appPO.view({viewId: await viewPage2.view.getViewId()}).tab.click(); // Expect the action-1 to be displayed only in the left part await expect(appPO.part({partId: 'left'}).action({cssClass: 'e2e-action-1'}).locator).not.toBeAttached(); await expect(appPO.part({partId: 'right'}).action({cssClass: 'e2e-action-1'}).locator).not.toBeAttached(); - await expect(appPO.part({partId: mainPartId}).action({cssClass: 'e2e-action-1'}).locator).not.toBeAttached(); + await expect(appPO.part({partId: initialPartId}).action({cssClass: 'e2e-action-1'}).locator).not.toBeAttached(); // Expect the action-2 to be displayed in the left and right parts await expect(appPO.part({partId: 'left'}).action({cssClass: 'e2e-action-2'}).locator).not.toBeAttached(); await expect(appPO.part({partId: 'right'}).action({cssClass: 'e2e-action-2'}).locator).not.toBeAttached(); - await expect(appPO.part({partId: mainPartId}).action({cssClass: 'e2e-action-2'}).locator).not.toBeAttached(); + await expect(appPO.part({partId: initialPartId}).action({cssClass: 'e2e-action-2'}).locator).not.toBeAttached(); - // Expect the action-3 to be displayed only in the main part + // Expect the action-3 to be displayed only in the initial part await expect(appPO.part({partId: 'left'}).action({cssClass: 'e2e-action-3'}).locator).not.toBeAttached(); await expect(appPO.part({partId: 'right'}).action({cssClass: 'e2e-action-3'}).locator).not.toBeAttached(); - await expect(appPO.part({partId: mainPartId}).action({cssClass: 'e2e-action-3'}).locator).not.toBeAttached(); + await expect(appPO.part({partId: initialPartId}).action({cssClass: 'e2e-action-3'}).locator).not.toBeAttached(); }); await test.step('change active view tabs back', async () => { - await appPO.view({viewId: 'view-1'}).tab.click(); - await appPO.view({viewId: 'view-3'}).tab.click(); - await appPO.view({viewId: await mainPage1.view.getViewId()}).tab.click(); + await appPO.view({viewId: 'view.101'}).tab.click(); + await appPO.view({viewId: 'view.103'}).tab.click(); + await appPO.view({viewId: await viewPage1.view.getViewId()}).tab.click(); // Expect the action-1 to be displayed only in the left part await expect(appPO.part({partId: 'left'}).action({cssClass: 'e2e-action-1'}).locator).toBeVisible(); await expect(appPO.part({partId: 'right'}).action({cssClass: 'e2e-action-1'}).locator).not.toBeAttached(); - await expect(appPO.part({partId: mainPartId}).action({cssClass: 'e2e-action-1'}).locator).not.toBeAttached(); + await expect(appPO.part({partId: initialPartId}).action({cssClass: 'e2e-action-1'}).locator).not.toBeAttached(); // Expect the action-2 to be displayed in the left and right parts await expect(appPO.part({partId: 'left'}).action({cssClass: 'e2e-action-2'}).locator).toBeVisible(); await expect(appPO.part({partId: 'right'}).action({cssClass: 'e2e-action-2'}).locator).toBeVisible(); - await expect(appPO.part({partId: mainPartId}).action({cssClass: 'e2e-action-2'}).locator).not.toBeAttached(); + await expect(appPO.part({partId: initialPartId}).action({cssClass: 'e2e-action-2'}).locator).not.toBeAttached(); - // Expect the action-3 to be displayed only in the main part + // Expect the action-3 to be displayed only in the initial part await expect(appPO.part({partId: 'left'}).action({cssClass: 'e2e-action-3'}).locator).not.toBeAttached(); await expect(appPO.part({partId: 'right'}).action({cssClass: 'e2e-action-3'}).locator).not.toBeAttached(); - await expect(appPO.part({partId: mainPartId}).action({cssClass: 'e2e-action-3'}).locator).toBeVisible(); + await expect(appPO.part({partId: initialPartId}).action({cssClass: 'e2e-action-3'}).locator).toBeVisible(); }); }); test('should contribute action to specific view in the main area', async ({appPO, workbenchNavigator}) => { - await appPO.navigateTo({microfrontendSupport: false, perspectives: ['perspective']}); - - // Switch perspective - await appPO.switchPerspective('perspective'); + await appPO.navigateTo({microfrontendSupport: false}); - // Prepare layout - // +------+-----------+-------+ - // | left | main-area | right | - // +------+-----------+-------+ - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - await layoutPage.addPart('left', {align: 'left', ratio: .25}); - await layoutPage.addPart('right', {align: 'right', ratio: .25}); - await layoutPage.addView('view-1', {partId: 'left', activateView: true}); - await layoutPage.addView('view-2', {partId: 'right', activateView: true}); - await layoutPage.registerRoute({path: '', outlet: 'view-1', component: 'view-page'}, {title: 'View 1'}); - await layoutPage.registerRoute({path: '', outlet: 'view-2', component: 'view-page'}, {title: 'View 2'}); + await workbenchNavigator.modifyLayout(layout => layout + .addPart('left', {align: 'left', ratio: .25}) + .addPart('right', {align: 'right', ratio: .25}) + .addView('view.101', {partId: 'left', activateView: true}) + .addView('view.102', {partId: 'right', activateView: true}), + ); // Open page in main area - const viewPage = await workbenchNavigator.openInNewTab(ViewPagePO); - const mainPartId = await viewPage.view.part.getPartId(); + const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); + const initialPartId = await layoutPage.view.part.getPartId(); // Register action - await layoutPage.registerPartAction('Action', {grid: 'mainArea', viewId: 'view-1', cssClass: 'e2e-action'}); + await layoutPage.registerPartAction('Action', {grid: 'mainArea', viewId: 'view.101', cssClass: 'e2e-action'}); // Expect the action not to be displayed await expect(appPO.part({partId: 'left'}).action({cssClass: 'e2e-action'}).locator).not.toBeAttached(); await expect(appPO.part({partId: 'right'}).action({cssClass: 'e2e-action'}).locator).not.toBeAttached(); - await expect(appPO.part({partId: mainPartId}).action({cssClass: 'e2e-action'}).locator).not.toBeAttached(); + await expect(appPO.part({partId: initialPartId}).action({cssClass: 'e2e-action'}).locator).not.toBeAttached(); - // Drag view-1 to main area - await appPO.view({viewId: 'view-1'}).tab.dragTo({partId: mainPartId, region: 'center'}); + // Drag view.101 to main area + await appPO.view({viewId: 'view.101'}).tab.dragTo({partId: initialPartId, region: 'center'}); // Expect the action not to be displayed await expect(appPO.part({partId: 'left'}).action({cssClass: 'e2e-action'}).locator).not.toBeAttached(); await expect(appPO.part({partId: 'right'}).action({cssClass: 'e2e-action'}).locator).not.toBeAttached(); - await expect(appPO.part({partId: mainPartId}).action({cssClass: 'e2e-action'}).locator).toBeVisible(); + await expect(appPO.part({partId: initialPartId}).action({cssClass: 'e2e-action'}).locator).toBeVisible(); - // Drag view-1 to right part - await appPO.view({viewId: 'view-1'}).tab.dragTo({partId: 'right', region: 'center'}); + // Drag view.101 to right part + await appPO.view({viewId: 'view.101'}).tab.dragTo({partId: 'right', region: 'center'}); // Expect the action not to be displayed await expect(appPO.part({partId: 'left'}).action({cssClass: 'e2e-action'}).locator).not.toBeAttached(); await expect(appPO.part({partId: 'right'}).action({cssClass: 'e2e-action'}).locator).not.toBeAttached(); - await expect(appPO.part({partId: mainPartId}).action({cssClass: 'e2e-action'}).locator).not.toBeAttached(); + await expect(appPO.part({partId: initialPartId}).action({cssClass: 'e2e-action'}).locator).not.toBeAttached(); }); test('should display actions when dragging view to the center', async ({appPO, workbenchNavigator}) => { @@ -310,15 +269,15 @@ test.describe('Workbench Part Action', () => { // Open a view const viewPage = await workbenchNavigator.openInNewTab(ViewPagePO); - const mainPartId = await appPO.activePart({inMainArea: true}).getPartId(); + const initialPartId = await appPO.activePart({inMainArea: true}).getPartId(); await layoutPage.registerPartAction('View Action', {viewId: await viewPage.view.getViewId(), cssClass: 'e2e-action-view'}); // Drag the view await viewPage.view.tab.dragTo({partId: await viewPage.view.part.getPartId(), region: 'center'}, {steps: 100, performDrop: false}); // Expect the global action to still display - await expect(appPO.part({partId: mainPartId}).action({cssClass: 'e2e-action-global'}).locator).toBeVisible(); - await expect(appPO.part({partId: mainPartId}).action({cssClass: 'e2e-action-view'}).locator).not.toBeAttached(); + await expect(appPO.part({partId: initialPartId}).action({cssClass: 'e2e-action-global'}).locator).toBeVisible(); + await expect(appPO.part({partId: initialPartId}).action({cssClass: 'e2e-action-view'}).locator).not.toBeAttached(); }); test('should display actions when dragging view quickly to the center', async ({appPO, workbenchNavigator}) => { @@ -329,15 +288,15 @@ test.describe('Workbench Part Action', () => { // Open a view const viewPage = await workbenchNavigator.openInNewTab(ViewPagePO); - const mainPartId = await appPO.activePart({inMainArea: true}).getPartId(); + const initialPartId = await appPO.activePart({inMainArea: true}).getPartId(); await layoutPage.registerPartAction('View Action', {viewId: await viewPage.view.getViewId(), cssClass: 'e2e-action-view'}); // Drag the view await viewPage.view.tab.dragTo({partId: await viewPage.view.part.getPartId(), region: 'center'}, {steps: 1, performDrop: false}); // Expect the global action to still display - await expect(appPO.part({partId: mainPartId}).action({cssClass: 'e2e-action-global'}).locator).toBeVisible(); - await expect(appPO.part({partId: mainPartId}).action({cssClass: 'e2e-action-view'}).locator).not.toBeAttached(); + await expect(appPO.part({partId: initialPartId}).action({cssClass: 'e2e-action-global'}).locator).toBeVisible(); + await expect(appPO.part({partId: initialPartId}).action({cssClass: 'e2e-action-view'}).locator).not.toBeAttached(); }); test('should display actions when dragging view to the north', async ({appPO, workbenchNavigator}) => { @@ -348,15 +307,15 @@ test.describe('Workbench Part Action', () => { // Open a view const viewPage = await workbenchNavigator.openInNewTab(ViewPagePO); - const mainPartId = await appPO.activePart({inMainArea: true}).getPartId(); + const initialPartId = await appPO.activePart({inMainArea: true}).getPartId(); await layoutPage.registerPartAction('View Action', {viewId: await viewPage.view.getViewId(), cssClass: 'e2e-action-view'}); // Drag the view await viewPage.view.tab.dragTo({partId: await viewPage.view.part.getPartId(), region: 'north'}, {steps: 100, performDrop: false}); // Expect the global action to still display - await expect(appPO.part({partId: mainPartId}).action({cssClass: 'e2e-action-global'}).locator).toBeVisible(); - await expect(appPO.part({partId: mainPartId}).action({cssClass: 'e2e-action-view'}).locator).not.toBeAttached(); + await expect(appPO.part({partId: initialPartId}).action({cssClass: 'e2e-action-global'}).locator).toBeVisible(); + await expect(appPO.part({partId: initialPartId}).action({cssClass: 'e2e-action-view'}).locator).not.toBeAttached(); }); test('should display actions when dragging view quickly to the north', async ({appPO, workbenchNavigator}) => { @@ -367,15 +326,15 @@ test.describe('Workbench Part Action', () => { // Open a view const viewPage = await workbenchNavigator.openInNewTab(ViewPagePO); - const mainPartId = await appPO.activePart({inMainArea: true}).getPartId(); + const initialPartId = await appPO.activePart({inMainArea: true}).getPartId(); await layoutPage.registerPartAction('View Action', {viewId: await viewPage.view.getViewId(), cssClass: 'e2e-action-view'}); // Drag the view await viewPage.view.tab.dragTo({partId: await viewPage.view.part.getPartId(), region: 'north'}, {steps: 1, performDrop: false}); // Expect the global action to still display - await expect(appPO.part({partId: mainPartId}).action({cssClass: 'e2e-action-global'}).locator).toBeVisible(); - await expect(appPO.part({partId: mainPartId}).action({cssClass: 'e2e-action-view'}).locator).not.toBeAttached(); + await expect(appPO.part({partId: initialPartId}).action({cssClass: 'e2e-action-global'}).locator).toBeVisible(); + await expect(appPO.part({partId: initialPartId}).action({cssClass: 'e2e-action-view'}).locator).not.toBeAttached(); }); test('should display actions when dragging view to the east', async ({appPO, workbenchNavigator}) => { @@ -386,15 +345,15 @@ test.describe('Workbench Part Action', () => { // Open a view const viewPage = await workbenchNavigator.openInNewTab(ViewPagePO); - const mainPartId = await appPO.activePart({inMainArea: true}).getPartId(); + const initialPartId = await appPO.activePart({inMainArea: true}).getPartId(); await layoutPage.registerPartAction('View Action', {viewId: await viewPage.view.getViewId(), cssClass: 'e2e-action-view'}); // Drag the view await viewPage.view.tab.dragTo({partId: await viewPage.view.part.getPartId(), region: 'east'}, {steps: 100, performDrop: false}); // Expect the global action to still display - await expect(appPO.part({partId: mainPartId}).action({cssClass: 'e2e-action-global'}).locator).toBeVisible(); - await expect(appPO.part({partId: mainPartId}).action({cssClass: 'e2e-action-view'}).locator).not.toBeAttached(); + await expect(appPO.part({partId: initialPartId}).action({cssClass: 'e2e-action-global'}).locator).toBeVisible(); + await expect(appPO.part({partId: initialPartId}).action({cssClass: 'e2e-action-view'}).locator).not.toBeAttached(); }); test('should display actions when dragging view quickly to the east', async ({appPO, workbenchNavigator}) => { @@ -405,15 +364,15 @@ test.describe('Workbench Part Action', () => { // Open a view const viewPage = await workbenchNavigator.openInNewTab(ViewPagePO); - const mainPartId = await appPO.activePart({inMainArea: true}).getPartId(); + const initialPartId = await appPO.activePart({inMainArea: true}).getPartId(); await layoutPage.registerPartAction('View Action', {viewId: await viewPage.view.getViewId(), cssClass: 'e2e-action-view'}); // Drag the view await viewPage.view.tab.dragTo({partId: await viewPage.view.part.getPartId(), region: 'east'}, {steps: 1, performDrop: false}); // Expect the global action to still display - await expect(appPO.part({partId: mainPartId}).action({cssClass: 'e2e-action-global'}).locator).toBeVisible(); - await expect(appPO.part({partId: mainPartId}).action({cssClass: 'e2e-action-view'}).locator).not.toBeAttached(); + await expect(appPO.part({partId: initialPartId}).action({cssClass: 'e2e-action-global'}).locator).toBeVisible(); + await expect(appPO.part({partId: initialPartId}).action({cssClass: 'e2e-action-view'}).locator).not.toBeAttached(); }); test('should display actions when dragging view to the south', async ({appPO, workbenchNavigator}) => { @@ -424,15 +383,15 @@ test.describe('Workbench Part Action', () => { // Open a view const viewPage = await workbenchNavigator.openInNewTab(ViewPagePO); - const mainPartId = await appPO.activePart({inMainArea: true}).getPartId(); + const initialPartId = await appPO.activePart({inMainArea: true}).getPartId(); await layoutPage.registerPartAction('View Action', {viewId: await viewPage.view.getViewId(), cssClass: 'e2e-action-view'}); // Drag the view await viewPage.view.tab.dragTo({partId: await viewPage.view.part.getPartId(), region: 'south'}, {steps: 100, performDrop: false}); // Expect the global action to still display - await expect(appPO.part({partId: mainPartId}).action({cssClass: 'e2e-action-global'}).locator).toBeVisible(); - await expect(appPO.part({partId: mainPartId}).action({cssClass: 'e2e-action-view'}).locator).not.toBeAttached(); + await expect(appPO.part({partId: initialPartId}).action({cssClass: 'e2e-action-global'}).locator).toBeVisible(); + await expect(appPO.part({partId: initialPartId}).action({cssClass: 'e2e-action-view'}).locator).not.toBeAttached(); }); test('should display actions when dragging view quickly to the south', async ({appPO, workbenchNavigator}) => { @@ -443,15 +402,15 @@ test.describe('Workbench Part Action', () => { // Open a view const viewPage = await workbenchNavigator.openInNewTab(ViewPagePO); - const mainPartId = await appPO.activePart({inMainArea: true}).getPartId(); + const initialPartId = await appPO.activePart({inMainArea: true}).getPartId(); await layoutPage.registerPartAction('View Action', {viewId: await viewPage.view.getViewId(), cssClass: 'e2e-action-view'}); // Drag the view await viewPage.view.tab.dragTo({partId: await viewPage.view.part.getPartId(), region: 'south'}, {steps: 1, performDrop: false}); // Expect the global action to still display - await expect(appPO.part({partId: mainPartId}).action({cssClass: 'e2e-action-global'}).locator).toBeVisible(); - await expect(appPO.part({partId: mainPartId}).action({cssClass: 'e2e-action-view'}).locator).not.toBeAttached(); + await expect(appPO.part({partId: initialPartId}).action({cssClass: 'e2e-action-global'}).locator).toBeVisible(); + await expect(appPO.part({partId: initialPartId}).action({cssClass: 'e2e-action-view'}).locator).not.toBeAttached(); }); test('should display actions when dragging view to the west', async ({appPO, workbenchNavigator}) => { @@ -462,15 +421,15 @@ test.describe('Workbench Part Action', () => { // Open a view const viewPage = await workbenchNavigator.openInNewTab(ViewPagePO); - const mainPartId = await appPO.activePart({inMainArea: true}).getPartId(); + const initialPartId = await appPO.activePart({inMainArea: true}).getPartId(); await layoutPage.registerPartAction('View Action', {viewId: await viewPage.view.getViewId(), cssClass: 'e2e-action-view'}); // Drag the view await viewPage.view.tab.dragTo({partId: await viewPage.view.part.getPartId(), region: 'west'}, {steps: 100, performDrop: false}); // Expect the global action to still display - await expect(appPO.part({partId: mainPartId}).action({cssClass: 'e2e-action-global'}).locator).toBeVisible(); - await expect(appPO.part({partId: mainPartId}).action({cssClass: 'e2e-action-view'}).locator).not.toBeAttached(); + await expect(appPO.part({partId: initialPartId}).action({cssClass: 'e2e-action-global'}).locator).toBeVisible(); + await expect(appPO.part({partId: initialPartId}).action({cssClass: 'e2e-action-view'}).locator).not.toBeAttached(); }); test('should display actions when dragging view quickly to the west', async ({appPO, workbenchNavigator}) => { @@ -481,15 +440,15 @@ test.describe('Workbench Part Action', () => { // Open a view const viewPage = await workbenchNavigator.openInNewTab(ViewPagePO); - const mainPartId = await appPO.activePart({inMainArea: true}).getPartId(); + const initialPartId = await appPO.activePart({inMainArea: true}).getPartId(); await layoutPage.registerPartAction('View Action', {viewId: await viewPage.view.getViewId(), cssClass: 'e2e-action-view'}); // Drag the view await viewPage.view.tab.dragTo({partId: await viewPage.view.part.getPartId(), region: 'west'}, {steps: 1, performDrop: false}); // Expect the global action to still display - await expect(appPO.part({partId: mainPartId}).action({cssClass: 'e2e-action-global'}).locator).toBeVisible(); - await expect(appPO.part({partId: mainPartId}).action({cssClass: 'e2e-action-view'}).locator).not.toBeAttached(); + await expect(appPO.part({partId: initialPartId}).action({cssClass: 'e2e-action-global'}).locator).toBeVisible(); + await expect(appPO.part({partId: initialPartId}).action({cssClass: 'e2e-action-view'}).locator).not.toBeAttached(); }); test('should display actions after drop', async ({appPO, workbenchNavigator}) => { @@ -497,7 +456,7 @@ test.describe('Workbench Part Action', () => { // Open a view const viewPage = await workbenchNavigator.openInNewTab(ViewPagePO); - const mainPartId = await appPO.activePart({inMainArea: true}).getPartId(); + const initialPartId = await appPO.activePart({inMainArea: true}).getPartId(); // Register view-specific action const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); @@ -508,7 +467,7 @@ test.describe('Workbench Part Action', () => { const newPartId = await viewPage.view.part.getPartId(); // Expect action to display - await expect(appPO.part({partId: mainPartId}).action({cssClass: 'e2e-action'}).locator).not.toBeAttached(); + await expect(appPO.part({partId: initialPartId}).action({cssClass: 'e2e-action'}).locator).not.toBeAttached(); await expect(appPO.part({partId: newPartId}).action({cssClass: 'e2e-action'}).locator).toBeVisible(); }); @@ -517,7 +476,7 @@ test.describe('Workbench Part Action', () => { // Open a view const viewPage = await workbenchNavigator.openInNewTab(ViewPagePO); - const mainPartId = await appPO.activePart({inMainArea: true}).getPartId(); + const initialPartId = await appPO.activePart({inMainArea: true}).getPartId(); // Register view-specific action const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); @@ -528,7 +487,7 @@ test.describe('Workbench Part Action', () => { const newPartId = await viewPage.view.part.getPartId(); // Expect action to display - await expect(appPO.part({partId: mainPartId}).action({cssClass: 'e2e-action'}).locator).not.toBeAttached(); + await expect(appPO.part({partId: initialPartId}).action({cssClass: 'e2e-action'}).locator).not.toBeAttached(); await expect(appPO.part({partId: newPartId}).action({cssClass: 'e2e-action'}).locator).toBeVisible(); }); }); diff --git a/projects/scion/e2e-testing/src/workbench/workbench-perspective-storage.e2e-spec.ts b/projects/scion/e2e-testing/src/workbench/workbench-perspective-storage.e2e-spec.ts index 3e0bf87e3..07cb4aed2 100644 --- a/projects/scion/e2e-testing/src/workbench/workbench-perspective-storage.e2e-spec.ts +++ b/projects/scion/e2e-testing/src/workbench/workbench-perspective-storage.e2e-spec.ts @@ -10,46 +10,114 @@ import {expect} from '@playwright/test'; import {test} from '../fixtures'; -import {LayoutPagePO} from './page-object/layout-page.po'; import {MAIN_AREA} from '../workbench.model'; import {ViewPagePO} from './page-object/view-page.po'; import {expectView} from '../matcher/view-matcher'; +import {MPart, MTreeNode} from '../matcher/to-equal-workbench-layout.matcher'; test.describe('Workbench Perspective Storage', () => { - test('should restore workbench grid from storage', async ({page, appPO, workbenchNavigator}) => { - await appPO.navigateTo({microfrontendSupport: false, perspectives: ['perspective-1']}); - - // Switch to perspective-1 - await appPO.switchPerspective('perspective-1'); + test('should restore workbench grid from storage', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); // Add part and view to the workbench grid - const layoutPage = await workbenchNavigator.openInNewTab(LayoutPagePO); - await layoutPage.addPart('left', {relativeTo: MAIN_AREA, align: 'left', ratio: .25}); - await layoutPage.addView('outline', {partId: 'left', activateView: true}); - await layoutPage.addView('console', {partId: 'left', activateView: true}); + await workbenchNavigator.modifyLayout((layout, activePartId) => layout + .addPart('left', {relativeTo: MAIN_AREA, align: 'left', ratio: .25}) + .addView('view.101', {partId: 'left'}) + .addView('view.102', {partId: 'left', activateView: true, activatePart: true}) + .addView('view.103', {partId: activePartId, activateView: true}) + .navigateView('view.101', ['test-view']) + .navigateView('view.102', ['test-view']) + .navigateView('view.103', ['test-view']), + ); - const testee1ViewPage = new ViewPagePO(appPO, {viewId: 'outline'}); - const testee2ViewPage = new ViewPagePO(appPO, {viewId: 'console'}); + const viewPage1 = new ViewPagePO(appPO, {viewId: 'view.101'}); + const viewPage2 = new ViewPagePO(appPO, {viewId: 'view.102'}); + const viewPage3 = new ViewPagePO(appPO, {viewId: 'view.103'}); // Reopen the page - await page.goto('about:blank'); - await appPO.navigateTo({microfrontendSupport: false, perspectives: ['perspective-1']}); + await appPO.reload(); + + // Expect perspective to be restored from the storage + await expect(appPO.workbench).toEqualWorkbenchLayout({ + workbenchGrid: { + root: new MTreeNode({ + direction: 'row', + ratio: .25, + child1: new MPart({ + id: 'left', + views: [{id: 'view.101'}, {id: 'view.102'}], + activeViewId: 'view.102', + }), + child2: new MPart({ + id: MAIN_AREA, + }), + }), + activePartId: 'left', + }, + mainAreaGrid: { + root: new MPart({ + views: [{id: 'view.103'}], + activeViewId: 'view.103', + }), + }, + }); - // Expect perspective-1 to be restored from the storage - await expect.poll(() => appPO.header.perspectiveToggleButton({perspectiveId: 'perspective-1'}).isActive()).toBe(true); - await expectView(testee1ViewPage).toBeInactive(); - await expectView(testee2ViewPage).toBeActive(); + await expectView(viewPage1).toBeInactive(); + await expectView(viewPage2).toBeActive(); + await expectView(viewPage3).toBeActive(); // Close view - await testee2ViewPage.view.tab.close(); + await viewPage2.view.tab.close(); // Reopen the page - await page.goto('about:blank'); - await appPO.navigateTo({microfrontendSupport: false, perspectives: ['perspective-1']}); + await appPO.reload(); + + // Expect perspective to be restored from the storage + await expect(appPO.workbench).toEqualWorkbenchLayout({ + workbenchGrid: { + root: new MTreeNode({ + direction: 'row', + ratio: .25, + child1: new MPart({ + id: 'left', + views: [{id: 'view.101'}], + activeViewId: 'view.101', + }), + child2: new MPart({ + id: MAIN_AREA, + }), + }), + activePartId: 'left', + }, + mainAreaGrid: { + root: new MPart({ + views: [{id: 'view.103'}], + activeViewId: 'view.103', + }), + }, + }); + await expectView(viewPage1).toBeActive(); + await expectView(viewPage2).not.toBeAttached(); + await expectView(viewPage3).toBeActive(); + }); + + test('should not set the initial perspective as the active perspective in storage and window', async ({appPO}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + await expect.poll(() => appPO.getWindowName()).toEqual(''); + await expect.poll(() => appPO.getLocalStorageItem('scion.workbench.perspective')).toBeNull(); + }); + + test('should select the initial perspective from storage', async ({appPO}) => { + await appPO.navigateTo({ + microfrontendSupport: false, + perspectives: ['testee-1', 'testee-2', 'testee-3'], + localStorage: { + 'scion.workbench.perspective': 'testee-2', + }, + }); - // Expect perspective-1 to be restored from the storage - await expectView(testee1ViewPage).toBeActive(); - await expectView(testee2ViewPage).not.toBeAttached(); + await expect.poll(() => appPO.header.perspectiveToggleButton({perspectiveId: 'testee-2'}).isActive()).toBe(true); }); }); diff --git a/projects/scion/workbench-client/src/lib/dialog/workbench-dialog-capability.ts b/projects/scion/workbench-client/src/lib/dialog/workbench-dialog-capability.ts index d392b1580..0471bee53 100644 --- a/projects/scion/workbench-client/src/lib/dialog/workbench-dialog-capability.ts +++ b/projects/scion/workbench-client/src/lib/dialog/workbench-dialog-capability.ts @@ -47,7 +47,7 @@ export interface WorkbenchDialogCapability extends Capability { /** * Specifies parameters required by the dialog. * - * Parameters are available in the path or title for placeholder substitution, or can be read in the microfrontend by injecting the {@link WorkbenchDialog} handle. + * Parameters are available in the path and title for placeholder substitution, or can be read in the microfrontend by injecting the {@link WorkbenchDialog} handle. * * @inheritDoc */ @@ -65,7 +65,7 @@ export interface WorkbenchDialogCapability extends Capability { * The path supports placeholders that will be replaced with parameter values. A placeholder * starts with a colon (`:`) followed by the parameter name. * - * #### Usage of named parameters in the path: + * Usage: * ```json * { * "type": "dialog", @@ -115,7 +115,7 @@ export interface WorkbenchDialogCapability extends Capability { */ showSplash?: boolean; /** - * Specifies CSS class(es) to be added to the dialog, useful in end-to-end tests for locating the dialog. + * Specifies CSS class(es) to add to the dialog, e.g., to locate the dialog in tests. */ cssClass?: string | string[]; }; diff --git a/projects/scion/workbench-client/src/lib/dialog/workbench-dialog-service.ts b/projects/scion/workbench-client/src/lib/dialog/workbench-dialog-service.ts index 260521287..02df7bfc7 100644 --- a/projects/scion/workbench-client/src/lib/dialog/workbench-dialog-service.ts +++ b/projects/scion/workbench-client/src/lib/dialog/workbench-dialog-service.ts @@ -22,6 +22,10 @@ import {WorkbenchView} from '../view/workbench-view'; * 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. * + * A microfrontend provided as a dialog capability can be opened in a dialog. The qualifier differentiates between different + * dialog capabilities. An application can open the public dialog capabilities of other applications if it manifests a respective + * intention. + * * Displayed on top of other content, a dialog blocks interaction with other parts of the application. Multiple dialogs are stacked, * and only the topmost dialog in each modality stack can be interacted with. * @@ -35,12 +39,12 @@ import {WorkbenchView} from '../view/workbench-view'; export class WorkbenchDialogService { /** - * Opens a microfrontend in a workbench dialog based on the given qualifier and options. + * Opens a microfrontend of a dialog capability in a workbench dialog based on the given qualifier 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}. * - * @param qualifier - Identifies the dialog capability that provides the microfrontend to be displayed in a dialog. + * @param qualifier - Identifies the dialog capability that provides the microfrontend to open in a dialog. * @param options - Controls how to open the 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. * diff --git a/projects/scion/workbench-client/src/lib/dialog/workbench-dialog.options.ts b/projects/scion/workbench-client/src/lib/dialog/workbench-dialog.options.ts index 2c57228e6..87797a0c4 100644 --- a/projects/scion/workbench-client/src/lib/dialog/workbench-dialog.options.ts +++ b/projects/scion/workbench-client/src/lib/dialog/workbench-dialog.options.ts @@ -8,6 +8,8 @@ * SPDX-License-Identifier: EPL-2.0 */ +import {ViewId} from '../view/workbench-view'; + /** * Configures the dialog to display a microfrontend in a workbench dialog using {@link WorkbenchDialogService}. * @@ -15,8 +17,9 @@ */ export interface WorkbenchDialogOptions { /** - * Passes data to the dialog microfrontend. The dialog provider can declare mandatory and optional parameters. - * No additional parameters may be included. Refer to the documentation of the dialog capability provider for more information. + * Passes data to the dialog. + * + * The dialog can declare mandatory and optional parameters. No additional parameters are allowed. Refer to the documentation of the capability for more information. */ params?: Map | {[param: string]: unknown}; /** @@ -39,14 +42,14 @@ export interface WorkbenchDialogOptions { * * By default, if opening the dialog in the context of a view, that view is used as the contextual view. */ - viewId?: string; + viewId?: ViewId; }; /** * Controls whether to animate the opening of the dialog. Defaults is `false`. */ animate?: boolean; /** - * Specifies CSS class(es) to be added to the dialog, useful in end-to-end tests for locating the dialog. + * Specifies CSS class(es) to add to the dialog, e.g., to locate the dialog in tests. */ cssClass?: string | string[]; } diff --git a/projects/scion/workbench-client/src/lib/message-box/workbench-message-box.config.ts b/projects/scion/workbench-client/src/lib/message-box/workbench-message-box.config.ts index 36e349bcb..8e0bababa 100644 --- a/projects/scion/workbench-client/src/lib/message-box/workbench-message-box.config.ts +++ b/projects/scion/workbench-client/src/lib/message-box/workbench-message-box.config.ts @@ -9,6 +9,7 @@ */ import {Dictionary} from '@scion/toolkit/util'; +import {ViewId} from '../view/workbench-view'; /** * Configures the content and appearance of a message presented to the user in the form of a message box. @@ -46,8 +47,9 @@ export interface WorkbenchMessageBoxConfig { content?: any; /** - * Allows passing data to the message box. The message box provider can declare mandatory and optional parameters. - * No additional parameters may be included. Refer to the documentation of the message box capability provider for more information. + * Passes data to the message box. + * + * The message box can declare mandatory and optional parameters. No additional parameters are allowed. Refer to the documentation of the capability for more information. */ params?: Map | Dictionary; @@ -90,7 +92,7 @@ export interface WorkbenchMessageBoxConfig { contentSelectable?: boolean; /** - * Specifies CSS class(es) to be added to the message box, useful in end-to-end tests for locating the message box. + * Specifies CSS class(es) to add to the message box, e.g., to locate the message box in tests. */ cssClass?: string | string[]; @@ -103,6 +105,6 @@ export interface WorkbenchMessageBoxConfig { * * By default, if opening the message box in the context of a view, that view is used as the contextual view. */ - viewId?: string; + viewId?: ViewId; }; } diff --git a/projects/scion/workbench-client/src/lib/notification/workbench-notification.config.ts b/projects/scion/workbench-client/src/lib/notification/workbench-notification.config.ts index 10992142b..00fbbb3e5 100644 --- a/projects/scion/workbench-client/src/lib/notification/workbench-notification.config.ts +++ b/projects/scion/workbench-client/src/lib/notification/workbench-notification.config.ts @@ -37,8 +37,9 @@ export interface WorkbenchNotificationConfig { content?: any; /** - * Allows passing data to the notification. The notification provider can declare mandatory and optional parameters. - * No additional parameters may be included. Refer to the documentation of the notification capability provider for more information. + * Passes data to the notification. + * + * The notification can declare mandatory and optional parameters. No additional parameters are allowed. Refer to the documentation of the capability for more information. */ params?: Map | Dictionary; @@ -60,7 +61,7 @@ export interface WorkbenchNotificationConfig { group?: string; /** - * Specifies CSS class(es) to be added to the notification, useful in end-to-end tests for locating the notification. + * Specifies CSS class(es) to add to the notification, e.g., to locate the notification in tests. */ cssClass?: string | string[]; } diff --git a/projects/scion/workbench-client/src/lib/popup/workbench-popup-capability.ts b/projects/scion/workbench-client/src/lib/popup/workbench-popup-capability.ts index e7495ad75..ccd2a0bdf 100644 --- a/projects/scion/workbench-client/src/lib/popup/workbench-popup-capability.ts +++ b/projects/scion/workbench-client/src/lib/popup/workbench-popup-capability.ts @@ -84,7 +84,7 @@ export interface WorkbenchPopupCapability extends Capability { */ showSplash?: boolean; /** - * Specifies CSS class(es) to be added to the popup, useful in end-to-end tests for locating the popup. + * Specifies CSS class(es) to add to the popup, e.g., to locate the popup in tests. */ cssClass?: string | string[]; }; diff --git a/projects/scion/workbench-client/src/lib/popup/workbench-popup-open-command.ts b/projects/scion/workbench-client/src/lib/popup/workbench-popup-open-command.ts index bd7c2274b..ca1306cc1 100644 --- a/projects/scion/workbench-client/src/lib/popup/workbench-popup-open-command.ts +++ b/projects/scion/workbench-client/src/lib/popup/workbench-popup-open-command.ts @@ -9,6 +9,7 @@ */ import {CloseStrategy} from './workbench-popup.config'; +import {ViewId} from '../view/workbench-view'; /** * Command object for instructing the Workbench to open the microfrontend of given popup capability in a popup. @@ -22,6 +23,6 @@ export interface ɵWorkbenchPopupCommand { closeStrategy?: CloseStrategy; cssClass?: string | string[]; context?: { - viewId?: string | null; + viewId?: ViewId | null; }; } diff --git a/projects/scion/workbench-client/src/lib/popup/workbench-popup-referrer.ts b/projects/scion/workbench-client/src/lib/popup/workbench-popup-referrer.ts index a7a04e406..1813e7697 100644 --- a/projects/scion/workbench-client/src/lib/popup/workbench-popup-referrer.ts +++ b/projects/scion/workbench-client/src/lib/popup/workbench-popup-referrer.ts @@ -8,6 +8,8 @@ * SPDX-License-Identifier: EPL-2.0 */ +import {ViewId} from '../view/workbench-view'; + /** * Information about the context in which a popup was opened. * @@ -17,7 +19,7 @@ export interface WorkbenchPopupReferrer { /** * Identity of the view if opened in the context of a view. */ - viewId?: string; + viewId?: ViewId; /** * Identity of the view capability if opened in the context of a view microfrontend. */ diff --git a/projects/scion/workbench-client/src/lib/popup/workbench-popup-service.ts b/projects/scion/workbench-client/src/lib/popup/workbench-popup-service.ts index f5ecf8330..ba6cabf4f 100644 --- a/projects/scion/workbench-client/src/lib/popup/workbench-popup-service.ts +++ b/projects/scion/workbench-client/src/lib/popup/workbench-popup-service.ts @@ -53,8 +53,8 @@ export class WorkbenchPopupService { * * By setting the alignment of the popup, you can control the region where to open the popup relative to its anchor. * - * You can pass data to the popup microfrontend using parameters. The popup provider can declare mandatory and optional parameters. - * No additional parameters may be included. Refer to the documentation of the popup capability provider for more information. + * You can pass data to the popup using parameters. The popup can declare mandatory and optional parameters. + * No additional parameters are allowed. Refer to the documentation of the capability for more information. * * By default, the popup will close on focus loss, or when the user hits the escape key. * diff --git a/projects/scion/workbench-client/src/lib/popup/workbench-popup.config.ts b/projects/scion/workbench-client/src/lib/popup/workbench-popup.config.ts index 9cfd3df52..4e42c6966 100644 --- a/projects/scion/workbench-client/src/lib/popup/workbench-popup.config.ts +++ b/projects/scion/workbench-client/src/lib/popup/workbench-popup.config.ts @@ -11,6 +11,7 @@ import {Observable} from 'rxjs'; import {Dictionary} from '@scion/toolkit/util'; import {PopupOrigin} from './popup.origin'; +import {ViewId} from '../view/workbench-view'; /** * Configures the popup to display a microfrontend in a workbench popup using {@link WorkbenchPopupService}. @@ -41,8 +42,9 @@ export interface WorkbenchPopupConfig { */ anchor: Element | PopupOrigin | Observable; /** - * Allows passing data to the popup microfrontend. The popup provider can declare mandatory and optional parameters. - * No additional parameters may be included. Refer to the documentation of the popup capability provider for more information. + * Passes data to the popup. + * + * The popup can declare mandatory and optional parameters. No additional parameters are allowed. Refer to the documentation of the capability for more information. */ params?: Map | Dictionary; /** @@ -55,7 +57,7 @@ export interface WorkbenchPopupConfig { */ closeStrategy?: CloseStrategy; /** - * Specifies CSS class(es) to be added to the popup, useful in end-to-end tests for locating the popup. + * Specifies CSS class(es) to add to the popup, e.g., to locate the popup in tests. */ cssClass?: string | string[]; /** @@ -70,7 +72,7 @@ export interface WorkbenchPopupConfig { * By default, if opening the popup in the context of a view, that view is used as the popup's contextual view. * If you set the view id to `null`, the popup will open without referring to the contextual view. */ - viewId?: string | null; + viewId?: ViewId | null; }; } diff --git a/projects/scion/workbench-client/src/lib/routing/workbench-router.ts b/projects/scion/workbench-client/src/lib/routing/workbench-router.ts index 7f4800b7e..b54ec2796 100644 --- a/projects/scion/workbench-client/src/lib/routing/workbench-router.ts +++ b/projects/scion/workbench-client/src/lib/routing/workbench-router.ts @@ -17,16 +17,13 @@ import {ɵWorkbenchCommands} from '../ɵworkbench-commands'; import {lastValueFrom} from 'rxjs'; /** - * Allows navigating to a microfrontend in a workbench view. + * Enables navigation of workbench views. * - * A view is a visual workbench component for displaying content stacked or side-by-side. + * A view is a visual workbench element for displaying content side-by-side or stacked. * - * In SCION Workbench Client, routing means instructing a workbench view to display the microfrontend of a registered view capability. - * A qualifier is used to differentiate view capabilities. A micro application can provide multiple view capabilities and make them - * publicly available to other micro applications. - * - * As a prerequisite for routing, the navigating micro application must declare a fulfilling view intention in its manifest unless navigating - * to views that the app provides itself. Navigation to microfrontends of other apps is only allowed for public view capabilities. + * A microfrontend provided as a view capability can be opened in a view. The qualifier differentiates between different + * view capabilities. An application can open the public view capabilities of other applications if it manifests a respective + * intention. * * @category Router * @category View @@ -34,22 +31,17 @@ import {lastValueFrom} from 'rxjs'; export class WorkbenchRouter { /** - * Navigates to a view microfrontend based on the given qualifier. - * - * The qualifier identifies the view microfrontend(s) which to open. If multiple view microfrontends match the qualifier, they are all opened. + * Navigates to a microfrontend of a view capability based on the given qualifier and extras. * - * By passing navigation extras, you can control where the microfrontend should open. By default, the router opens the microfrontend in a new view - * tab if no view is found that matches the specified qualifier and required params. Optional parameters do not affect view resolution. If one - * (or more) view(s) match the qualifier and required params, they are navigated instead of opening the microfrontend in a new view tab. + * By default, the router opens a new view if no view is found that matches the specified qualifier and required params. Optional parameters do not affect view resolution. + * If one or more views match the qualifier and required params, they will be navigated instead of opening the microfrontend in a new view tab. + * This behavior can be changed by setting an explicit navigation target in navigation extras. * - * @param qualifier - Identifies the view capability that provides the microfrontend. - * By passing an empty qualifier (`{}`), the currently loaded microfrontend can update its parameters in the workbench URL, - * e.g., to support persistent navigation. This type of navigation is referred to as self-navigation and is supported only - * if in the context of a view. Setting {@link WorkbenchNavigationExtras#paramsHandling} allows instructing the workbench - * router how to handle params. By default, new params replace params contained in the URL. + * @param qualifier - Identifies the view capability that provides the microfrontend to display in a view. + * Passing an empty qualifier (`{}`) allows the microfrontend to update its parameters, restoring updated parameters when the page reloads. + * Parameter handling can be controlled using the {@link WorkbenchNavigationExtras#paramsHandling} option. * @param extras - Options to control navigation. - * @return Promise that resolves to `true` when navigation succeeds, to `false` when navigation fails, or is rejected on error, - * e.g., if not qualified or because no application provides the requested view. + * @return Promise that resolves to `true` on successful navigation, or `false` otherwise. */ public async navigate(qualifier: Qualifier | {}, extras?: WorkbenchNavigationExtras): Promise { if (this.isSelfNavigation(qualifier)) { @@ -97,7 +89,7 @@ export class WorkbenchRouter { private isSelfNavigation(qualifier: Qualifier | {}): boolean { if (!qualifier || Object.keys(qualifier).length === 0) { if (!Beans.opt(WorkbenchView)) { - throw Error('[WorkbenchRouterError] Self-navigation is supported only if in the context of a view.'); + throw Error('[NavigateError] Self-navigation is supported only if in the context of a view.'); } return true; } @@ -113,57 +105,51 @@ export class WorkbenchRouter { */ export interface WorkbenchNavigationExtras { /** - * Allows passing additional data to the microfrontend. In contrast to the qualifier, params have no effect on the intent routing. - * If the fulfilling capability(-ies) declare(s) mandatory parameters, be sure to include them, otherwise navigation will be rejected. + * Passes data to the view. + * + * The view can declare mandatory and optional parameters. No additional parameters are allowed. Refer to the documentation of the capability for more information. */ params?: Map | Dictionary; /** * Instructs the workbench router how to handle params in self-navigation. * - * Self-navigation allows a view to update its parameters in the workbench URL to support persistent navigation. Setting a `paramsHandling` - * strategy has no effect on navigations other than self-navigation. A self-navigation is initiated by passing an empty qualifier. + * Self-navigation allows the microfrontend to update its parameters, restoring updated parameters when the page reloads. + * Setting a `paramsHandling` strategy has no effect on navigations other than self-navigation. A self-navigation is + * initiated by passing an empty qualifier. * * One of: - * * `replace`: Discards parameters in the URL and uses the new parameters instead (which is by default if not set). - * * `merge`: Merges new parameters with the parameters currently contained in the URL. In case of a key collision, new parameters overwrite - * the parameters contained in the URL. A parameter can be removed by passing `undefined` as its value. + * * `replace`: Replaces current parameters (default). + * * `merge`: Merges new parameters with current parameters, with new parameters of equal name overwriting existing parameters. + * A parameter can be removed by passing `undefined` as its value. */ paramsHandling?: 'merge' | 'replace'; /** - * Instructs the router to activate the view. Defaults to `true` if not specified. + * Instructs the router to activate the view. Default is `true`. */ activate?: boolean; /** - * Closes the view(s) that match the specified qualifier and required parameter(s). Optional parameters do not affect view resolution. + * Closes views that match the specified qualifier and required parameters. Optional parameters do not affect view resolution. * - * To match views with any value for a specific required parameter, use the asterisk wildcard character (`*`) as the parameter value. + * The parameters support the asterisk wildcard value (`*`) to match views with any value for a parameter. * - * Note that you can only close view(s) for which you have an intention and which are visible to your app. + * Only views for which the application has an intention can be closed. */ close?: boolean; /** - * Controls where to open the view. + * Controls where to open the view. Default is `auto`. * * One of: - * - 'auto': Opens the microfrontend in a new view tab if no view is found that matches the specified qualifier and required params. Optional parameters - * do not affect view resolution. If one (or more) view(s) match the qualifier and required params, they are navigated instead of - * opening the microfrontend in a new view tab, e.g., to update optional parameters. This is the default behavior if not set. - * - 'blank': Opens the microfrontend in a new view tab. - * - : Navigates the specified view. If already opened, replaces it, or opens the view in a new view tab otherwise. - * Note that the passed view identifier must start with `view.`, e.g., `view.5`. - * - * If not specified, defaults to `auto`. + * - 'auto': Navigates existing views that match the qualifier and required params, or opens a new view otherwise. Optional parameters do not affect view resolution. + * - 'blank': Navigates in a new view. + * - : Navigates the specified view. If already opened, replaces it, or opens a new view otherwise. */ target?: string | 'blank' | 'auto'; /** - * Specifies the position where to insert the view into the tab bar when using 'blank' view target strategy. - * If not specified, the view is inserted after the active view. Set the index to 'start' or 'end' for inserting - * the view at the beginning or at the end. + * Specifies where to insert the view into the tab bar. Has no effect if navigating an existing view. Default is after the active view. */ - blankInsertionIndex?: number | 'start' | 'end'; + blankInsertionIndex?: number | 'start' | 'end' | 'before-active-view' | 'after-active-view'; /** - * Specifies CSS class(es) to be added to the view, useful in end-to-end tests for locating view and view tab. - * CSS class(es) will not be added to the browser URL, consequently will not survive a page reload. + * Specifies CSS class(es) to add to the view, e.g., to locate the view in tests. */ cssClass?: string | string[]; } diff --git a/projects/scion/workbench-client/src/lib/view/workbench-view-capability.ts b/projects/scion/workbench-client/src/lib/view/workbench-view-capability.ts index fe9a22021..1affc39c4 100644 --- a/projects/scion/workbench-client/src/lib/view/workbench-view-capability.ts +++ b/projects/scion/workbench-client/src/lib/view/workbench-view-capability.ts @@ -14,35 +14,48 @@ import {WorkbenchCapabilities} from '../workbench-capabilities.enum'; /** * Represents a microfrontend for display in a workbench view. * - * A view is a visual workbench component for displaying content stacked or side-by-side. + * A view is a visual workbench element for displaying content stacked or side-by-side. + * + * The microfrontend can inject the {@link WorkbenchView} handle to interact with the view, such as setting the title, reading + * parameters, or closing it. * * @category View + * @see WorkbenchView + * @see WorkbenchRouter */ export interface WorkbenchViewCapability extends Capability { - + /** + * @inheritDoc + */ type: WorkbenchCapabilities.View; - /** * Qualifies this view. The qualifier is required for views. + * + * @inheritDoc */ qualifier: Qualifier; - + /** + * Specifies parameters required by the view. + * + * Parameters are available in the path and title for placeholder substitution, or can be read in the microfrontend by injecting the {@link WorkbenchView} handle. + * + * @inheritDoc + */ params?: ViewParamDefinition[]; - + /** + * @inheritDoc + */ properties: { /** - * Specifies the path of the microfrontend to be opened when navigating to this view capability. + * Specifies the path to the microfrontend. * - * The path is relative to the base URL, as specified in the application manifest. If the + * The path is relative to the base URL specified in the application manifest. If the * application does not declare a base URL, it is relative to the origin of the manifest file. * - * You can refer to qualifiers or parameters in the form of named parameters to be replaced during navigation. - * Named parameters begin with a colon (`:`) followed by the parameter name or qualifier key, and are allowed in path segments, - * query parameters, matrix parameters and the fragment part. Empty query and matrix params are removed, but not empty path params. - * - * In addition to using qualifier and parameter values as named parameters in the URL, params are available in the microfrontend via {@link WorkbenchView.params$} Observable. + * The path supports placeholders that will be replaced with parameter values. A placeholder + * starts with a colon (`:`) followed by the parameter name. * - * #### Usage of named parameters in the path: + * Usage: * ```json * { * "type": "view", @@ -56,33 +69,24 @@ export interface WorkbenchViewCapability extends Capability { * } * } * ``` - * - * #### Path parameter example: - * segment/:param1/segment/:param2 - * - * #### Matrix parameter example: - * segment/segment;matrixParam1=:param1;matrixParam2=:param2 - * - * #### Query parameter example: - * segment/segment?queryParam1=:param1&queryParam2=:param2 */ path: string; /** - * Specifies the title to be displayed in the view tab. + * Specifies the title of this view. * - * You can refer to qualifiers or parameters in the form of named parameters to be replaced during navigation. - * Named parameters begin with a colon (`:`) followed by the parameter name or qualifier key. + * The title supports placeholders that will be replaced with parameter values. A placeholder starts with a colon (`:`) followed by the parameter name. + * The title can also be set in the microfrontend via {@link WorkbenchView} handle. */ title?: string; /** - * Specifies the subtitle to be displayed in the view tab. + * Specifies the subtitle of this view. * - * You can refer to qualifiers or parameters in the form of named parameters to be replaced during navigation. - * Named parameters begin with a colon (`:`) followed by the parameter name or qualifier key. + * The heading supports placeholders that will be replaced with parameter values. A placeholder starts with a colon (`:`) followed by the parameter name. + * The heading can also be set in the microfrontend via {@link WorkbenchView} handle. */ heading?: string; /** - * Specifies if a close button should be displayed in the view tab. + * Specifies if to display a close button in the view tab. Default is `true`. */ closable?: boolean; /** @@ -94,7 +98,7 @@ export interface WorkbenchViewCapability extends Capability { */ showSplash?: boolean; /** - * Specifies CSS class(es) to be added to the view, useful in end-to-end tests for locating view and view tab. + * Specifies CSS class(es) to add to the view, e.g., to locate the view in tests. */ cssClass?: string | string[]; }; @@ -102,18 +106,23 @@ export interface WorkbenchViewCapability extends Capability { /** * Describes a parameter to be passed along with a view intent. + * + * @category View + * @inheritDoc */ export interface ViewParamDefinition extends ParamDefinition { /** - * Controls how the workbench router should pass the parameter to the workbench view that embeds the microfrontend. + * Controls how the workbench router should pass this parameter to the workbench view. + * + * By default, parameters are passed via the workbench URL as matrix parameters. + * Marking a parameter as "transient" instructs the router to pass it via navigational state, useful for large objects. * - * By default, the workbench router passes the parameter via the workbench URL as matrix parameter to the workbench view - * that embeds the microfrontend. By marking the parameter as "transient", you can instruct the workbench router to pass it - * via navigational state instead of the workbench URL, for example to pass large objects. Since a transient parameter is not - * included in the workbench URL, it does not survive a page reload, i.e., is only available during the initial navigation of - * the microfrontend. Consequently, the microfrontend must be able to restore its state without this parameter present. + * Transient parameters are stored in the browser's session history, supporting back/forward navigation, but are lost on page reload. + * Therefore, microfrontends must be able to restore their state without relying on transient parameters. */ transient?: boolean; - + /** + * @inheritDoc + */ [property: string]: any; } diff --git a/projects/scion/workbench-client/src/lib/view/workbench-view-initializer.ts b/projects/scion/workbench-client/src/lib/view/workbench-view-initializer.ts index b0aa28f3f..24512d115 100644 --- a/projects/scion/workbench-client/src/lib/view/workbench-view-initializer.ts +++ b/projects/scion/workbench-client/src/lib/view/workbench-view-initializer.ts @@ -10,7 +10,7 @@ import {Beans, Initializer} from '@scion/toolkit/bean-manager'; import {ContextService} from '@scion/microfrontend-platform'; -import {WorkbenchView} from './workbench-view'; +import {ViewId, WorkbenchView} from './workbench-view'; import {ɵVIEW_ID_CONTEXT_KEY, ɵWorkbenchView} from './ɵworkbench-view'; /** @@ -21,7 +21,7 @@ import {ɵVIEW_ID_CONTEXT_KEY, ɵWorkbenchView} from './ɵworkbench-view'; export class WorkbenchViewInitializer implements Initializer { public async init(): Promise { - const viewId = await Beans.get(ContextService).lookup(ɵVIEW_ID_CONTEXT_KEY); + const viewId = await Beans.get(ContextService).lookup(ɵVIEW_ID_CONTEXT_KEY); if (viewId !== null) { const workbenchView = new ɵWorkbenchView(viewId); Beans.register(WorkbenchView, {useValue: workbenchView}); diff --git a/projects/scion/workbench-client/src/lib/view/workbench-view.ts b/projects/scion/workbench-client/src/lib/view/workbench-view.ts index 0fdb84143..bfeb643b5 100644 --- a/projects/scion/workbench-client/src/lib/view/workbench-view.ts +++ b/projects/scion/workbench-client/src/lib/view/workbench-view.ts @@ -12,25 +12,21 @@ import {Observable} from 'rxjs'; import {WorkbenchViewCapability} from './workbench-view-capability'; /** - * A view is a visual workbench component for displaying content stacked or side-by-side. + * Handle to interact with a view opened via {@link WorkbenchRouter}. * - * If a microfrontend lives in the context of a workbench view, regardless of its embedding level, it can inject an instance - * of this class to interact with the workbench view, such as setting view tab properties or closing the view. It further - * provides you access to the microfrontend capability and passed parameters. - * - * This object's lifecycle is bound to the workbench view and not to the navigation. In other words: If using hash-based routing - * in your app, no new instance will be constructed when navigating to a different microfrontend of the same application, or when - * re-routing to the same view capability, e.g., for updating the browser URL to persist navigation. Consequently, do not forget - * to unsubscribe from Observables of this class before displaying another microfrontend. + * The view microfrontend can inject this handle to interact with the view, such as setting the title, + * reading parameters, or closing it. * * @category View + * @see WorkbenchViewCapability + * @see WorkbenchRouter */ export abstract class WorkbenchView { /** * Represents the identity of this workbench view. */ - public abstract readonly id: string; + public abstract readonly id: ViewId; /** * Signals readiness, notifying the workbench that this view has completed initialization. @@ -179,3 +175,12 @@ export class ViewClosingEvent { export interface ViewSnapshot { params: ReadonlyMap; } + +/** + * Format of a view identifier. + * + * Each view is assigned a unique identifier (e.g., `view.1`, `view.2`, etc.). + * + * @category View + */ +export type ViewId = `view.${number}`; diff --git "a/projects/scion/workbench-client/src/lib/view/\311\265workbench-view.ts" "b/projects/scion/workbench-client/src/lib/view/\311\265workbench-view.ts" index e3ae41246..2d03c90c6 100644 --- "a/projects/scion/workbench-client/src/lib/view/\311\265workbench-view.ts" +++ "b/projects/scion/workbench-client/src/lib/view/\311\265workbench-view.ts" @@ -16,7 +16,7 @@ import {ɵWorkbenchCommands} from '../ɵworkbench-commands'; import {distinctUntilChanged, filter, map, mergeMap, shareReplay, skip, switchMap, takeUntil, tap} from 'rxjs/operators'; import {ɵMicrofrontendRouteParams} from '../routing/workbench-router'; import {Observables} from '@scion/toolkit/util'; -import {ViewClosingEvent, ViewClosingListener, ViewSnapshot, WorkbenchView} from './workbench-view'; +import {ViewClosingEvent, ViewClosingListener, ViewId, ViewSnapshot, WorkbenchView} from './workbench-view'; import {decorateObservable} from '../observable-decorator'; export class ɵWorkbenchView implements WorkbenchView, PreDestroy { @@ -43,7 +43,7 @@ export class ɵWorkbenchView implements WorkbenchView, PreDestroy { params: new Map(), }; - constructor(public id: string) { + constructor(public id: ViewId) { this._beforeUnload$ = Beans.get(MessageClient).observe$(ɵWorkbenchCommands.viewUnloadingTopic(this.id)) .pipe(map(() => undefined)); diff --git a/projects/scion/workbench-client/src/lib/workbench-capabilities.enum.ts b/projects/scion/workbench-client/src/lib/workbench-capabilities.enum.ts index 8a8667e9b..cfaadf4d6 100644 --- a/projects/scion/workbench-client/src/lib/workbench-capabilities.enum.ts +++ b/projects/scion/workbench-client/src/lib/workbench-capabilities.enum.ts @@ -15,7 +15,7 @@ export enum WorkbenchCapabilities { /** * Contributes a microfrontend for display in workbench view. * - * A view is a visual workbench component for displaying content stacked or side-by-side. + * A view is a visual workbench element for displaying content stacked or side-by-side. */ View = 'view', /** diff --git "a/projects/scion/workbench-client/src/lib/\311\265workbench-commands.ts" "b/projects/scion/workbench-client/src/lib/\311\265workbench-commands.ts" index 11fc71eca..1826610b7 100644 --- "a/projects/scion/workbench-client/src/lib/\311\265workbench-commands.ts" +++ "b/projects/scion/workbench-client/src/lib/\311\265workbench-commands.ts" @@ -8,6 +8,8 @@ * SPDX-License-Identifier: EPL-2.0 */ +import {ViewId} from './view/workbench-view'; + /** * Defines command endpoints for the communication between SCION Workbench and SCION Workbench Client. * @@ -18,34 +20,34 @@ export const ɵWorkbenchCommands = { /** * Computes the topic via which the title of a workbench view tab can be set. */ - viewTitleTopic: (viewId: string) => `ɵworkbench/views/${viewId}/title`, + viewTitleTopic: (viewId: ViewId | ':viewId') => `ɵworkbench/views/${viewId}/title`, /** * Computes the topic via which the heading of a workbench view tab can be set. */ - viewHeadingTopic: (viewId: string) => `ɵworkbench/views/${viewId}/heading`, + viewHeadingTopic: (viewId: ViewId | ':viewId') => `ɵworkbench/views/${viewId}/heading`, /** * Computes the topic via which a view tab can be marked dirty or pristine. */ - viewDirtyTopic: (viewId: string) => `ɵworkbench/views/${viewId}/dirty`, + viewDirtyTopic: (viewId: ViewId | ':viewId') => `ɵworkbench/views/${viewId}/dirty`, /** * Computes the topic via which a view tab can be made closable. */ - viewClosableTopic: (viewId: string) => `ɵworkbench/views/${viewId}/closable`, + viewClosableTopic: (viewId: ViewId | ':viewId') => `ɵworkbench/views/${viewId}/closable`, /** * Computes the topic via which a view can be closed. */ - viewCloseTopic: (viewId: string) => `ɵworkbench/views/${viewId}/close`, + viewCloseTopic: (viewId: ViewId | ':viewId') => `ɵworkbench/views/${viewId}/close`, /** * Computes the topic for notifying about view active state changes. * * The active state is published as a retained message. */ - viewActiveTopic: (viewId: string) => `ɵworkbench/views/${viewId}/active`, + viewActiveTopic: (viewId: ViewId) => `ɵworkbench/views/${viewId}/active`, /** * Computes the topic for signaling that a view is about to be closed. @@ -54,17 +56,17 @@ export const ɵWorkbenchCommands = { * a closing confirmation via this topic. By sending a `true` reply, the workbench continues with closing the view, * by sending a `false` reply, closing is prevented. */ - viewClosingTopic: (viewId: string) => `ɵworkbench/views/${viewId}/closing`, + viewClosingTopic: (viewId: ViewId) => `ɵworkbench/views/${viewId}/closing`, /** * Computes the topic for signaling that a microfrontend is about to be replaced by a microfrontend of another app. */ - viewUnloadingTopic: (viewId: string) => `ɵworkbench/views/${viewId}/unloading`, + viewUnloadingTopic: (viewId: ViewId) => `ɵworkbench/views/${viewId}/unloading`, /** * Computes the topic for updating params of a microfrontend view. */ - viewParamsUpdateTopic: (viewId: string, viewCapabilityId: string) => `ɵworkbench/views/${viewId}/capabilities/${viewCapabilityId}/params/update`, + viewParamsUpdateTopic: (viewId: ViewId, viewCapabilityId: string) => `ɵworkbench/views/${viewId}/capabilities/${viewCapabilityId}/params/update`, /** * Computes the topic for providing params to a view microfrontend. @@ -74,7 +76,7 @@ export const ɵWorkbenchCommands = { * * Params are published as a retained message. */ - viewParamsTopic: (viewId: string) => `ɵworkbench/views/${viewId}/params`, + viewParamsTopic: (viewId: ViewId) => `ɵworkbench/views/${viewId}/params`, /** * Computes the topic for observing the popup origin. diff --git a/projects/scion/workbench-client/src/public-api.ts b/projects/scion/workbench-client/src/public-api.ts index e54b5a67b..60467b54c 100644 --- a/projects/scion/workbench-client/src/public-api.ts +++ b/projects/scion/workbench-client/src/public-api.ts @@ -14,7 +14,7 @@ export {WorkbenchClient} from './lib/workbench-client'; export {WorkbenchRouter, WorkbenchNavigationExtras, ɵMicrofrontendRouteParams, ɵViewParamsUpdateCommand} from './lib/routing/workbench-router'; export {WorkbenchViewCapability, ViewParamDefinition} from './lib/view/workbench-view-capability'; -export {WorkbenchView, ViewClosingListener, ViewClosingEvent, ViewSnapshot} from './lib/view/workbench-view'; +export {WorkbenchView, ViewClosingListener, ViewClosingEvent, ViewSnapshot, ViewId} from './lib/view/workbench-view'; export {ɵVIEW_ID_CONTEXT_KEY, ɵWorkbenchView} from './lib/view/ɵworkbench-view'; export {WorkbenchCapabilities} from './lib/workbench-capabilities.enum'; export {ɵWorkbenchCommands} from './lib/ɵworkbench-commands'; diff --git a/projects/scion/workbench/README.md b/projects/scion/workbench/README.md index d3b3130c7..2f75c2610 100644 --- a/projects/scion/workbench/README.md +++ b/projects/scion/workbench/README.md @@ -7,7 +7,7 @@ The workbench layout is a grid of parts. Parts are aligned relative to each othe The layout can be divided into a main and a peripheral area, with the main area as the primary place for opening views. The peripheral area arranges parts around the main area to provide navigation or context-sensitive assistance to support the user's workflow. Defining a main area is optional and recommended for applications requiring a dedicated and maximizable area for user interaction. -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. +Multiple layouts, called perspectives, are supported. Perspectives can be switched. Only one perspective is active at a time. Perspectives share the same main area, if any. The sources for this package are in [SCION Workbench](https://github.com/SchweizerischeBundesbahnen/scion-workbench) repo. Please file issues and pull requests against that repo. diff --git a/projects/scion/workbench/src/lib/common/grid-element-if-visible.pipe.ts b/projects/scion/workbench/src/lib/common/grid-element-if-visible.pipe.ts index d78d1cc2b..6b8d262a0 100644 --- a/projects/scion/workbench/src/lib/common/grid-element-if-visible.pipe.ts +++ b/projects/scion/workbench/src/lib/common/grid-element-if-visible.pipe.ts @@ -10,7 +10,7 @@ import {Pipe, PipeTransform} from '@angular/core'; import {MPart, MTreeNode} from '../layout/workbench-layout.model'; -import {isGridElementVisible} from '../layout/ɵworkbench-layout'; +import {WorkbenchLayouts} from '../layout/workbench-layouts.util'; /** * Returns given grid element, but only if visible. @@ -21,7 +21,7 @@ import {isGridElementVisible} from '../layout/ɵworkbench-layout'; export class GridElementIfVisiblePipe implements PipeTransform { public transform(gridElement: MTreeNode | MPart | null | undefined): MTreeNode | MPart | null { - if (gridElement && isGridElementVisible(gridElement)) { + if (gridElement && WorkbenchLayouts.isGridElementVisible(gridElement)) { return gridElement; } return null; diff --git a/projects/scion/workbench/src/lib/common/objects.util.spec.ts b/projects/scion/workbench/src/lib/common/objects.util.spec.ts new file mode 100644 index 000000000..3597adc74 --- /dev/null +++ b/projects/scion/workbench/src/lib/common/objects.util.spec.ts @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2018-2024 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 {Objects} from './objects.util'; + +describe('Objects.keys', () => { + + it('should return keys', () => { + const object = {key1: 'value1', key2: 'value2'}; + expect(Objects.keys(object)).toEqual(['key1', 'key2']); + }); + + it('should preserve data type of keys', () => { + type Key = `key.${number}`; + const object: Record = {'key.1': 'value1', 'key.2': 'value2'}; + expect(Objects.keys(object) satisfies Key[]).toEqual(['key.1', 'key.2']); + }); +}); + +describe('Objects.entries', () => { + + it('should return entries', () => { + const object = {key1: 'value1', key2: 'value2'}; + expect(Objects.entries(object)).toEqual([['key1', 'value1'], ['key2', 'value2']]); + }); + + it('should preserve data type of keys', () => { + type Key = `key.${number}`; + const object: Record = {'key.1': 'value1', 'key.2': 'value2'}; + expect(Objects.entries(object) satisfies Array<[Key, string]>).toEqual([['key.1', 'value1'], ['key.2', 'value2']]); + }); +}); + +describe('Objects.withoutUndefinedEntries', () => { + + it('should preserve data type', () => { + type Type = {key1?: string; key2?: string; key3?: string}; + const object: Type = {key1: 'value1', key2: undefined, key3: 'value3'}; + expect(Objects.withoutUndefinedEntries(object)).toEqual({key1: 'value1', key3: 'value3'}); + }); +}); diff --git a/projects/scion/workbench/src/lib/common/objects.util.ts b/projects/scion/workbench/src/lib/common/objects.util.ts new file mode 100644 index 000000000..d6b946c86 --- /dev/null +++ b/projects/scion/workbench/src/lib/common/objects.util.ts @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2018-2024 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 {Dictionaries} from '@scion/toolkit/util'; + +/** + * Provides helper functions for working with objects. + */ +export const Objects = { + + /** + * Like {@link Object.keys}, but preserving the data type of keys. + */ + keys: (object: T): Array => { + return Object.keys(object as Record) as Array; + }, + + /** + * Like {@link Object.entries}, but preserving the data type of keys. + */ + entries: (object: Record | ArrayLike): Array<[K, V]> => { + return Object.entries(object) as Array<[K, V]>; + }, + + /** + * Like {@link Dictionaries.withoutUndefinedEntries}, but preserving the object data type. + */ + withoutUndefinedEntries: (object: T & Record): T => { + return Dictionaries.withoutUndefinedEntries(object) as T; + }, +} as const; diff --git a/projects/scion/workbench/src/lib/dialog/workbench-dialog.options.ts b/projects/scion/workbench/src/lib/dialog/workbench-dialog.options.ts index 93ae9ca42..4cd1f7806 100644 --- a/projects/scion/workbench/src/lib/dialog/workbench-dialog.options.ts +++ b/projects/scion/workbench/src/lib/dialog/workbench-dialog.options.ts @@ -9,6 +9,7 @@ */ import {Injector} from '@angular/core'; +import {ViewId} from '../view/workbench-view.model'; /** * Controls how to open a dialog. @@ -56,7 +57,7 @@ export interface WorkbenchDialogOptions { injector?: Injector; /** - * Specifies CSS class(es) to be added to the dialog, useful in end-to-end tests for locating the dialog. + * Specifies CSS class(es) to add to the dialog, e.g., to locate the dialog in tests. */ cssClass?: string | string[]; @@ -74,6 +75,6 @@ export interface WorkbenchDialogOptions { * * By default, if opening the dialog in the context of a view, that view is used as the contextual view. */ - viewId?: string; + viewId?: ViewId; }; } diff --git a/projects/scion/workbench/src/lib/dialog/workbench-dialog.registry.ts b/projects/scion/workbench/src/lib/dialog/workbench-dialog.registry.ts index 8444629aa..f5ed52436 100644 --- a/projects/scion/workbench/src/lib/dialog/workbench-dialog.registry.ts +++ b/projects/scion/workbench/src/lib/dialog/workbench-dialog.registry.ts @@ -12,6 +12,7 @@ import {Injectable, OnDestroy} from '@angular/core'; import {ɵWorkbenchDialog} from './ɵworkbench-dialog'; import {BehaviorSubject, Observable} from 'rxjs'; import {distinctUntilChanged, map} from 'rxjs/operators'; +import {ViewId} from '../view/workbench-view.model'; /** * Registry for {@link ɵWorkbenchDialog} objects. @@ -40,7 +41,7 @@ export class WorkbenchDialogRegistry implements OnDestroy { /** * 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[] { + public dialogs(filter?: {viewId?: ViewId} | ((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); } @@ -52,7 +53,7 @@ export class WorkbenchDialogRegistry implements OnDestroy { * 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 | null> { + public top$(context?: {viewId?: ViewId}): Observable<ɵWorkbenchDialog | null> { return this._dialogs$ .pipe( map(() => this.top(context)), @@ -67,7 +68,7 @@ export class WorkbenchDialogRegistry implements OnDestroy { * 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 | null { + public top(context?: {viewId?: ViewId}): ɵWorkbenchDialog | null { return this.dialogs(dialog => !dialog.context.view || dialog.context.view.id === context?.viewId).at(-1) ?? null; } diff --git a/projects/scion/workbench/src/lib/dialog/workbench-dialog.ts b/projects/scion/workbench/src/lib/dialog/workbench-dialog.ts index 67bfa8f6c..70ebccf14 100644 --- a/projects/scion/workbench/src/lib/dialog/workbench-dialog.ts +++ b/projects/scion/workbench/src/lib/dialog/workbench-dialog.ts @@ -49,7 +49,7 @@ export abstract class WorkbenchDialog { public abstract resizable: boolean; /** - * Specifies CSS class(es) to be added to the dialog, useful in end-to-end tests for locating the dialog. + * Specifies CSS class(es) to add to the dialog, e.g., to locate the dialog in tests. */ public abstract cssClass: string | string[]; diff --git a/projects/scion/workbench/src/lib/layout/grid-element/grid-element.component.ts b/projects/scion/workbench/src/lib/layout/grid-element/grid-element.component.ts index b20a2f64d..7fd35234d 100644 --- a/projects/scion/workbench/src/lib/layout/grid-element/grid-element.component.ts +++ b/projects/scion/workbench/src/lib/layout/grid-element/grid-element.component.ts @@ -17,7 +17,7 @@ import {PortalModule} from '@angular/cdk/portal'; import {PartPortalPipe} from '../../part/part-portal.pipe'; import {NgFor, NgIf} from '@angular/common'; import {SciSashboxComponent, SciSashDirective} from '@scion/components/sashbox'; -import {isGridElementVisible} from '../ɵworkbench-layout'; +import {WorkbenchLayouts} from '../workbench-layouts.util'; /** * Renders a {@link MTreeNode} or {@link MPart}. @@ -86,8 +86,8 @@ export class GridElementComponent implements OnChanges { } private computeChildren(treeNode: MTreeNode): ChildElement[] { - const child1Visible = isGridElementVisible(treeNode.child1); - const child2Visible = isGridElementVisible(treeNode.child2); + const child1Visible = WorkbenchLayouts.isGridElementVisible(treeNode.child1); + const child2Visible = WorkbenchLayouts.isGridElementVisible(treeNode.child2); if (child1Visible && child2Visible) { const [size1, size2] = calculateSashSizes(treeNode.ratio); diff --git a/projects/scion/workbench/src/lib/layout/main-area-layout/main-area-layout.component.ts b/projects/scion/workbench/src/lib/layout/main-area-layout/main-area-layout.component.ts index 0b9beb086..d36b2f338 100644 --- a/projects/scion/workbench/src/lib/layout/main-area-layout/main-area-layout.component.ts +++ b/projects/scion/workbench/src/lib/layout/main-area-layout/main-area-layout.component.ts @@ -86,7 +86,9 @@ export class MainAreaLayoutComponent { workbenchId: event.dragData.workbenchId, partId: event.dragData.partId, viewId: event.dragData.viewId, + alternativeViewId: event.dragData.alternativeViewId, viewUrlSegments: event.dragData.viewUrlSegments, + navigationHint: event.dragData.navigationHint, classList: event.dragData.classList, }, target: GridDropTargets.resolve({ diff --git a/projects/scion/workbench/src/lib/layout/migration/model/workbench-layout-migration-v1.model.ts b/projects/scion/workbench/src/lib/layout/migration/model/workbench-layout-migration-v1.model.ts new file mode 100644 index 000000000..43e266fd6 --- /dev/null +++ b/projects/scion/workbench/src/lib/layout/migration/model/workbench-layout-migration-v1.model.ts @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2018-2024 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 interface MPartV1 { + partId: string; + viewIds: string[]; + activeViewId?: string; +} + +export interface MTreeNodeV1 { + nodeId: string; + child1: MTreeNodeV1 | MPartV1; + child2: MTreeNodeV1 | MPartV1; + ratio: number; + direction: 'column' | 'row'; +} + +export interface MPartsLayoutV1 { + root: MTreeNodeV1 | MPartV1; + activePartId: string; +} diff --git a/projects/scion/workbench/src/lib/layout/migration/model/workbench-layout-migration-v2.model.ts b/projects/scion/workbench/src/lib/layout/migration/model/workbench-layout-migration-v2.model.ts new file mode 100644 index 000000000..f1e0c63e2 --- /dev/null +++ b/projects/scion/workbench/src/lib/layout/migration/model/workbench-layout-migration-v2.model.ts @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2018-2024 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 interface MPartV2 { + type: 'MPart'; + id: string; + views: MViewV2[]; + activeViewId?: string; + structural: boolean; +} + +export interface MTreeNodeV2 { + type: 'MTreeNode'; + nodeId: string; + child1: MTreeNodeV2 | MPartV2; + child2: MTreeNodeV2 | MPartV2; + ratio: number; + direction: 'column' | 'row'; +} + +export interface MPartGridV2 { + root: MTreeNodeV2 | MPartV2; + activePartId: string; +} + +export interface MViewV2 { + id: string; +} diff --git a/projects/scion/workbench/src/lib/layout/migration/model/workbench-layout-migration-v3.model.ts b/projects/scion/workbench/src/lib/layout/migration/model/workbench-layout-migration-v3.model.ts new file mode 100644 index 000000000..d75f1fe46 --- /dev/null +++ b/projects/scion/workbench/src/lib/layout/migration/model/workbench-layout-migration-v3.model.ts @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2018-2024 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 interface MPartV3 { + type: 'MPart'; + id: string; + views: MViewV3[]; + activeViewId?: ViewIdV3; + structural: boolean; +} + +export interface MTreeNodeV3 { + type: 'MTreeNode'; + nodeId: string; + child1: MTreeNodeV3 | MPartV3; + child2: MTreeNodeV3 | MPartV3; + ratio: number; + direction: 'column' | 'row'; +} + +export interface MPartGridV3 { + root: MTreeNodeV3 | MPartV3; + activePartId: string; +} + +export interface MViewV3 { + id: ViewIdV3; + alternativeId?: string; + cssClass?: string[]; + navigation?: { + hint?: string; + cssClass?: string[]; + }; +} + +export type ViewIdV3 = `view.${number}`; + +export const VIEW_ID_PREFIX_V3 = 'view.'; diff --git a/projects/scion/workbench/src/lib/layout/migration/workbench-layout-v1-migrator.service.ts b/projects/scion/workbench/src/lib/layout/migration/workbench-layout-migration-v2.service.ts similarity index 68% rename from projects/scion/workbench/src/lib/layout/migration/workbench-layout-v1-migrator.service.ts rename to projects/scion/workbench/src/lib/layout/migration/workbench-layout-migration-v2.service.ts index 4e25e98dd..1afba079e 100644 --- a/projects/scion/workbench/src/lib/layout/migration/workbench-layout-v1-migrator.service.ts +++ b/projects/scion/workbench/src/lib/layout/migration/workbench-layout-migration-v2.service.ts @@ -9,26 +9,27 @@ */ import {Injectable} from '@angular/core'; -import {MPart, MPartGrid, MTreeNode} from '../workbench-layout.model'; +import {MPartsLayoutV1, MPartV1, MTreeNodeV1} from './model/workbench-layout-migration-v1.model'; +import {MPartGridV2, MPartV2, MTreeNodeV2} from './model/workbench-layout-migration-v2.model'; +import {WorkbenchMigration} from '../../migration/workbench-migration'; /** - * Migrates a workbench layout in version 1 to the latest version. + * Migrates the workbench layout from version 1 to version 2. * - * TODO [Angular 18] Remove migrator. + * TODO [Angular 20] Remove migrator. */ @Injectable({providedIn: 'root'}) -export class WorkbenchLayoutV1Migrator { - +export class WorkbenchLayoutMigrationV2 implements WorkbenchMigration { public migrate(json: string): string { const partsLayoutV1: MPartsLayoutV1 = JSON.parse(json); - const partsGrid: MPartGrid = { + const partsGridV2: MPartGridV2 = { root: this.migrateGridElement(partsLayoutV1.root), activePartId: partsLayoutV1.activePartId, }; - return JSON.stringify(partsGrid); + return JSON.stringify(partsGridV2); } - private migrateGridElement(elementV1: MTreeNodeV1 | MPartV1): MTreeNode | MPart { + private migrateGridElement(elementV1: MTreeNodeV1 | MPartV1): MTreeNodeV2 | MPartV2 { if (elementV1.hasOwnProperty('partId')) { // eslint-disable-line no-prototype-builtins const partV1 = elementV1 as MPartV1; return { @@ -51,27 +52,7 @@ export class WorkbenchLayoutV1Migrator { }; } else { - throw Error(`[WorkbenchLayoutError] Unable to migrate to the latest version. Expected element to be of type 'MPart' or 'MTreeNode'. [version=1, element=${elementV1}]`); + throw Error(`[WorkbenchLayoutError] Unable to migrate to the latest version. Expected element to be of type 'MPart' or 'MTreeNode'. [version=1, element=${JSON.stringify(elementV1)}]`); } } } - -interface MPartsLayoutV1 { - root: MTreeNodeV1 | MPartV1; - activePartId: string; -} - -interface MTreeNodeV1 { - nodeId: string; - child1: MTreeNodeV1 | MPartV1; - child2: MTreeNodeV1 | MPartV1; - ratio: number; - direction: 'column' | 'row'; -} - -interface MPartV1 { - partId: string; - parent?: MTreeNodeV1; - viewIds: string[]; - activeViewId?: string; -} diff --git a/projects/scion/workbench/src/lib/layout/migration/workbench-layout-migration-v3.service.ts b/projects/scion/workbench/src/lib/layout/migration/workbench-layout-migration-v3.service.ts new file mode 100644 index 000000000..f79ae86ac --- /dev/null +++ b/projects/scion/workbench/src/lib/layout/migration/workbench-layout-migration-v3.service.ts @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2018-2024 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 {MPartGridV2, MPartV2, MTreeNodeV2, MViewV2} from './model/workbench-layout-migration-v2.model'; +import {MPartGridV3, MPartV3, MTreeNodeV3, MViewV3, VIEW_ID_PREFIX_V3, ViewIdV3} from './model/workbench-layout-migration-v3.model'; +import {Router, UrlTree} from '@angular/router'; +import {WorkbenchMigration} from '../../migration/workbench-migration'; +import {RouterUtils} from '../../routing/router.util'; + +/** + * Migrates the workbench layout from version 2 to version 3. + * + * TODO [Angular 20] Remove migrator. + */ +@Injectable({providedIn: 'root'}) +export class WorkbenchLayoutMigrationV3 implements WorkbenchMigration { + + constructor(private _router: Router) { + } + + public migrate(json: string): string { + const partGridV2: MPartGridV2 = JSON.parse(json); + + // Consider the ids of views contained in the URL as already used. + // Otherwise, when migrating the main area and using a view id already present in the perspective, + // the view outlet would not be removed from the URL, resulting the migrated view to display + // "Page Not Found" or incorrect content. + const viewOutlets = RouterUtils.parseViewOutlets(this.getCurrentUrl()); + const usedViewIds = new Set([...viewOutlets.keys(), ...collectViewIds(partGridV2.root)]); + + // Migrate the grid. + const partGridV3: MPartGridV3 = { + root: migrateGridElement(partGridV2.root), + activePartId: partGridV2.activePartId, + }; + return JSON.stringify(partGridV3); + + function migrateGridElement(elementV2: MTreeNodeV2 | MPartV2): MTreeNodeV3 | MPartV3 { + switch (elementV2.type) { + case 'MTreeNode': + return migrateNode(elementV2); + case 'MPart': + return migratePart(elementV2); + default: + throw Error(`[WorkbenchLayoutError] Unable to migrate to the latest version. Expected element to be of type 'MPart' or 'MTreeNode'. [version=2, element=${JSON.stringify(elementV2)}]`); + } + } + + function migrateNode(nodeV2: MTreeNodeV2): MTreeNodeV3 { + return { + type: 'MTreeNode', + nodeId: nodeV2.nodeId, + child1: migrateGridElement(nodeV2.child1), + child2: migrateGridElement(nodeV2.child2), + ratio: nodeV2.ratio, + direction: nodeV2.direction, + }; + } + + function migratePart(partV2: MPartV2): MPartV3 { + const partV3: MPartV3 = { + type: 'MPart', + id: partV2.id, + structural: partV2.structural, + views: [], + }; + + // Add views and set the active view. + partV2.views.forEach((viewV2: MViewV2) => { + const viewV3: MViewV3 = migrateView(viewV2); + if (partV2.activeViewId === viewV2.id) { + partV3.activeViewId = viewV3.id; + } + partV3.views.push(viewV3); + usedViewIds.add(viewV3.id); + }); + + return partV3; + } + + function migrateView(viewV2: MViewV2): MViewV3 { + if (isViewId(viewV2.id)) { + return {id: viewV2.id, navigation: {}}; + } + else { + return {id: computeNextViewId(usedViewIds), navigation: {hint: viewV2.id}}; + } + } + } + + private getCurrentUrl(): UrlTree { + return this._router.getCurrentNavigation()?.initialUrl ?? this._router.parseUrl(this._router.url); + } +} + +function computeNextViewId(viewIds: Iterable): ViewIdV3 { + const ids = Array.from(viewIds) + .map(viewId => Number(viewId.substring(VIEW_ID_PREFIX_V3.length))) + .reduce((set, id) => set.add(id), new Set()); + + for (let i = 1; i <= ids.size; i++) { + if (!ids.has(i)) { + return VIEW_ID_PREFIX_V3.concat(`${i}`) as ViewIdV3; + } + } + return VIEW_ID_PREFIX_V3.concat(`${ids.size + 1}`) as ViewIdV3; +} + +function isViewId(viewId: string): viewId is ViewIdV3 { + return viewId.startsWith(VIEW_ID_PREFIX_V3); +} + +function collectViewIds(node: MPartV2 | MTreeNodeV2): Set { + if (node.type === 'MPart') { + return new Set(node.views.map(view => view.id).filter(isViewId)); + } + else { + return new Set([...collectViewIds(node.child1), ...collectViewIds(node.child2)]); + } +} diff --git a/projects/scion/workbench/src/lib/layout/migration/workbench-layout-migrator.service.ts b/projects/scion/workbench/src/lib/layout/migration/workbench-layout-migrator.service.ts deleted file mode 100644 index 4b4730dd0..000000000 --- a/projects/scion/workbench/src/lib/layout/migration/workbench-layout-migrator.service.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* - * 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 {WorkbenchLayoutV1Migrator} from './workbench-layout-v1-migrator.service'; - -/** - * Migrates a workbench layout to the latest version. - */ -@Injectable({providedIn: 'root'}) -export class WorkbenchLayoutMigrator { - - constructor(private _workbenchLayoutV1Migrator: WorkbenchLayoutV1Migrator) { - } - - /** - * Migrates a workbench layout to the latest version. - */ - public migrate(version: number, json: string): string { - switch (version) { - case 1: - return this._workbenchLayoutV1Migrator.migrate(json); - default: - throw Error(`[WorkbenchLayoutError] Unsupported workbench layout version. Unable to migrate to the latest version. [version=${version}, layout=${json}]`); - } - } -} diff --git a/projects/scion/workbench/src/lib/layout/workbench-layout.component.spec.ts b/projects/scion/workbench/src/lib/layout/workbench-layout.component.spec.ts index 5b9324e8e..38d52177b 100644 --- a/projects/scion/workbench/src/lib/layout/workbench-layout.component.spec.ts +++ b/projects/scion/workbench/src/lib/layout/workbench-layout.component.spec.ts @@ -9,7 +9,7 @@ */ import {TestBed} from '@angular/core/testing'; -import {Router, RouterOutlet, UrlSegment} from '@angular/router'; +import {Router, RouterOutlet} from '@angular/router'; import {WorkbenchRouter} from '../routing/workbench-router.service'; import {toEqualWorkbenchLayoutCustomMatcher} from '../testing/jasmine/matcher/to-equal-workbench-layout.matcher'; import {toBeRegisteredCustomMatcher} from '../testing/jasmine/matcher/to-be-registered.matcher'; @@ -22,7 +22,7 @@ import {By} from '@angular/platform-browser'; import {MAIN_AREA} from './workbench-layout'; import {toHaveTransientStateCustomMatcher} from '../testing/jasmine/matcher/to-have-transient-state.matcher'; import {enterTransientViewState, TestComponent, withComponentContent, withTransientStateInputElement} from '../testing/test.component'; -import {styleFixture, waitForInitialWorkbenchLayout, waitUntilStable} from '../testing/testing.util'; +import {segments, styleFixture, waitForInitialWorkbenchLayout, waitUntilStable} from '../testing/testing.util'; import {WorkbenchTestingModule} from '../testing/workbench-testing.module'; import {RouterTestingModule} from '@angular/router/testing'; import {MPart, MTreeNode} from './workbench-layout.model'; @@ -46,8 +46,8 @@ describe('WorkbenchLayout', () => { imports: [ WorkbenchTestingModule.forTest(), RouterTestingModule.withRoutes([ - {path: 'view', component: TestComponent}, - {path: 'outlet', component: TestComponent, outlet: 'outlet', providers: [withComponentContent('routed content')]}, + {path: 'path/to/view', component: TestComponent}, + {path: 'path/to/outlet', component: TestComponent, outlet: 'outlet', providers: [withComponentContent('routed content')]}, ]), ], }); @@ -55,22 +55,13 @@ describe('WorkbenchLayout', () => { await waitForInitialWorkbenchLayout(); // Create initial workbench layout. - await TestBed.inject(WorkbenchRouter).ɵnavigate(layout => { - return { - layout: layout - .addPart('left', {relativeTo: MAIN_AREA, align: 'left', ratio: .2}) - .addPart('right', {relativeTo: 'main', align: 'right', ratio: .5}) - .addView('view.1', {partId: 'left', activateView: true}) - .addView('view.2', {partId: 'main', activateView: true}) - .addView('view.3', {partId: 'right', activateView: true}) - , - viewOutlets: { - 'view.1': ['view'], - 'view.2': ['view'], - 'view.3': ['view'], - }, - }; - }); + await TestBed.inject(WorkbenchRouter).ɵnavigate(layout => layout + .addPart('left', {relativeTo: MAIN_AREA, align: 'left', ratio: .2}) + .addPart('right', {relativeTo: 'main', align: 'right', ratio: .5}) + .addView('view.1', {partId: 'left', activateView: true}) + .addView('view.2', {partId: 'main', activateView: true}) + .addView('view.3', {partId: 'right', activateView: true}), + ); await waitUntilStable(); // Assert initial workbench layout @@ -94,7 +85,7 @@ describe('WorkbenchLayout', () => { }); // Navigate using the Angular router. - await TestBed.inject(Router).navigate([{outlets: {outlet: ['outlet']}}]); + await TestBed.inject(Router).navigate([{outlets: {outlet: ['path', 'to', 'outlet']}}]); await waitUntilStable(); // Expect the layout not to be discarded. @@ -118,7 +109,7 @@ describe('WorkbenchLayout', () => { }); // Navigate using the Workbench router. - await TestBed.inject(WorkbenchRouter).navigate(['view'], {target: 'blank'}); + await TestBed.inject(WorkbenchRouter).navigate(['path/to/view'], {target: 'blank'}); await waitUntilStable(); // Expect the layout to be changed. @@ -147,7 +138,7 @@ describe('WorkbenchLayout', () => { imports: [ WorkbenchTestingModule.forTest({startup: {launcher: 'APP_INITIALIZER'}}), RouterTestingModule.withRoutes([ - {path: 'view', component: TestComponent}, + {path: 'path/to/view', component: TestComponent}, ]), ], }); @@ -155,10 +146,10 @@ describe('WorkbenchLayout', () => { await waitForInitialWorkbenchLayout(); // GIVEN four views (view.1, view.2, view.3, view.4). - await TestBed.inject(WorkbenchRouter).navigate(['view'], {target: 'blank'}); - await TestBed.inject(WorkbenchRouter).navigate(['view'], {target: 'blank'}); - await TestBed.inject(WorkbenchRouter).navigate(['view'], {target: 'blank'}); - await TestBed.inject(WorkbenchRouter).navigate(['view'], {target: 'blank'}); + await TestBed.inject(WorkbenchRouter).navigate(['path/to/view'], {target: 'blank'}); + await TestBed.inject(WorkbenchRouter).navigate(['path/to/view'], {target: 'blank'}); + await TestBed.inject(WorkbenchRouter).navigate(['path/to/view'], {target: 'blank'}); + await TestBed.inject(WorkbenchRouter).navigate(['path/to/view'], {target: 'blank'}); // WHEN moving view.3 to position 0 TestBed.inject(ViewDragService).dispatchViewMoveEvent({ @@ -166,7 +157,7 @@ describe('WorkbenchLayout', () => { workbenchId: TestBed.inject(WORKBENCH_ID), partId: 'main', viewId: 'view.3', - viewUrlSegments: [new UrlSegment('view', {})], + viewUrlSegments: segments(['path/to/view']), }, target: { workbenchId: TestBed.inject(WORKBENCH_ID), @@ -186,7 +177,7 @@ describe('WorkbenchLayout', () => { workbenchId: TestBed.inject(WORKBENCH_ID), partId: 'main', viewId: 'view.3', - viewUrlSegments: [new UrlSegment('view', {})], + viewUrlSegments: segments(['path/to/view']), }, target: { workbenchId: TestBed.inject(WORKBENCH_ID), @@ -206,7 +197,7 @@ describe('WorkbenchLayout', () => { workbenchId: TestBed.inject(WORKBENCH_ID), partId: 'main', viewId: 'view.3', - viewUrlSegments: [new UrlSegment('view', {})], + viewUrlSegments: segments(['path/to/view']), }, target: { workbenchId: TestBed.inject(WORKBENCH_ID), @@ -226,7 +217,7 @@ describe('WorkbenchLayout', () => { workbenchId: TestBed.inject(WORKBENCH_ID), partId: 'main', viewId: 'view.3', - viewUrlSegments: [new UrlSegment('view', {})], + viewUrlSegments: segments(['path/to/view']), }, target: { workbenchId: TestBed.inject(WORKBENCH_ID), @@ -246,7 +237,7 @@ describe('WorkbenchLayout', () => { workbenchId: TestBed.inject(WORKBENCH_ID), partId: 'main', viewId: 'view.3', - viewUrlSegments: [new UrlSegment('view', {})], + viewUrlSegments: segments(['path/to/view']), }, target: { workbenchId: TestBed.inject(WORKBENCH_ID), @@ -266,8 +257,8 @@ describe('WorkbenchLayout', () => { imports: [ WorkbenchTestingModule.forTest({startup: {launcher: 'APP_INITIALIZER'}}), RouterTestingModule.withRoutes([ - {path: 'view-1', component: TestComponent, providers: [withTransientStateInputElement()]}, - {path: 'view-2', component: TestComponent, providers: [withTransientStateInputElement()]}, + {path: 'path/to/view/1', component: TestComponent, providers: [withTransientStateInputElement()]}, + {path: 'path/to/view/2', component: TestComponent, providers: [withTransientStateInputElement()]}, ]), ], }); @@ -275,7 +266,7 @@ describe('WorkbenchLayout', () => { await waitForInitialWorkbenchLayout(); // Add view 1 - await TestBed.inject(WorkbenchRouter).navigate(['view-1']); + await TestBed.inject(WorkbenchRouter).navigate(['path/to/view/1']); await waitUntilStable(); enterTransientViewState(fixture, 'view.1', 'A'); @@ -288,7 +279,7 @@ describe('WorkbenchLayout', () => { expect('view.1').toHaveTransientState('A'); // Add view 2 - await TestBed.inject(WorkbenchRouter).navigate(['view-2']); + await TestBed.inject(WorkbenchRouter).navigate(['path/to/view/2']); await waitUntilStable(); enterTransientViewState(fixture, 'view.2', 'B'); @@ -308,7 +299,7 @@ describe('WorkbenchLayout', () => { workbenchId: TestBed.inject(WORKBENCH_ID), partId: 'main', viewId: 'view.2', - viewUrlSegments: [new UrlSegment('view-2', {})], + viewUrlSegments: segments(['path/to/view/2']), }, target: { workbenchId: TestBed.inject(WORKBENCH_ID), @@ -340,8 +331,8 @@ describe('WorkbenchLayout', () => { imports: [ WorkbenchTestingModule.forTest({startup: {launcher: 'APP_INITIALIZER'}}), RouterTestingModule.withRoutes([ - {path: 'view-1', component: TestComponent, providers: [withTransientStateInputElement()]}, - {path: 'view-2', component: TestComponent, providers: [withTransientStateInputElement()]}, + {path: 'path/to/view/1', component: TestComponent, providers: [withTransientStateInputElement()]}, + {path: 'path/to/view/2', component: TestComponent, providers: [withTransientStateInputElement()]}, ]), ], }); @@ -349,7 +340,7 @@ describe('WorkbenchLayout', () => { await waitForInitialWorkbenchLayout(); // Add view 1 - await TestBed.inject(WorkbenchRouter).navigate(['view-1']); + await TestBed.inject(WorkbenchRouter).navigate(['path/to/view/1']); await waitUntilStable(); enterTransientViewState(fixture, 'view.1', 'A'); @@ -362,7 +353,7 @@ describe('WorkbenchLayout', () => { expect('view.1').toHaveTransientState('A'); // Add view 2 - await TestBed.inject(WorkbenchRouter).navigate(['view-2']); + await TestBed.inject(WorkbenchRouter).navigate(['path/to/view/2']); await waitUntilStable(); enterTransientViewState(fixture, 'view.2', 'B'); @@ -382,7 +373,7 @@ describe('WorkbenchLayout', () => { workbenchId: TestBed.inject(WORKBENCH_ID), partId: 'main', viewId: 'view.2', - viewUrlSegments: [new UrlSegment('view-2', {})], + viewUrlSegments: segments(['path/to/view/2']), }, target: { workbenchId: TestBed.inject(WORKBENCH_ID), @@ -414,8 +405,8 @@ describe('WorkbenchLayout', () => { imports: [ WorkbenchTestingModule.forTest({startup: {launcher: 'APP_INITIALIZER'}}), RouterTestingModule.withRoutes([ - {path: 'view-1', component: TestComponent, providers: [withTransientStateInputElement()]}, - {path: 'view-2', component: TestComponent, providers: [withTransientStateInputElement()]}, + {path: 'path/to/view/1', component: TestComponent, providers: [withTransientStateInputElement()]}, + {path: 'path/to/view/2', component: TestComponent, providers: [withTransientStateInputElement()]}, ]), ], }); @@ -423,7 +414,7 @@ describe('WorkbenchLayout', () => { await waitForInitialWorkbenchLayout(); // Add view 1 - await TestBed.inject(WorkbenchRouter).navigate(['view-1']); + await TestBed.inject(WorkbenchRouter).navigate(['path/to/view/1']); await waitUntilStable(); enterTransientViewState(fixture, 'view.1', 'A'); @@ -436,7 +427,7 @@ describe('WorkbenchLayout', () => { expect('view.1').toHaveTransientState('A'); // Add view 2 - await TestBed.inject(WorkbenchRouter).navigate(['view-2']); + await TestBed.inject(WorkbenchRouter).navigate(['path/to/view/2']); await waitUntilStable(); enterTransientViewState(fixture, 'view.2', 'B'); @@ -456,7 +447,7 @@ describe('WorkbenchLayout', () => { workbenchId: TestBed.inject(WORKBENCH_ID), partId: 'main', viewId: 'view.2', - viewUrlSegments: [new UrlSegment('view-2', {})], + viewUrlSegments: segments(['path/to/view/2']), }, target: { workbenchId: TestBed.inject(WORKBENCH_ID), @@ -488,8 +479,8 @@ describe('WorkbenchLayout', () => { imports: [ WorkbenchTestingModule.forTest({startup: {launcher: 'APP_INITIALIZER'}}), RouterTestingModule.withRoutes([ - {path: 'view-1', component: TestComponent, providers: [withTransientStateInputElement()]}, - {path: 'view-2', component: TestComponent, providers: [withTransientStateInputElement()]}, + {path: 'path/to/view/1', component: TestComponent, providers: [withTransientStateInputElement()]}, + {path: 'path/to/view/2', component: TestComponent, providers: [withTransientStateInputElement()]}, ]), ], }); @@ -497,7 +488,7 @@ describe('WorkbenchLayout', () => { await waitForInitialWorkbenchLayout(); // Add view 1 - await TestBed.inject(WorkbenchRouter).navigate(['view-1']); + await TestBed.inject(WorkbenchRouter).navigate(['path/to/view/1']); await waitUntilStable(); enterTransientViewState(fixture, 'view.1', 'A'); @@ -510,7 +501,7 @@ describe('WorkbenchLayout', () => { expect('view.1').toHaveTransientState('A'); // Add view 2 - await TestBed.inject(WorkbenchRouter).navigate(['view-2']); + await TestBed.inject(WorkbenchRouter).navigate(['path/to/view/2']); await waitUntilStable(); enterTransientViewState(fixture, 'view.2', 'B'); @@ -530,7 +521,7 @@ describe('WorkbenchLayout', () => { workbenchId: TestBed.inject(WORKBENCH_ID), partId: 'main', viewId: 'view.2', - viewUrlSegments: [new UrlSegment('view-2', {})], + viewUrlSegments: segments(['path/to/view/2']), }, target: { workbenchId: TestBed.inject(WORKBENCH_ID), @@ -562,8 +553,8 @@ describe('WorkbenchLayout', () => { imports: [ WorkbenchTestingModule.forTest({startup: {launcher: 'APP_INITIALIZER'}}), RouterTestingModule.withRoutes([ - {path: 'view-1', component: TestComponent, providers: [withTransientStateInputElement()]}, - {path: 'view-2', component: TestComponent, providers: [withTransientStateInputElement()]}, + {path: 'path/to/view/1', component: TestComponent, providers: [withTransientStateInputElement()]}, + {path: 'path/to/view/2', component: TestComponent, providers: [withTransientStateInputElement()]}, ]), ], }); @@ -571,7 +562,7 @@ describe('WorkbenchLayout', () => { await waitForInitialWorkbenchLayout(); // Add view 1 - await TestBed.inject(WorkbenchRouter).navigate(['view-1']); + await TestBed.inject(WorkbenchRouter).navigate(['path/to/view/1']); await waitUntilStable(); enterTransientViewState(fixture, 'view.1', 'A'); @@ -584,7 +575,7 @@ describe('WorkbenchLayout', () => { expect('view.1').toHaveTransientState('A'); // Add view 2 - await TestBed.inject(WorkbenchRouter).navigate(['view-2']); + await TestBed.inject(WorkbenchRouter).navigate(['path/to/view/2']); await waitUntilStable(); enterTransientViewState(fixture, 'view.2', 'B'); @@ -604,7 +595,7 @@ describe('WorkbenchLayout', () => { workbenchId: TestBed.inject(WORKBENCH_ID), partId: 'main', viewId: 'view.2', - viewUrlSegments: [new UrlSegment('view-2', {})], + viewUrlSegments: segments(['path/to/view/2']), }, target: { workbenchId: TestBed.inject(WORKBENCH_ID), @@ -629,9 +620,9 @@ describe('WorkbenchLayout', () => { imports: [ WorkbenchTestingModule.forTest({startup: {launcher: 'APP_INITIALIZER'}}), RouterTestingModule.withRoutes([ - {path: 'view-1', component: TestComponent, providers: [withTransientStateInputElement()]}, - {path: 'view-2', component: TestComponent, providers: [withTransientStateInputElement()]}, - {path: 'view-3', component: TestComponent, providers: [withTransientStateInputElement()]}, + {path: 'path/to/view/1', component: TestComponent, providers: [withTransientStateInputElement()]}, + {path: 'path/to/view/2', component: TestComponent, providers: [withTransientStateInputElement()]}, + {path: 'path/to/view/3', component: TestComponent, providers: [withTransientStateInputElement()]}, ]), ], }); @@ -639,7 +630,7 @@ describe('WorkbenchLayout', () => { await waitForInitialWorkbenchLayout(); // Add view 1 - await TestBed.inject(WorkbenchRouter).navigate(['view-1']); + await TestBed.inject(WorkbenchRouter).navigate(['path/to/view/1']); await waitUntilStable(); enterTransientViewState(fixture, 'view.1', 'A'); @@ -652,7 +643,7 @@ describe('WorkbenchLayout', () => { expect('view.1').toHaveTransientState('A'); // Add view 2 - await TestBed.inject(WorkbenchRouter).navigate(['view-2']); + await TestBed.inject(WorkbenchRouter).navigate(['path/to/view/2']); await waitUntilStable(); enterTransientViewState(fixture, 'view.2', 'B'); @@ -667,7 +658,7 @@ describe('WorkbenchLayout', () => { expect('view.2').toHaveTransientState('B'); // Add view 3 - await TestBed.inject(WorkbenchRouter).navigate(['view-3']); + await TestBed.inject(WorkbenchRouter).navigate(['path/to/view/3']); await waitUntilStable(); enterTransientViewState(fixture, 'view.3', 'C'); @@ -689,7 +680,7 @@ describe('WorkbenchLayout', () => { workbenchId: TestBed.inject(WORKBENCH_ID), partId: 'main', viewId: 'view.3', - viewUrlSegments: [new UrlSegment('view-3', {})], + viewUrlSegments: segments(['path/to/view/3']), }, target: { workbenchId: TestBed.inject(WORKBENCH_ID), @@ -724,7 +715,7 @@ describe('WorkbenchLayout', () => { workbenchId: TestBed.inject(WORKBENCH_ID), partId: 'main', viewId: 'view.2', - viewUrlSegments: [new UrlSegment('view-2', {})], + viewUrlSegments: segments(['path/to/view/2']), }, target: { workbenchId: TestBed.inject(WORKBENCH_ID), @@ -756,7 +747,7 @@ describe('WorkbenchLayout', () => { workbenchId: TestBed.inject(WORKBENCH_ID), partId: 'main', viewId: 'view.1', - viewUrlSegments: [new UrlSegment('view-1', {})], + viewUrlSegments: segments(['path/to/view/1']), }, target: { workbenchId: TestBed.inject(WORKBENCH_ID), @@ -784,8 +775,8 @@ describe('WorkbenchLayout', () => { imports: [ WorkbenchTestingModule.forTest({startup: {launcher: 'APP_INITIALIZER'}}), RouterTestingModule.withRoutes([ - {path: 'view-1', component: TestComponent, providers: [withTransientStateInputElement()]}, - {path: 'view-2', component: TestComponent, providers: [withTransientStateInputElement()]}, + {path: 'path/to/view/1', component: TestComponent, providers: [withTransientStateInputElement()]}, + {path: 'path/to/view/2', component: TestComponent, providers: [withTransientStateInputElement()]}, ]), ], }); @@ -793,7 +784,7 @@ describe('WorkbenchLayout', () => { await waitForInitialWorkbenchLayout(); // Add view 1 - await TestBed.inject(WorkbenchRouter).navigate(['view-1']); + await TestBed.inject(WorkbenchRouter).navigate(['path/to/view/1']); await waitUntilStable(); enterTransientViewState(fixture, 'view.1', 'A'); @@ -806,7 +797,7 @@ describe('WorkbenchLayout', () => { expect('view.1').toHaveTransientState('A'); // Add view 2 - await TestBed.inject(WorkbenchRouter).navigate(['view-2']); + await TestBed.inject(WorkbenchRouter).navigate(['path/to/view/2']); await waitUntilStable(); enterTransientViewState(fixture, 'view.2', 'B'); @@ -826,7 +817,7 @@ describe('WorkbenchLayout', () => { workbenchId: TestBed.inject(WORKBENCH_ID), partId: 'main', viewId: 'view.2', - viewUrlSegments: [new UrlSegment('view-2', {})], + viewUrlSegments: segments(['path/to/view/2']), }, target: { workbenchId: TestBed.inject(WORKBENCH_ID), @@ -859,7 +850,7 @@ describe('WorkbenchLayout', () => { workbenchId: TestBed.inject(WORKBENCH_ID), partId: 'EAST-1', viewId: 'view.2', - viewUrlSegments: [new UrlSegment('view-2', {})], + viewUrlSegments: segments(['path/to/view/2']), }, target: { workbenchId: TestBed.inject(WORKBENCH_ID), @@ -891,8 +882,8 @@ describe('WorkbenchLayout', () => { imports: [ WorkbenchTestingModule.forTest({startup: {launcher: 'APP_INITIALIZER'}}), RouterTestingModule.withRoutes([ - {path: 'view-1', component: TestComponent, providers: [withTransientStateInputElement()]}, - {path: 'view-2', component: TestComponent, providers: [withTransientStateInputElement()]}, + {path: 'path/to/view/1', component: TestComponent, providers: [withTransientStateInputElement()]}, + {path: 'path/to/view/2', component: TestComponent, providers: [withTransientStateInputElement()]}, ]), ], }); @@ -900,7 +891,7 @@ describe('WorkbenchLayout', () => { await waitForInitialWorkbenchLayout(); // Add view 1 - await TestBed.inject(WorkbenchRouter).navigate(['view-1']); + await TestBed.inject(WorkbenchRouter).navigate(['path/to/view/1']); await waitUntilStable(); enterTransientViewState(fixture, 'view.1', 'A'); @@ -913,7 +904,7 @@ describe('WorkbenchLayout', () => { expect('view.1').toHaveTransientState('A'); // Add view 2 - await TestBed.inject(WorkbenchRouter).navigate(['view-2']); + await TestBed.inject(WorkbenchRouter).navigate(['path/to/view/2']); await waitUntilStable(); enterTransientViewState(fixture, 'view.2', 'B'); @@ -933,7 +924,7 @@ describe('WorkbenchLayout', () => { workbenchId: TestBed.inject(WORKBENCH_ID), partId: 'main', viewId: 'view.2', - viewUrlSegments: [new UrlSegment('view-2', {})], + viewUrlSegments: segments(['path/to/view/2']), }, target: { workbenchId: TestBed.inject(WORKBENCH_ID), @@ -966,7 +957,7 @@ describe('WorkbenchLayout', () => { workbenchId: TestBed.inject(WORKBENCH_ID), partId: 'EAST', viewId: 'view.2', - viewUrlSegments: [new UrlSegment('view-2', {})], + viewUrlSegments: segments(['path/to/view/2']), }, target: { workbenchId: TestBed.inject(WORKBENCH_ID), @@ -998,8 +989,8 @@ describe('WorkbenchLayout', () => { imports: [ WorkbenchTestingModule.forTest({startup: {launcher: 'APP_INITIALIZER'}}), RouterTestingModule.withRoutes([ - {path: 'view-1', component: TestComponent, providers: [withTransientStateInputElement()]}, - {path: 'view-2', component: TestComponent, providers: [withTransientStateInputElement()]}, + {path: 'path/to/view/1', component: TestComponent, providers: [withTransientStateInputElement()]}, + {path: 'path/to/view/2', component: TestComponent, providers: [withTransientStateInputElement()]}, ]), ], }); @@ -1007,7 +998,7 @@ describe('WorkbenchLayout', () => { await waitForInitialWorkbenchLayout(); // Add view 1 - await TestBed.inject(WorkbenchRouter).navigate(['view-1']); + await TestBed.inject(WorkbenchRouter).navigate(['path/to/view/1']); await waitUntilStable(); enterTransientViewState(fixture, 'view.1', 'A'); @@ -1020,7 +1011,7 @@ describe('WorkbenchLayout', () => { expect('view.1').toHaveTransientState('A'); // Add view 2 - await TestBed.inject(WorkbenchRouter).navigate(['view-2']); + await TestBed.inject(WorkbenchRouter).navigate(['path/to/view/2']); await waitUntilStable(); enterTransientViewState(fixture, 'view.2', 'B'); @@ -1040,7 +1031,7 @@ describe('WorkbenchLayout', () => { workbenchId: TestBed.inject(WORKBENCH_ID), partId: 'main', viewId: 'view.2', - viewUrlSegments: [new UrlSegment('view-2', {})], + viewUrlSegments: segments(['path/to/view/2']), }, target: { workbenchId: TestBed.inject(WORKBENCH_ID), @@ -1072,7 +1063,7 @@ describe('WorkbenchLayout', () => { workbenchId: TestBed.inject(WORKBENCH_ID), partId: 'EAST', viewId: 'view.2', - viewUrlSegments: [new UrlSegment('view-2', {})], + viewUrlSegments: segments(['path/to/view/2']), }, target: { workbenchId: TestBed.inject(WORKBENCH_ID), @@ -1104,8 +1095,8 @@ describe('WorkbenchLayout', () => { imports: [ WorkbenchTestingModule.forTest({startup: {launcher: 'APP_INITIALIZER'}}), RouterTestingModule.withRoutes([ - {path: 'view-1', component: TestComponent, providers: [withTransientStateInputElement()]}, - {path: 'view-2', component: TestComponent, providers: [withTransientStateInputElement()]}, + {path: 'path/to/view/1', component: TestComponent, providers: [withTransientStateInputElement()]}, + {path: 'path/to/view/2', component: TestComponent, providers: [withTransientStateInputElement()]}, ]), ], }); @@ -1113,7 +1104,7 @@ describe('WorkbenchLayout', () => { await waitForInitialWorkbenchLayout(); // Add view 1 - await TestBed.inject(WorkbenchRouter).navigate(['view-1']); + await TestBed.inject(WorkbenchRouter).navigate(['path/to/view/1']); await waitUntilStable(); enterTransientViewState(fixture, 'view.1', 'A'); @@ -1126,7 +1117,7 @@ describe('WorkbenchLayout', () => { expect('view.1').toHaveTransientState('A'); // Add view 2 - await TestBed.inject(WorkbenchRouter).navigate(['view-2']); + await TestBed.inject(WorkbenchRouter).navigate(['path/to/view/2']); await waitUntilStable(); enterTransientViewState(fixture, 'view.2', 'B'); @@ -1146,7 +1137,7 @@ describe('WorkbenchLayout', () => { workbenchId: TestBed.inject(WORKBENCH_ID), partId: 'main', viewId: 'view.2', - viewUrlSegments: [new UrlSegment('view-2', {})], + viewUrlSegments: segments(['path/to/view/2']), }, target: { workbenchId: TestBed.inject(WORKBENCH_ID), @@ -1178,7 +1169,7 @@ describe('WorkbenchLayout', () => { workbenchId: TestBed.inject(WORKBENCH_ID), partId: 'EAST', viewId: 'view.2', - viewUrlSegments: [new UrlSegment('view-2', {})], + viewUrlSegments: segments(['path/to/view/2']), }, target: { workbenchId: TestBed.inject(WORKBENCH_ID), @@ -1210,9 +1201,9 @@ describe('WorkbenchLayout', () => { imports: [ WorkbenchTestingModule.forTest({startup: {launcher: 'APP_INITIALIZER'}}), RouterTestingModule.withRoutes([ - {path: 'view-1', component: TestComponent, providers: [withTransientStateInputElement()]}, - {path: 'view-2', component: TestComponent, providers: [withTransientStateInputElement()]}, - {path: 'view-3', component: TestComponent, providers: [withTransientStateInputElement()]}, + {path: 'path/to/view/1', component: TestComponent, providers: [withTransientStateInputElement()]}, + {path: 'path/to/view/2', component: TestComponent, providers: [withTransientStateInputElement()]}, + {path: 'path/to/view/3', component: TestComponent, providers: [withTransientStateInputElement()]}, ]), ], }); @@ -1220,7 +1211,7 @@ describe('WorkbenchLayout', () => { await waitForInitialWorkbenchLayout(); // Add view 1 - await TestBed.inject(WorkbenchRouter).navigate(['view-1']); + await TestBed.inject(WorkbenchRouter).navigate(['path/to/view/1']); await waitUntilStable(); enterTransientViewState(fixture, 'view.1', 'A'); @@ -1233,7 +1224,7 @@ describe('WorkbenchLayout', () => { expect('view.1').toHaveTransientState('A'); // Add view 2 - await TestBed.inject(WorkbenchRouter).navigate(['view-2']); + await TestBed.inject(WorkbenchRouter).navigate(['path/to/view/2']); await waitUntilStable(); enterTransientViewState(fixture, 'view.2', 'B'); @@ -1248,7 +1239,7 @@ describe('WorkbenchLayout', () => { expect('view.2').toHaveTransientState('B'); // Add view 3 - await TestBed.inject(WorkbenchRouter).navigate(['view-3']); + await TestBed.inject(WorkbenchRouter).navigate(['path/to/view/3']); await waitUntilStable(); enterTransientViewState(fixture, 'view.3', 'C'); @@ -1270,7 +1261,7 @@ describe('WorkbenchLayout', () => { workbenchId: TestBed.inject(WORKBENCH_ID), partId: 'main', viewId: 'view.3', - viewUrlSegments: [new UrlSegment('view-3', {})], + viewUrlSegments: segments(['path/to/view/3']), }, target: { workbenchId: TestBed.inject(WORKBENCH_ID), @@ -1305,7 +1296,7 @@ describe('WorkbenchLayout', () => { workbenchId: TestBed.inject(WORKBENCH_ID), partId: 'main', viewId: 'view.2', - viewUrlSegments: [new UrlSegment('view-2', {})], + viewUrlSegments: segments(['path/to/view/2']), }, target: { workbenchId: TestBed.inject(WORKBENCH_ID), @@ -1344,7 +1335,7 @@ describe('WorkbenchLayout', () => { workbenchId: TestBed.inject(WORKBENCH_ID), partId: 'SOUTH-EAST', viewId: 'view.2', - viewUrlSegments: [new UrlSegment('view-2', {})], + viewUrlSegments: segments(['path/to/view/2']), }, target: { workbenchId: TestBed.inject(WORKBENCH_ID), @@ -1378,13 +1369,13 @@ describe('WorkbenchLayout', () => { expect('view.3').toHaveTransientState('C'); }); - it('allows to move a view to a new part in the south and back to the main part ', async () => { + it('allows to move a view to a new part in the south and back to the initial part ', async () => { TestBed.configureTestingModule({ imports: [ WorkbenchTestingModule.forTest({startup: {launcher: 'APP_INITIALIZER'}}), RouterTestingModule.withRoutes([ - {path: 'view-1', component: TestComponent, providers: [withTransientStateInputElement()]}, - {path: 'view-2', component: TestComponent, providers: [withTransientStateInputElement()]}, + {path: 'path/to/view/1', component: TestComponent, providers: [withTransientStateInputElement()]}, + {path: 'path/to/view/2', component: TestComponent, providers: [withTransientStateInputElement()]}, ]), ], }); @@ -1392,7 +1383,7 @@ describe('WorkbenchLayout', () => { await waitForInitialWorkbenchLayout(); // Add view 1 - await TestBed.inject(WorkbenchRouter).navigate(['view-1']); + await TestBed.inject(WorkbenchRouter).navigate(['path/to/view/1']); await waitUntilStable(); enterTransientViewState(fixture, 'view.1', 'A'); @@ -1405,7 +1396,7 @@ describe('WorkbenchLayout', () => { expect('view.1').toHaveTransientState('A'); // Add view 2 - await TestBed.inject(WorkbenchRouter).navigate(['view-2']); + await TestBed.inject(WorkbenchRouter).navigate(['path/to/view/2']); await waitUntilStable(); enterTransientViewState(fixture, 'view.2', 'B'); @@ -1425,7 +1416,7 @@ describe('WorkbenchLayout', () => { workbenchId: TestBed.inject(WORKBENCH_ID), partId: 'main', viewId: 'view.2', - viewUrlSegments: [new UrlSegment('view-2', {})], + viewUrlSegments: segments(['path/to/view/2']), }, target: { workbenchId: TestBed.inject(WORKBENCH_ID), @@ -1451,13 +1442,13 @@ describe('WorkbenchLayout', () => { expect('view.2').toBeRegistered({partId: 'SOUTH', active: true}); expect('view.2').toHaveTransientState('B'); - // Move view 2 back to the main part + // Move view 2 back to the initial part TestBed.inject(ViewDragService).dispatchViewMoveEvent({ source: { workbenchId: TestBed.inject(WORKBENCH_ID), partId: 'SOUTH', viewId: 'view.2', - viewUrlSegments: [new UrlSegment('view-2', {})], + viewUrlSegments: segments(['path/to/view/2']), }, target: { workbenchId: TestBed.inject(WORKBENCH_ID), @@ -1477,13 +1468,13 @@ describe('WorkbenchLayout', () => { expect('view.2').toHaveTransientState('B'); }); - it('allows to move a view to a new part in the east and then to the south of the main part ', async () => { + it('allows to move a view to a new part in the east and then to the south of the initial part ', async () => { TestBed.configureTestingModule({ imports: [ WorkbenchTestingModule.forTest({startup: {launcher: 'APP_INITIALIZER'}}), RouterTestingModule.withRoutes([ - {path: 'view-1', component: TestComponent, providers: [withTransientStateInputElement()]}, - {path: 'view-2', component: TestComponent, providers: [withTransientStateInputElement()]}, + {path: 'path/to/view/1', component: TestComponent, providers: [withTransientStateInputElement()]}, + {path: 'path/to/view/2', component: TestComponent, providers: [withTransientStateInputElement()]}, ]), ], }); @@ -1491,7 +1482,7 @@ describe('WorkbenchLayout', () => { await waitForInitialWorkbenchLayout(); // Add view 1 - await TestBed.inject(WorkbenchRouter).navigate(['view-1']); + await TestBed.inject(WorkbenchRouter).navigate(['path/to/view/1']); await waitUntilStable(); enterTransientViewState(fixture, 'view.1', 'A'); @@ -1504,7 +1495,7 @@ describe('WorkbenchLayout', () => { expect('view.1').toHaveTransientState('A'); // Add view 2 - await TestBed.inject(WorkbenchRouter).navigate(['view-2']); + await TestBed.inject(WorkbenchRouter).navigate(['path/to/view/2']); await waitUntilStable(); enterTransientViewState(fixture, 'view.2', 'B'); @@ -1524,7 +1515,7 @@ describe('WorkbenchLayout', () => { workbenchId: TestBed.inject(WORKBENCH_ID), partId: 'main', viewId: 'view.2', - viewUrlSegments: [new UrlSegment('view-2', {})], + viewUrlSegments: segments(['path/to/view/2']), }, target: { workbenchId: TestBed.inject(WORKBENCH_ID), @@ -1556,7 +1547,7 @@ describe('WorkbenchLayout', () => { workbenchId: TestBed.inject(WORKBENCH_ID), partId: 'EAST', viewId: 'view.2', - viewUrlSegments: [new UrlSegment('view-2', {})], + viewUrlSegments: segments(['path/to/view/2']), }, target: { workbenchId: TestBed.inject(WORKBENCH_ID), @@ -1583,13 +1574,13 @@ describe('WorkbenchLayout', () => { expect('view.2').toHaveTransientState('B'); }); - it('allows to move a view to a new part in the west and then to the south of the main part ', async () => { + it('allows to move a view to a new part in the west and then to the south of the initial part ', async () => { TestBed.configureTestingModule({ imports: [ WorkbenchTestingModule.forTest({startup: {launcher: 'APP_INITIALIZER'}}), RouterTestingModule.withRoutes([ - {path: 'view-1', component: TestComponent, providers: [withTransientStateInputElement()]}, - {path: 'view-2', component: TestComponent, providers: [withTransientStateInputElement()]}, + {path: 'path/to/view/1', component: TestComponent, providers: [withTransientStateInputElement()]}, + {path: 'path/to/view/2', component: TestComponent, providers: [withTransientStateInputElement()]}, ]), ], }); @@ -1597,7 +1588,7 @@ describe('WorkbenchLayout', () => { await waitForInitialWorkbenchLayout(); // Add view 1 - await TestBed.inject(WorkbenchRouter).navigate(['view-1']); + await TestBed.inject(WorkbenchRouter).navigate(['path/to/view/1']); await waitUntilStable(); enterTransientViewState(fixture, 'view.1', 'A'); @@ -1610,7 +1601,7 @@ describe('WorkbenchLayout', () => { expect('view.1').toHaveTransientState('A'); // Add view 2 - await TestBed.inject(WorkbenchRouter).navigate(['view-2']); + await TestBed.inject(WorkbenchRouter).navigate(['path/to/view/2']); await waitUntilStable(); enterTransientViewState(fixture, 'view.2', 'B'); @@ -1630,7 +1621,7 @@ describe('WorkbenchLayout', () => { workbenchId: TestBed.inject(WORKBENCH_ID), partId: 'main', viewId: 'view.2', - viewUrlSegments: [new UrlSegment('view-2', {})], + viewUrlSegments: segments(['path/to/view/2']), }, target: { workbenchId: TestBed.inject(WORKBENCH_ID), @@ -1662,7 +1653,7 @@ describe('WorkbenchLayout', () => { workbenchId: TestBed.inject(WORKBENCH_ID), partId: 'WEST', viewId: 'view.2', - viewUrlSegments: [new UrlSegment('view-2', {})], + viewUrlSegments: segments(['path/to/view/2']), }, target: { workbenchId: TestBed.inject(WORKBENCH_ID), @@ -1694,8 +1685,8 @@ describe('WorkbenchLayout', () => { imports: [ WorkbenchTestingModule.forTest({startup: {launcher: 'APP_INITIALIZER'}}), RouterTestingModule.withRoutes([ - {path: 'view-1', component: TestComponent, providers: [withTransientStateInputElement()]}, - {path: 'view-2', component: TestComponent, providers: [withTransientStateInputElement()]}, + {path: 'path/to/view/1', component: TestComponent, providers: [withTransientStateInputElement()]}, + {path: 'path/to/view/2', component: TestComponent, providers: [withTransientStateInputElement()]}, ]), ], }); @@ -1703,7 +1694,7 @@ describe('WorkbenchLayout', () => { await waitForInitialWorkbenchLayout(); // Add view 1 - await TestBed.inject(WorkbenchRouter).navigate(['view-1']); + await TestBed.inject(WorkbenchRouter).navigate(['path/to/view/1']); await waitUntilStable(); enterTransientViewState(fixture, 'view.1', 'A'); @@ -1717,7 +1708,7 @@ describe('WorkbenchLayout', () => { expect('view.1').toHaveTransientState('A'); // Add view 2 - await TestBed.inject(WorkbenchRouter).navigate(['view-2']); + await TestBed.inject(WorkbenchRouter).navigate(['path/to/view/2']); await waitUntilStable(); enterTransientViewState(fixture, 'view.2', 'B'); @@ -1732,7 +1723,7 @@ describe('WorkbenchLayout', () => { expect('view.2').toHaveTransientState('B'); // Add view 2 again - await TestBed.inject(WorkbenchRouter).navigate(['view-2'], {blankPartId: 'main'}); + await TestBed.inject(WorkbenchRouter).navigate(['path/to/view/2'], {blankPartId: 'main'}); await waitUntilStable(); expect(fixture).toEqualWorkbenchLayout({ @@ -1751,8 +1742,8 @@ describe('WorkbenchLayout', () => { imports: [ WorkbenchTestingModule.forTest({startup: {launcher: 'APP_INITIALIZER'}}), RouterTestingModule.withRoutes([ - {path: 'view-1', component: TestComponent, providers: [withTransientStateInputElement()]}, - {path: 'view-2', component: TestComponent, providers: [withTransientStateInputElement()]}, + {path: 'path/to/view/1', component: TestComponent, providers: [withTransientStateInputElement()]}, + {path: 'path/to/view/2', component: TestComponent, providers: [withTransientStateInputElement()]}, ]), ], }); @@ -1760,7 +1751,7 @@ describe('WorkbenchLayout', () => { await waitForInitialWorkbenchLayout(); // Add view 1 - await TestBed.inject(WorkbenchRouter).navigate(['view-1']); + await TestBed.inject(WorkbenchRouter).navigate(['path/to/view/1']); await waitUntilStable(); enterTransientViewState(fixture, 'view.1', 'A'); @@ -1773,7 +1764,7 @@ describe('WorkbenchLayout', () => { expect('view.1').toHaveTransientState('A'); // Add view 2 - await TestBed.inject(WorkbenchRouter).navigate(['view-2']); + await TestBed.inject(WorkbenchRouter).navigate(['path/to/view/2']); await waitUntilStable(); enterTransientViewState(fixture, 'view.2', 'B'); @@ -1788,7 +1779,7 @@ describe('WorkbenchLayout', () => { expect('view.2').toHaveTransientState('B'); // Add view 2 again - await TestBed.inject(WorkbenchRouter).navigate(['view-2'], {blankPartId: 'main', target: 'blank'}); + await TestBed.inject(WorkbenchRouter).navigate(['path/to/view/2'], {blankPartId: 'main', target: 'blank'}); await waitUntilStable(); enterTransientViewState(fixture, 'view.3', 'C'); @@ -1810,10 +1801,10 @@ describe('WorkbenchLayout', () => { imports: [ WorkbenchTestingModule.forTest({startup: {launcher: 'APP_INITIALIZER'}}), RouterTestingModule.withRoutes([ - {path: 'view-1', component: TestComponent, providers: [withTransientStateInputElement()]}, - {path: 'view-2', component: TestComponent, providers: [withTransientStateInputElement()]}, - {path: 'view-3', component: TestComponent, providers: [withTransientStateInputElement()]}, - {path: 'view-4', component: TestComponent, providers: [withTransientStateInputElement()]}, + {path: 'path/to/view/1', component: TestComponent, providers: [withTransientStateInputElement()]}, + {path: 'path/to/view/2', component: TestComponent, providers: [withTransientStateInputElement()]}, + {path: 'path/to/view/3', component: TestComponent, providers: [withTransientStateInputElement()]}, + {path: 'path/to/view/4', component: TestComponent, providers: [withTransientStateInputElement()]}, ]), ], }); @@ -1821,7 +1812,7 @@ describe('WorkbenchLayout', () => { await waitForInitialWorkbenchLayout(); // Add view 1 - await TestBed.inject(WorkbenchRouter).navigate(['view-1']); + await TestBed.inject(WorkbenchRouter).navigate(['path/to/view/1']); await waitUntilStable(); enterTransientViewState(fixture, 'view.1', 'A'); @@ -1834,7 +1825,7 @@ describe('WorkbenchLayout', () => { expect('view.1').toHaveTransientState('A'); // Add view 2 - await TestBed.inject(WorkbenchRouter).navigate(['view-2']); + await TestBed.inject(WorkbenchRouter).navigate(['path/to/view/2']); await waitUntilStable(); enterTransientViewState(fixture, 'view.2', 'B'); @@ -1854,7 +1845,7 @@ describe('WorkbenchLayout', () => { workbenchId: TestBed.inject(WORKBENCH_ID), partId: 'main', viewId: 'view.2', - viewUrlSegments: [new UrlSegment('view-2', {})], + viewUrlSegments: segments(['path/to/view/2']), }, target: { workbenchId: TestBed.inject(WORKBENCH_ID), @@ -1881,7 +1872,7 @@ describe('WorkbenchLayout', () => { expect('view.2').toHaveTransientState('B'); // Add view 3 to part EAST-1 - await TestBed.inject(WorkbenchRouter).navigate(['view-3']); + await TestBed.inject(WorkbenchRouter).navigate(['path/to/view/3']); await waitUntilStable(); enterTransientViewState(fixture, 'view.3', 'C'); @@ -1908,7 +1899,7 @@ describe('WorkbenchLayout', () => { workbenchId: TestBed.inject(WORKBENCH_ID), partId: 'EAST-1', viewId: 'view.3', - viewUrlSegments: [new UrlSegment('view-3', {})], + viewUrlSegments: segments(['path/to/view/3']), }, target: { workbenchId: TestBed.inject(WORKBENCH_ID), @@ -1942,7 +1933,7 @@ describe('WorkbenchLayout', () => { expect('view.3').toHaveTransientState('C'); // Add view 4 to part EAST-2 - await TestBed.inject(WorkbenchRouter).navigate(['view-4']); + await TestBed.inject(WorkbenchRouter).navigate(['path/to/view/4']); await waitUntilStable(); enterTransientViewState(fixture, 'view.4', 'D'); @@ -1976,7 +1967,7 @@ describe('WorkbenchLayout', () => { workbenchId: TestBed.inject(WORKBENCH_ID), partId: 'EAST-2', viewId: 'view.4', - viewUrlSegments: [new UrlSegment('view-4', {})], + viewUrlSegments: segments(['path/to/view/4']), }, target: { workbenchId: TestBed.inject(WORKBENCH_ID), @@ -2022,7 +2013,7 @@ describe('WorkbenchLayout', () => { workbenchId: TestBed.inject(WORKBENCH_ID), partId: 'EAST-2', viewId: 'view.3', - viewUrlSegments: [new UrlSegment('view-3', {})], + viewUrlSegments: segments(['path/to/view/3']), }, target: { workbenchId: TestBed.inject(WORKBENCH_ID), @@ -2068,7 +2059,7 @@ describe('WorkbenchLayout', () => { workbenchId: TestBed.inject(WORKBENCH_ID), partId: 'EAST-1', viewId: 'view.2', - viewUrlSegments: [new UrlSegment('view-2', {})], + viewUrlSegments: segments(['path/to/view/2']), }, target: { workbenchId: TestBed.inject(WORKBENCH_ID), @@ -2110,7 +2101,7 @@ describe('WorkbenchLayout', () => { expect('view.4').toHaveTransientState('D'); // Close view 1 - await TestBed.inject(WorkbenchRouter).navigate(['view-1'], {close: true}); + await TestBed.inject(WorkbenchRouter).navigate(['path/to/view/1'], {close: true}); await waitUntilStable(); expect(fixture).toEqualWorkbenchLayout({ @@ -2137,7 +2128,7 @@ describe('WorkbenchLayout', () => { expect('view.4').toHaveTransientState('D'); // Close view 3 - await TestBed.inject(WorkbenchRouter).navigate(['view-3'], {close: true}); + await TestBed.inject(WorkbenchRouter).navigate(['path/to/view/3'], {close: true}); await waitUntilStable(); expect(fixture).toEqualWorkbenchLayout({ @@ -2158,7 +2149,7 @@ describe('WorkbenchLayout', () => { expect('view.4').toHaveTransientState('D'); // Close view 4 - await TestBed.inject(WorkbenchRouter).navigate(['view-4'], {close: true}); + await TestBed.inject(WorkbenchRouter).navigate(['path/to/view/4'], {close: true}); await waitUntilStable(); expect(fixture).toEqualWorkbenchLayout({ @@ -2178,7 +2169,7 @@ describe('WorkbenchLayout', () => { imports: [ WorkbenchTestingModule.forTest({startup: {launcher: 'APP_INITIALIZER'}}), RouterTestingModule.withRoutes([ - {path: 'view', component: TestComponent, providers: [withTransientStateInputElement()]}, + {path: 'path/to/view', component: TestComponent, providers: [withTransientStateInputElement()]}, ]), ], }); @@ -2186,20 +2177,15 @@ describe('WorkbenchLayout', () => { await waitForInitialWorkbenchLayout(); // Create initial workbench layout. - await TestBed.inject(WorkbenchRouter).ɵnavigate(layout => { - return { - layout: layout - .addView('view.1', {partId: 'main'}) - .addView('view.2', {partId: 'main'}) - .addView('view.3', {partId: 'main'}) - .activateView('view.3'), - viewOutlets: { - 'view.1': ['view'], - 'view.2': ['view'], - 'view.3': ['view'], - }, - }; - }); + await TestBed.inject(WorkbenchRouter).ɵnavigate(layout => layout + .addView('view.1', {partId: 'main'}) + .addView('view.2', {partId: 'main'}) + .addView('view.3', {partId: 'main'}) + .navigateView('view.1', ['path/to/view']) + .navigateView('view.2', ['path/to/view']) + .navigateView('view.3', ['path/to/view']) + .activateView('view.3'), + ); await waitUntilStable(); // Enter transient states. @@ -2221,7 +2207,7 @@ describe('WorkbenchLayout', () => { workbenchId: TestBed.inject(WORKBENCH_ID), partId: 'main', viewId: 'view.2', - viewUrlSegments: [new UrlSegment('view', {})], + viewUrlSegments: segments(['path/to/view']), }, target: { workbenchId: TestBed.inject(WORKBENCH_ID), @@ -2252,7 +2238,7 @@ describe('WorkbenchLayout', () => { workbenchId: TestBed.inject(WORKBENCH_ID), partId: 'main', viewId: 'view.3', - viewUrlSegments: [new UrlSegment('view', {})], + viewUrlSegments: segments(['path/to/view']), }, target: { workbenchId: TestBed.inject(WORKBENCH_ID), @@ -2288,7 +2274,7 @@ describe('WorkbenchLayout', () => { imports: [ WorkbenchTestingModule.forTest({startup: {launcher: 'APP_INITIALIZER'}}), RouterTestingModule.withRoutes([ - {path: 'view', component: TestComponent, providers: [withTransientStateInputElement()]}, + {path: 'path/to/view', component: TestComponent, providers: [withTransientStateInputElement()]}, ]), ], }); @@ -2296,20 +2282,15 @@ describe('WorkbenchLayout', () => { await waitForInitialWorkbenchLayout(); // Create initial workbench layout. - await TestBed.inject(WorkbenchRouter).ɵnavigate(layout => { - return { - layout: layout - .addView('view.1', {partId: 'main'}) - .addView('view.2', {partId: 'main'}) - .addView('view.3', {partId: 'main'}) - .activateView('view.3'), - viewOutlets: { - 'view.1': ['view'], - 'view.2': ['view'], - 'view.3': ['view'], - }, - }; - }); + await TestBed.inject(WorkbenchRouter).ɵnavigate(layout => layout + .addView('view.1', {partId: 'main'}) + .addView('view.2', {partId: 'main'}) + .addView('view.3', {partId: 'main'}) + .navigateView('view.1', ['path/to/view']) + .navigateView('view.2', ['path/to/view']) + .navigateView('view.3', ['path/to/view']) + .activateView('view.3'), + ); await waitUntilStable(); // Enter transient states. @@ -2331,7 +2312,7 @@ describe('WorkbenchLayout', () => { workbenchId: TestBed.inject(WORKBENCH_ID), partId: 'main', viewId: 'view.2', - viewUrlSegments: [new UrlSegment('view', {})], + viewUrlSegments: segments(['path/to/view']), }, target: { workbenchId: TestBed.inject(WORKBENCH_ID), @@ -2356,13 +2337,13 @@ describe('WorkbenchLayout', () => { expect('view.2').toHaveTransientState('B'); expect('view.3').toHaveTransientState('C'); - // Move view 2 to a new part in the west of the main part + // Move view 2 to a new part in the west of the initial part TestBed.inject(ViewDragService).dispatchViewMoveEvent({ source: { workbenchId: TestBed.inject(WORKBENCH_ID), partId: 'EAST', viewId: 'view.2', - viewUrlSegments: [new UrlSegment('view', {})], + viewUrlSegments: segments(['path/to/view']), }, target: { workbenchId: TestBed.inject(WORKBENCH_ID), @@ -2393,7 +2374,7 @@ describe('WorkbenchLayout', () => { imports: [ WorkbenchTestingModule.forTest({startup: {launcher: 'APP_INITIALIZER'}}), RouterTestingModule.withRoutes([ - {path: 'view', component: TestComponent, providers: [withTransientStateInputElement()]}, + {path: 'path/to/view', component: TestComponent, providers: [withTransientStateInputElement()]}, ]), ], }); @@ -2401,22 +2382,17 @@ describe('WorkbenchLayout', () => { await waitForInitialWorkbenchLayout(); // Create initial workbench layout. - await TestBed.inject(WorkbenchRouter).ɵnavigate(layout => { - return { - layout: layout - .addView('view.1', {partId: 'main'}) - .addView('view.2', {partId: 'main'}) - .addView('view.3', {partId: 'main'}) - .addView('view.4', {partId: 'main'}) - .activateView('view.4'), - viewOutlets: { - 'view.1': ['view'], - 'view.2': ['view'], - 'view.3': ['view'], - 'view.4': ['view'], - }, - }; - }); + await TestBed.inject(WorkbenchRouter).ɵnavigate(layout => layout + .addView('view.1', {partId: 'main'}) + .addView('view.2', {partId: 'main'}) + .addView('view.3', {partId: 'main'}) + .addView('view.4', {partId: 'main'}) + .navigateView('view.1', ['path/to/view']) + .navigateView('view.2', ['path/to/view']) + .navigateView('view.3', ['path/to/view']) + .navigateView('view.4', ['path/to/view']) + .activateView('view.4'), + ); await waitUntilStable(); // Enter transient states. @@ -2442,7 +2418,7 @@ describe('WorkbenchLayout', () => { workbenchId: TestBed.inject(WORKBENCH_ID), partId: 'main', viewId: 'view.2', - viewUrlSegments: [new UrlSegment('view', {})], + viewUrlSegments: segments(['path/to/view']), }, target: { workbenchId: TestBed.inject(WORKBENCH_ID), @@ -2474,7 +2450,7 @@ describe('WorkbenchLayout', () => { workbenchId: TestBed.inject(WORKBENCH_ID), partId: 'main', viewId: 'view.3', - viewUrlSegments: [new UrlSegment('view', {})], + viewUrlSegments: segments(['path/to/view']), }, target: { workbenchId: TestBed.inject(WORKBENCH_ID), @@ -2511,7 +2487,7 @@ describe('WorkbenchLayout', () => { workbenchId: TestBed.inject(WORKBENCH_ID), partId: 'main', viewId: 'view.4', - viewUrlSegments: [new UrlSegment('view', {})], + viewUrlSegments: segments(['path/to/view']), }, target: { workbenchId: TestBed.inject(WORKBENCH_ID), @@ -2553,7 +2529,7 @@ describe('WorkbenchLayout', () => { workbenchId: TestBed.inject(WORKBENCH_ID), partId: 'EAST-2', viewId: 'view.3', - viewUrlSegments: [new UrlSegment('view', {})], + viewUrlSegments: segments(['path/to/view']), }, target: { workbenchId: TestBed.inject(WORKBENCH_ID), @@ -2595,7 +2571,7 @@ describe('WorkbenchLayout', () => { workbenchId: TestBed.inject(WORKBENCH_ID), partId: 'EAST-1', viewId: 'view.2', - viewUrlSegments: [new UrlSegment('view', {})], + viewUrlSegments: segments(['path/to/view']), }, target: { workbenchId: TestBed.inject(WORKBENCH_ID), @@ -2688,7 +2664,7 @@ describe('WorkbenchLayout', () => { imports: [ WorkbenchTestingModule.forTest({startup: {launcher: 'APP_INITIALIZER'}}), RouterTestingModule.withRoutes([ - {path: 'view', component: TestComponent, providers: [withTransientStateInputElement()]}, + {path: 'path/to/view', component: TestComponent, providers: [withTransientStateInputElement()]}, ]), ], }); @@ -2696,20 +2672,15 @@ describe('WorkbenchLayout', () => { await waitForInitialWorkbenchLayout(); // Create initial workbench layout. - await TestBed.inject(WorkbenchRouter).ɵnavigate(layout => { - return { - layout: layout - .addView('view.1', {partId: 'main'}) - .addView('view.2', {partId: 'main'}) - .addView('view.3', {partId: 'main'}) - .activateView('view.3'), - viewOutlets: { - 'view.1': ['view'], - 'view.2': ['view'], - 'view.3': ['view'], - }, - }; - }); + await TestBed.inject(WorkbenchRouter).ɵnavigate(layout => layout + .addView('view.1', {partId: 'main'}) + .addView('view.2', {partId: 'main'}) + .addView('view.3', {partId: 'main'}) + .navigateView('view.1', ['path/to/view']) + .navigateView('view.2', ['path/to/view']) + .navigateView('view.3', ['path/to/view']) + .activateView('view.3'), + ); await waitUntilStable(); // Enter transient states. @@ -2731,7 +2702,7 @@ describe('WorkbenchLayout', () => { workbenchId: TestBed.inject(WORKBENCH_ID), partId: 'main', viewId: 'view.3', - viewUrlSegments: [new UrlSegment('view', {})], + viewUrlSegments: segments(['path/to/view']), }, target: { workbenchId: TestBed.inject(WORKBENCH_ID), @@ -2762,7 +2733,7 @@ describe('WorkbenchLayout', () => { workbenchId: TestBed.inject(WORKBENCH_ID), partId: 'main', viewId: 'view.2', - viewUrlSegments: [new UrlSegment('view', {})], + viewUrlSegments: segments(['path/to/view']), }, target: { workbenchId: TestBed.inject(WORKBENCH_ID), @@ -2798,7 +2769,7 @@ describe('WorkbenchLayout', () => { workbenchId: TestBed.inject(WORKBENCH_ID), partId: 'main', viewId: 'view.2', - viewUrlSegments: [new UrlSegment('view', {})], + viewUrlSegments: segments(['path/to/view']), }, target: { workbenchId: TestBed.inject(WORKBENCH_ID), @@ -2834,7 +2805,7 @@ describe('WorkbenchLayout', () => { imports: [ WorkbenchTestingModule.forTest({startup: {launcher: 'APP_INITIALIZER'}}), RouterTestingModule.withRoutes([ - {path: 'view', component: TestComponent, providers: [withTransientStateInputElement()]}, + {path: 'path/to/view', component: TestComponent, providers: [withTransientStateInputElement()]}, ]), ], }); @@ -2842,20 +2813,15 @@ describe('WorkbenchLayout', () => { await waitForInitialWorkbenchLayout(); // Create initial workbench layout. - await TestBed.inject(WorkbenchRouter).ɵnavigate(layout => { - return { - layout: layout - .addView('view.1', {partId: 'main'}) - .addView('view.2', {partId: 'main'}) - .addView('view.3', {partId: 'main'}) - .activateView('view.3'), - viewOutlets: { - 'view.1': ['view'], - 'view.2': ['view'], - 'view.3': ['view'], - }, - }; - }); + await TestBed.inject(WorkbenchRouter).ɵnavigate(layout => layout + .addView('view.1', {partId: 'main'}) + .addView('view.2', {partId: 'main'}) + .addView('view.3', {partId: 'main'}) + .navigateView('view.1', ['path/to/view']) + .navigateView('view.2', ['path/to/view']) + .navigateView('view.3', ['path/to/view']) + .activateView('view.3'), + ); await waitUntilStable(); // Enter transient states. @@ -2877,7 +2843,7 @@ describe('WorkbenchLayout', () => { workbenchId: TestBed.inject(WORKBENCH_ID), partId: 'main', viewId: 'view.3', - viewUrlSegments: [new UrlSegment('view', {})], + viewUrlSegments: segments(['path/to/view']), }, target: { workbenchId: TestBed.inject(WORKBENCH_ID), @@ -2908,7 +2874,7 @@ describe('WorkbenchLayout', () => { workbenchId: TestBed.inject(WORKBENCH_ID), partId: 'main', viewId: 'view.2', - viewUrlSegments: [new UrlSegment('view', {})], + viewUrlSegments: segments(['path/to/view']), }, target: { workbenchId: TestBed.inject(WORKBENCH_ID), @@ -2944,7 +2910,7 @@ describe('WorkbenchLayout', () => { workbenchId: TestBed.inject(WORKBENCH_ID), partId: 'EAST', viewId: 'view.3', - viewUrlSegments: [new UrlSegment('view', {})], + viewUrlSegments: segments(['path/to/view']), }, target: { workbenchId: TestBed.inject(WORKBENCH_ID), @@ -2980,7 +2946,7 @@ describe('WorkbenchLayout', () => { imports: [ WorkbenchTestingModule.forTest({startup: {launcher: 'APP_INITIALIZER'}}), RouterTestingModule.withRoutes([ - {path: 'view', component: TestComponent, providers: [withTransientStateInputElement()]}, + {path: 'path/to/view', component: TestComponent, providers: [withTransientStateInputElement()]}, ]), ], }); @@ -2988,18 +2954,13 @@ describe('WorkbenchLayout', () => { await waitForInitialWorkbenchLayout(); // Create initial workbench layout. - await TestBed.inject(WorkbenchRouter).ɵnavigate(layout => { - return { - layout: layout - .addView('view.1', {partId: 'main'}) - .addView('view.2', {partId: 'main'}) - .activateView('view.2'), - viewOutlets: { - 'view.1': ['view'], - 'view.2': ['view'], - }, - }; - }); + await TestBed.inject(WorkbenchRouter).ɵnavigate(layout => layout + .addView('view.1', {partId: 'main'}) + .addView('view.2', {partId: 'main'}) + .navigateView('view.1', ['path/to/view']) + .navigateView('view.2', ['path/to/view']) + .activateView('view.2'), + ); await waitUntilStable(); // Enter transient states. @@ -3017,7 +2978,7 @@ describe('WorkbenchLayout', () => { workbenchId: TestBed.inject(WORKBENCH_ID), partId: 'main', viewId: 'view.2', - viewUrlSegments: [new UrlSegment('view', {})], + viewUrlSegments: segments(['path/to/view']), }, target: { workbenchId: TestBed.inject(WORKBENCH_ID), @@ -3041,13 +3002,13 @@ describe('WorkbenchLayout', () => { expect('view.1').toHaveTransientState('A'); expect('view.2').toHaveTransientState('B'); - // Move view 2 to a new part in the west of main part + // Move view 2 to a new part in the west of initial part TestBed.inject(ViewDragService).dispatchViewMoveEvent({ source: { workbenchId: TestBed.inject(WORKBENCH_ID), partId: 'EAST', viewId: 'view.2', - viewUrlSegments: [new UrlSegment('view', {})], + viewUrlSegments: segments(['path/to/view']), }, target: { workbenchId: TestBed.inject(WORKBENCH_ID), diff --git a/projects/scion/workbench/src/lib/layout/workbench-layout.component.ts b/projects/scion/workbench/src/lib/layout/workbench-layout.component.ts index 9a3616492..797d92be7 100644 --- a/projects/scion/workbench/src/lib/layout/workbench-layout.component.ts +++ b/projects/scion/workbench/src/lib/layout/workbench-layout.component.ts @@ -85,7 +85,9 @@ export class WorkbenchLayoutComponent { workbenchId: event.dragData.workbenchId, partId: event.dragData.partId, viewId: event.dragData.viewId, + alternativeViewId: event.dragData.alternativeViewId, viewUrlSegments: event.dragData.viewUrlSegments, + navigationHint: event.dragData.navigationHint, classList: event.dragData.classList, }, target: GridDropTargets.resolve({ diff --git a/projects/scion/workbench/src/lib/layout/workbench-layout.factory.ts b/projects/scion/workbench/src/lib/layout/workbench-layout.factory.ts index 425629d1d..405bbbf0a 100644 --- a/projects/scion/workbench/src/lib/layout/workbench-layout.factory.ts +++ b/projects/scion/workbench/src/lib/layout/workbench-layout.factory.ts @@ -23,7 +23,7 @@ export abstract class WorkbenchLayoutFactory { * * @param id - The id of the part. Use {@link MAIN_AREA} to add the main area. * @param options - Controls how to add the part to the layout. - * @property activate - Controls whether to activate the part. If not set, defaults to `false`. + * @param options.activate - Controls whether to activate the part. If not set, defaults to `false`. * @return layout with the part added. */ public abstract addPart(id: string | MAIN_AREA, options?: {activate?: boolean}): WorkbenchLayout; diff --git a/projects/scion/workbench/src/lib/layout/workbench-layout.model.ts b/projects/scion/workbench/src/lib/layout/workbench-layout.model.ts index fb6e63694..f8f5b65bd 100644 --- a/projects/scion/workbench/src/lib/layout/workbench-layout.model.ts +++ b/projects/scion/workbench/src/lib/layout/workbench-layout.model.ts @@ -10,11 +10,12 @@ import {assertType} from '../common/asserts.util'; import {Defined} from '@scion/toolkit/util'; +import {ViewId} from '../view/workbench-view.model'; /** * Represents the arrangement of parts as grid. * - * The M-prefix indicates that {@link MPartGrid} is a layout model object that will be serialized into the URL. + * The M-prefix indicates this object is a model object that is serialized and stored, requiring migration on breaking change. */ export interface MPartGrid { root: MTreeNode | MPart; @@ -28,7 +29,7 @@ export interface ɵMPartGrid extends MPartGrid { /** * Indicates if this grid was migrated from an older version. */ - migrated: boolean; + migrated?: true; } /** @@ -37,7 +38,7 @@ export interface ɵMPartGrid extends MPartGrid { * A node contains two children, which are either a {@link MPart} or a {@link MTreeNode}, respectively. * The ratio together with the direction describes how to arrange the two children. * - * The M-prefix indicates that {@link MTreeNode} is a layout model object that will be serialized into the URL. + * The M-prefix indicates this object is a model object that is serialized and stored, requiring migration on breaking change. */ export class MTreeNode { @@ -63,7 +64,7 @@ export class MTreeNode { * Tests if the given object is a {@link MTreeNode}. */ public static isMTreeNode(object: any): object is MTreeNode { - return (object as MTreeNode).type === 'MTreeNode'; + return object && (object as MTreeNode).type === 'MTreeNode'; } } @@ -72,7 +73,7 @@ export class MTreeNode { * * A part is a container for views. * - * The M-prefix indicates that {@link MPart} is a layout model object that will be serialized into the URL. + * The M-prefix indicates this object is a model object that is serialized and stored, requiring migration on breaking change. */ export class MPart { @@ -83,7 +84,7 @@ export class MPart { public readonly id!: string; public parent?: MTreeNode; public views: MView[] = []; - public activeViewId?: string; + public activeViewId?: ViewId; public structural!: boolean; constructor(part: Partial>) { @@ -96,15 +97,21 @@ export class MPart { * Tests if the given object is a {@link MPart}. */ public static isMPart(object: any): object is MPart { - return (object as MPart).type === 'MPart'; + return object && (object as MPart).type === 'MPart'; } } /** * Represents a view contained in a {@link MPart}. * - * The M-prefix indicates that {@link MView} is a layout model object that will be serialized into the URL. + * The M-prefix indicates this object is a model object that is serialized and stored, requiring migration on breaking change. */ export interface MView { - readonly id: string; + id: ViewId; + alternativeId?: string; + cssClass?: string[]; + navigation?: { + hint?: string; + cssClass?: string[]; + }; } diff --git a/projects/scion/workbench/src/lib/layout/workbench-layout.spec.ts b/projects/scion/workbench/src/lib/layout/workbench-layout.spec.ts index 0b1109aa4..4d01aab17 100644 --- a/projects/scion/workbench/src/lib/layout/workbench-layout.spec.ts +++ b/projects/scion/workbench/src/lib/layout/workbench-layout.spec.ts @@ -8,7 +8,7 @@ * SPDX-License-Identifier: EPL-2.0 */ -import {MPart, MTreeNode} from './workbench-layout.model'; +import {MPart, MTreeNode, MView} from './workbench-layout.model'; import {MAIN_AREA_INITIAL_PART_ID, PartActivationInstantProvider, ViewActivationInstantProvider, ɵWorkbenchLayout} from './ɵworkbench-layout'; import {MAIN_AREA, WorkbenchLayout} from './workbench-layout'; import {toEqualWorkbenchLayoutCustomMatcher} from '../testing/jasmine/matcher/to-equal-workbench-layout.matcher'; @@ -16,6 +16,8 @@ import {expect} from '../testing/jasmine/matcher/custom-matchers.definition'; import {TestBed} from '@angular/core/testing'; import {WorkbenchLayoutFactory} from './workbench-layout.factory'; import {ɵWorkbenchLayoutFactory} from './ɵworkbench-layout.factory'; +import {UrlSegmentMatcher} from '../routing/url-segment-matcher'; +import {segments} from '../testing/testing.util'; describe('WorkbenchLayout', () => { @@ -31,35 +33,35 @@ describe('WorkbenchLayout', () => { // add view without specifying position expect(layout .addView('view.4', {partId: 'A'}) - .part({by: {partId: 'A'}}) + .part({partId: 'A'}) .views.map(view => view.id), ).toEqual(['view.1', 'view.2', 'view.3', 'view.4']); // add view at the start expect(layout .addView('view.4', {partId: 'A', position: 'start'}) - .part({by: {partId: 'A'}}) + .part({partId: 'A'}) .views.map(view => view.id), ).toEqual(['view.4', 'view.1', 'view.2', 'view.3']); // add view at the end expect(layout .addView('view.4', {partId: 'A', position: 'end'}) - .part({by: {partId: 'A'}}) + .part({partId: 'A'}) .views.map(view => view.id), ).toEqual(['view.1', 'view.2', 'view.3', 'view.4']); // add view before the active view expect(layout .addView('view.4', {partId: 'A', position: 'before-active-view'}) - .part({by: {partId: 'A'}}) + .part({partId: 'A'}) .views.map(view => view.id), ).toEqual(['view.1', 'view.4', 'view.2', 'view.3']); // add view after the active view expect(layout .addView('view.4', {partId: 'A', position: 'after-active-view'}) - .part({by: {partId: 'A'}}) + .part({partId: 'A'}) .views.map(view => view.id), ).toEqual(['view.1', 'view.2', 'view.4', 'view.3']); }); @@ -250,7 +252,7 @@ describe('WorkbenchLayout', () => { root: new MPart({id: 'B'}), }, }); - expect(workbenchLayout.part({by: {partId: 'B'}}).parent).toBeUndefined(); + expect(workbenchLayout.part({partId: 'B'}).parent).toBeUndefined(); }); /** @@ -704,11 +706,11 @@ describe('WorkbenchLayout', () => { expect(bottomLeftPart.parent).toBe(bcNode); expect(bottomLeftPart.id).toEqual('C'); - // verify the main part - const mainPart = rootNode.child2 as MPart; - expect(mainPart.constructor).toEqual(MPart); - expect(mainPart.parent).toBe(rootNode); - expect(mainPart.id).toEqual('A'); + // verify the initial part + const initialPart = rootNode.child2 as MPart; + expect(initialPart.constructor).toEqual(MPart); + expect(initialPart.parent).toBe(rootNode); + expect(initialPart.id).toEqual('A'); }); /** @@ -761,9 +763,9 @@ describe('WorkbenchLayout', () => { .addView('view.3', {partId: 'A'}) .addView('view.4', {partId: 'C'}); - expect(workbenchLayout.part({by: {partId: 'B'}}).views).toEqual([{id: 'view.1'}, {id: 'view.2'}]); - expect(workbenchLayout.part({by: {partId: 'A'}}).views).toEqual([{id: 'view.3'}]); - expect(workbenchLayout.part({by: {partId: 'C'}}).views).toEqual([{id: 'view.4'}]); + expect(workbenchLayout.part({partId: 'B'}).views).toEqual([{id: 'view.1'}, {id: 'view.2'}]); + expect(workbenchLayout.part({partId: 'A'}).views).toEqual([{id: 'view.3'}]); + expect(workbenchLayout.part({partId: 'C'}).views).toEqual([{id: 'view.4'}]); }); it('should remove non-structural part when removing its last view', () => { @@ -777,7 +779,7 @@ describe('WorkbenchLayout', () => { .removeView('view.1') .removeView('view.2'); - expect(() => workbenchLayout.part({by: {partId: 'left'}})).toThrowError(/NullPartError/); + expect(() => workbenchLayout.part({partId: 'left'})).toThrowError(/NullPartError/); expect(workbenchLayout.hasPart('left')).toBeFalse(); }); @@ -792,7 +794,7 @@ describe('WorkbenchLayout', () => { .removeView('view.1') .removeView('view.2'); - expect(workbenchLayout.part({by: {partId: 'left'}})).toEqual(jasmine.objectContaining({id: 'left'})); + expect(workbenchLayout.part({partId: 'left'})).toEqual(jasmine.objectContaining({id: 'left'})); }); /** @@ -817,7 +819,7 @@ describe('WorkbenchLayout', () => { .addView('view.3', {partId: 'A'}) .addView('view.4', {partId: 'A'}) .moveView('view.1', 'A', {position: 2}) - .part({by: {partId: 'A'}}) + .part({partId: 'A'}) .views.map(view => view.id), ).toEqual(['view.2', 'view.1', 'view.3', 'view.4']); @@ -831,7 +833,7 @@ describe('WorkbenchLayout', () => { .addView('view.3', {partId: 'A'}) .addView('view.4', {partId: 'A'}) .moveView('view.4', 'A', {position: 2}) - .part({by: {partId: 'A'}}) + .part({partId: 'A'}) .views.map(view => view.id), ).toEqual(['view.1', 'view.2', 'view.4', 'view.3']); @@ -845,7 +847,7 @@ describe('WorkbenchLayout', () => { .addView('view.3', {partId: 'A'}) .addView('view.4', {partId: 'A'}) .moveView('view.2', 'A', {position: 'end'}) - .part({by: {partId: 'A'}}) + .part({partId: 'A'}) .views.map(view => view.id), ).toEqual(['view.1', 'view.3', 'view.4', 'view.2']); @@ -859,7 +861,7 @@ describe('WorkbenchLayout', () => { .addView('view.3', {partId: 'A'}) .addView('view.4', {partId: 'A'}) .moveView('view.3', 'A', {position: 'start'}) - .part({by: {partId: 'A'}}) + .part({partId: 'A'}) .views.map(view => view.id), ).toEqual(['view.3', 'view.1', 'view.2', 'view.4']); @@ -873,7 +875,7 @@ describe('WorkbenchLayout', () => { .addView('view.3', {partId: 'A', activateView: true}) .addView('view.4', {partId: 'A'}) .moveView('view.1', 'A', {position: 'before-active-view'}) - .part({by: {partId: 'A'}}) + .part({partId: 'A'}) .views.map(view => view.id), ).toEqual(['view.2', 'view.1', 'view.3', 'view.4']); @@ -889,7 +891,7 @@ describe('WorkbenchLayout', () => { .addView('view.5', {partId: 'C', activateView: true}) .addView('view.6', {partId: 'C'}) .moveView('view.2', 'C', {position: 'before-active-view'}) - .part({by: {partId: 'C'}}) + .part({partId: 'C'}) .views.map(view => view.id), ).toEqual(['view.4', 'view.2', 'view.5', 'view.6']); @@ -903,7 +905,7 @@ describe('WorkbenchLayout', () => { .addView('view.3', {partId: 'A', activateView: true}) .addView('view.4', {partId: 'A'}) .moveView('view.1', 'A', {position: 'after-active-view'}) - .part({by: {partId: 'A'}}) + .part({partId: 'A'}) .views.map(view => view.id), ).toEqual(['view.2', 'view.3', 'view.1', 'view.4']); @@ -919,7 +921,7 @@ describe('WorkbenchLayout', () => { .addView('view.5', {partId: 'C', activateView: true}) .addView('view.6', {partId: 'C'}) .moveView('view.2', 'C', {position: 'after-active-view'}) - .part({by: {partId: 'C'}}) + .part({partId: 'C'}) .views.map(view => view.id), ).toEqual(['view.4', 'view.5', 'view.2', 'view.6']); @@ -933,7 +935,7 @@ describe('WorkbenchLayout', () => { .addView('view.3', {partId: 'A'}) .addView('view.4', {partId: 'A'}) .moveView('view.2', 'A') - .part({by: {partId: 'A'}}) + .part({partId: 'A'}) .views.map(view => view.id), ).toEqual(['view.1', 'view.2', 'view.3', 'view.4']); @@ -949,7 +951,7 @@ describe('WorkbenchLayout', () => { .addView('view.5', {partId: 'C', activateView: true}) .addView('view.6', {partId: 'C'}) .moveView('view.2', 'C') - .part({by: {partId: 'C'}}) + .part({partId: 'C'}) .views.map(view => view.id), ).toEqual(['view.4', 'view.5', 'view.6', 'view.2']); }); @@ -978,9 +980,113 @@ describe('WorkbenchLayout', () => { .moveView('view.2', 'C') .moveView('view.3', 'C'); - expect(workbenchLayout.part({by: {partId: 'B'}}).views).toEqual([{id: 'view.1'}]); - expect(workbenchLayout.part({by: {partId: 'C'}}).views).toEqual([{id: 'view.2'}, {id: 'view.3'}]); - expect(workbenchLayout.part({by: {partId: 'A'}}).views).toEqual([{id: 'view.4'}]); + expect(workbenchLayout.part({partId: 'B'}).views).toEqual([{id: 'view.1'}]); + expect(workbenchLayout.part({partId: 'C'}).views).toEqual([{id: 'view.2'}, {id: 'view.3'}]); + expect(workbenchLayout.part({partId: 'A'}).views).toEqual([{id: 'view.4'}]); + }); + + it('should retain navigation when moving view to another part', () => { + const workbenchLayout = TestBed.inject(ɵWorkbenchLayoutFactory) + .addPart('left') + .addPart('right', {relativeTo: 'left', align: 'right'}) + .addView('view.1', {partId: 'left', cssClass: 'class-view'}) + .addView('view.2', {partId: 'left'}) + .navigateView('view.1', ['path/to/view'], {cssClass: 'class-navigation'}) + .navigateView('view.2', [], {hint: 'some-hint'}) + .moveView('view.1', 'right') + .moveView('view.2', 'right'); + + expect(workbenchLayout.part({partId: 'right'}).views).toEqual(jasmine.arrayWithExactContents([ + {id: 'view.1', navigation: {cssClass: ['class-navigation']}, cssClass: ['class-view']} satisfies MView, + {id: 'view.2', navigation: {hint: 'some-hint'}} satisfies MView, + ])); + expect(workbenchLayout.urlSegments({viewId: 'view.1'})).toEqual(segments(['path/to/view'])); + expect(workbenchLayout.urlSegments({viewId: 'view.2'})).toEqual([]); + }); + + it('should retain state when moving view to another part', () => { + const workbenchLayout = TestBed.inject(ɵWorkbenchLayoutFactory) + .addPart('left') + .addPart('right', {relativeTo: 'left', align: 'right'}) + .addView('view.1', {partId: 'left'}) + .navigateView('view.1', ['path/to/view'], {state: {some: 'state'}}) + .moveView('view.1', 'right'); + + expect(workbenchLayout.part({partId: 'right'}).views).toEqual([{id: 'view.1', navigation: {}} satisfies MView]); + expect(workbenchLayout.viewState({viewId: 'view.1'})).toEqual({some: 'state'}); + }); + + it('should clear hint of previous navigation when navigating without hint', () => { + const workbenchLayout = TestBed.inject(ɵWorkbenchLayoutFactory) + .addPart('part') + .addView('view.1', {partId: 'part'}) + .navigateView('view.1', [], {hint: 'some-hint'}) + .navigateView('view.1', ['path/to/view']); + + expect(workbenchLayout.view({viewId: 'view.1'})).toEqual({id: 'view.1', navigation: {}} satisfies MView); + expect(workbenchLayout.urlSegments({viewId: 'view.1'})).toEqual(segments(['path/to/view'])); + }); + + it('should clear URL of previous navigation when navigating without URL', () => { + const workbenchLayout = TestBed.inject(ɵWorkbenchLayoutFactory) + .addPart('part') + .addView('view.1', {partId: 'part'}) + .navigateView('view.1', ['path/to/view']) + .navigateView('view.1', [], {hint: 'some-hint'}); + + expect(workbenchLayout.view({viewId: 'view.1'})).toEqual({id: 'view.1', navigation: {hint: 'some-hint'}} satisfies MView); + expect(workbenchLayout.urlSegments({viewId: 'view.1'})).toEqual([]); + }); + + it('should clear state of previous navigation when navigating without state', () => { + const workbenchLayout = TestBed.inject(ɵWorkbenchLayoutFactory) + .addPart('part') + .addView('view.1', {partId: 'part'}) + .navigateView('view.1', ['path/to/view'], {state: {some: 'state'}}) + .navigateView('view.1', ['path/to/view']); + + expect(workbenchLayout.view({viewId: 'view.1'})).toEqual({id: 'view.1', navigation: {}} satisfies MView); + expect(workbenchLayout.viewState({viewId: 'view.1'})).toEqual({}); + expect(workbenchLayout.urlSegments({viewId: 'view.1'})).toEqual(segments(['path/to/view'])); + }); + + it('should remove views of a part when removing a part', () => { + const workbenchLayout = TestBed.inject(ɵWorkbenchLayoutFactory) + .addPart(MAIN_AREA) + .addPart('part', {align: 'right'}) + .addView('view.1', {partId: 'part'}) + .navigateView('view.1', ['path/to/view'], {state: {some: 'state'}}) + .removePart('part'); + + expect(workbenchLayout.view({viewId: 'view.1'}, {orElse: null})).toBeNull(); + expect(workbenchLayout.viewState({viewId: 'view.1'})).toEqual({}); + expect(workbenchLayout.urlSegments({viewId: 'view.1'})).toEqual([]); + }); + + it('should remove associated data when removing view', () => { + const workbenchLayout = TestBed.inject(ɵWorkbenchLayoutFactory) + .addPart('part') + .addView('view.1', {partId: 'part'}) + .navigateView('view.1', ['path/to/view'], {state: {some: 'state'}}) + .removeView('view.1'); + + expect(workbenchLayout.view({viewId: 'view.1'}, {orElse: null})).toBeNull(); + expect(workbenchLayout.viewState({viewId: 'view.1'})).toEqual({}); + expect(workbenchLayout.urlSegments({viewId: 'view.1'})).toEqual([]); + }); + + it('should also rename associated data when renaming view', () => { + const workbenchLayout = TestBed.inject(ɵWorkbenchLayoutFactory) + .addPart('part') + .addView('view.1', {partId: 'part'}) + .navigateView('view.1', ['path/to/view'], {state: {some: 'state'}}) + .renameView('view.1', 'view.2'); + + expect(workbenchLayout.viewState({viewId: 'view.1'})).toEqual({}); + expect(workbenchLayout.urlSegments({viewId: 'view.1'})).toEqual([]); + + expect(workbenchLayout.viewState({viewId: 'view.2'})).toEqual({some: 'state'}); + expect(workbenchLayout.urlSegments({viewId: 'view.2'})).toEqual(segments(['path/to/view'])); }); it('should activate part and view when moving view to another part', () => { @@ -998,15 +1104,15 @@ describe('WorkbenchLayout', () => { .activateView('view.3'); expect(workbenchLayout.activePart({grid: 'mainArea'})!.id).toEqual('left'); - expect(workbenchLayout.part({by: {partId: 'left'}}).activeViewId).toEqual('view.1'); - expect(workbenchLayout.part({by: {partId: 'right'}}).activeViewId).toEqual('view.3'); + expect(workbenchLayout.part({partId: 'left'}).activeViewId).toEqual('view.1'); + expect(workbenchLayout.part({partId: 'right'}).activeViewId).toEqual('view.3'); // Move view.1 to part right workbenchLayout = workbenchLayout.moveView('view.1', 'right', {activatePart: true, activateView: true}); expect(workbenchLayout.activePart({grid: 'mainArea'})!.id).toEqual('right'); - expect(workbenchLayout.part({by: {partId: 'left'}}).activeViewId).toEqual('view.2'); - expect(workbenchLayout.part({by: {partId: 'right'}}).activeViewId).toEqual('view.1'); + expect(workbenchLayout.part({partId: 'left'}).activeViewId).toEqual('view.2'); + expect(workbenchLayout.part({partId: 'right'}).activeViewId).toEqual('view.1'); }); it('should not activate part and view when moving view to another part', () => { @@ -1024,15 +1130,15 @@ describe('WorkbenchLayout', () => { .activateView('view.3'); expect(workbenchLayout.activePart({grid: 'mainArea'})!.id).toEqual('left'); - expect(workbenchLayout.part({by: {partId: 'left'}}).activeViewId).toEqual('view.1'); - expect(workbenchLayout.part({by: {partId: 'right'}}).activeViewId).toEqual('view.3'); + expect(workbenchLayout.part({partId: 'left'}).activeViewId).toEqual('view.1'); + expect(workbenchLayout.part({partId: 'right'}).activeViewId).toEqual('view.3'); // Move view.1 to part right workbenchLayout = workbenchLayout.moveView('view.1', 'right'); expect(workbenchLayout.activePart({grid: 'mainArea'})!.id).toEqual('left'); - expect(workbenchLayout.part({by: {partId: 'left'}}).activeViewId).toEqual('view.2'); - expect(workbenchLayout.part({by: {partId: 'right'}}).activeViewId).toEqual('view.3'); + expect(workbenchLayout.part({partId: 'left'}).activeViewId).toEqual('view.2'); + expect(workbenchLayout.part({partId: 'right'}).activeViewId).toEqual('view.3'); }); it('should activate part and view when moving view inside the part', () => { @@ -1050,15 +1156,15 @@ describe('WorkbenchLayout', () => { .activateView('view.3'); expect(workbenchLayout.activePart({grid: 'mainArea'})!.id).toEqual('right'); - expect(workbenchLayout.part({by: {partId: 'left'}}).activeViewId).toEqual('view.1'); - expect(workbenchLayout.part({by: {partId: 'right'}}).activeViewId).toEqual('view.3'); + expect(workbenchLayout.part({partId: 'left'}).activeViewId).toEqual('view.1'); + expect(workbenchLayout.part({partId: 'right'}).activeViewId).toEqual('view.3'); // Move view.1 to part right workbenchLayout = workbenchLayout.moveView('view.2', 'left', {position: 0, activatePart: true, activateView: true}); expect(workbenchLayout.activePart({grid: 'mainArea'})!.id).toEqual('left'); - expect(workbenchLayout.part({by: {partId: 'left'}}).activeViewId).toEqual('view.2'); - expect(workbenchLayout.part({by: {partId: 'right'}}).activeViewId).toEqual('view.3'); + expect(workbenchLayout.part({partId: 'left'}).activeViewId).toEqual('view.2'); + expect(workbenchLayout.part({partId: 'right'}).activeViewId).toEqual('view.3'); }); it('should not activate part and view when moving view inside the part', () => { @@ -1076,15 +1182,15 @@ describe('WorkbenchLayout', () => { .activateView('view.3'); expect(workbenchLayout.activePart({grid: 'mainArea'})!.id).toEqual('right'); - expect(workbenchLayout.part({by: {partId: 'left'}}).activeViewId).toEqual('view.1'); - expect(workbenchLayout.part({by: {partId: 'right'}}).activeViewId).toEqual('view.3'); + expect(workbenchLayout.part({partId: 'left'}).activeViewId).toEqual('view.1'); + expect(workbenchLayout.part({partId: 'right'}).activeViewId).toEqual('view.3'); // Move view.1 to part right workbenchLayout = workbenchLayout.moveView('view.2', 'left', {position: 0}); expect(workbenchLayout.activePart({grid: 'mainArea'})!.id).toEqual('right'); - expect(workbenchLayout.part({by: {partId: 'left'}}).activeViewId).toEqual('view.1'); - expect(workbenchLayout.part({by: {partId: 'right'}}).activeViewId).toEqual('view.3'); + expect(workbenchLayout.part({partId: 'left'}).activeViewId).toEqual('view.1'); + expect(workbenchLayout.part({partId: 'right'}).activeViewId).toEqual('view.3'); }); /** @@ -1111,8 +1217,8 @@ describe('WorkbenchLayout', () => { .moveView('view.3', 'C'); expect(workbenchLayout.hasPart('B')).toBeFalse(); - expect(workbenchLayout.part({by: {partId: 'A'}}).views).toEqual([{id: 'view.1'}, {id: 'view.2'}]); - expect(workbenchLayout.part({by: {partId: 'C'}}).views).toEqual([{id: 'view.3'}]); + expect(workbenchLayout.part({partId: 'A'}).views).toEqual([{id: 'view.1'}, {id: 'view.2'}]); + expect(workbenchLayout.part({partId: 'C'}).views).toEqual([{id: 'view.3'}]); }); /** @@ -1138,9 +1244,9 @@ describe('WorkbenchLayout', () => { .moveView('view.2', 'A') .moveView('view.3', 'C'); - expect(workbenchLayout.part({by: {partId: 'B'}})).toEqual(jasmine.objectContaining({id: 'B'})); - expect(workbenchLayout.part({by: {partId: 'A'}}).views).toEqual([{id: 'view.1'}, {id: 'view.2'}]); - expect(workbenchLayout.part({by: {partId: 'C'}}).views).toEqual([{id: 'view.3'}]); + expect(workbenchLayout.part({partId: 'B'})).toEqual(jasmine.objectContaining({id: 'B'})); + expect(workbenchLayout.part({partId: 'A'}).views).toEqual([{id: 'view.1'}, {id: 'view.2'}]); + expect(workbenchLayout.part({partId: 'C'}).views).toEqual([{id: 'view.3'}]); }); it('should activate the most recently activated view when removing a view', () => { @@ -1152,8 +1258,8 @@ describe('WorkbenchLayout', () => { .addView('view.1', {partId: 'main'}) .addView('view.5', {partId: 'main'}) .addView('view.2', {partId: 'main'}) - .addView('view.4', {partId: 'main'}) - .addView('view.3', {partId: 'main'}); + .addView('view.3', {partId: 'main'}) + .addView('view.4', {partId: 'main'}); // prepare the activation history viewActivationInstantProviderSpyObj.getActivationInstant @@ -1166,16 +1272,16 @@ describe('WorkbenchLayout', () => { workbenchLayout = workbenchLayout .activateView('view.1') .removeView('view.1'); - expect(workbenchLayout.part({by: {partId: 'main'}}).activeViewId).toEqual('view.4'); + expect(workbenchLayout.part({partId: 'main'}).activeViewId).toEqual('view.4'); workbenchLayout = workbenchLayout.removeView('view.4'); - expect(workbenchLayout.part({by: {partId: 'main'}}).activeViewId).toEqual('view.2'); + expect(workbenchLayout.part({partId: 'main'}).activeViewId).toEqual('view.2'); workbenchLayout = workbenchLayout.removeView('view.2'); - expect(workbenchLayout.part({by: {partId: 'main'}}).activeViewId).toEqual('view.5'); + expect(workbenchLayout.part({partId: 'main'}).activeViewId).toEqual('view.5'); workbenchLayout = workbenchLayout.removeView('view.5'); - expect(workbenchLayout.part({by: {partId: 'main'}}).activeViewId).toEqual('view.3'); + expect(workbenchLayout.part({partId: 'main'}).activeViewId).toEqual('view.3'); }); /** @@ -1436,7 +1542,7 @@ describe('WorkbenchLayout', () => { expect(workbenchLayout.activePart({grid: 'mainArea'})!.id).toEqual('B'); }); - it('should compute next view id for views that are target of a primary route', async () => { + it('should compute next view id', async () => { TestBed.overrideProvider(MAIN_AREA_INITIAL_PART_ID, {useValue: 'main'}); let workbenchLayout = TestBed.inject(ɵWorkbenchLayoutFactory).addPart(MAIN_AREA); @@ -1450,9 +1556,6 @@ describe('WorkbenchLayout', () => { workbenchLayout = workbenchLayout.addView('view.2', {partId: 'main'}); expect(workbenchLayout.computeNextViewId()).toEqual('view.3'); - workbenchLayout = workbenchLayout.addView('view.6', {partId: 'main'}); - expect(workbenchLayout.computeNextViewId()).toEqual('view.3'); - workbenchLayout = workbenchLayout.addView('view.3', {partId: 'main'}); expect(workbenchLayout.computeNextViewId()).toEqual('view.4'); @@ -1460,6 +1563,9 @@ describe('WorkbenchLayout', () => { expect(workbenchLayout.computeNextViewId()).toEqual('view.5'); workbenchLayout = workbenchLayout.addView('view.5', {partId: 'main'}); + expect(workbenchLayout.computeNextViewId()).toEqual('view.6'); + + workbenchLayout = workbenchLayout.addView('view.6', {partId: 'main'}); expect(workbenchLayout.computeNextViewId()).toEqual('view.7'); workbenchLayout = workbenchLayout.removeView('view.3'); @@ -1524,40 +1630,40 @@ describe('WorkbenchLayout', () => { .addView('view.4', {partId: 'outerRight'}); // Find by part id - expect(workbenchLayout.part({by: {partId: 'outerLeft'}}).id).toEqual('outerLeft'); - expect(workbenchLayout.part({by: {partId: 'innerLeft'}}).id).toEqual('innerLeft'); - expect(workbenchLayout.part({by: {partId: 'innerRight'}}).id).toEqual('innerRight'); - expect(workbenchLayout.part({by: {partId: 'outerRight'}}).id).toEqual('outerRight'); + expect(workbenchLayout.part({partId: 'outerLeft'}).id).toEqual('outerLeft'); + expect(workbenchLayout.part({partId: 'innerLeft'}).id).toEqual('innerLeft'); + expect(workbenchLayout.part({partId: 'innerRight'}).id).toEqual('innerRight'); + expect(workbenchLayout.part({partId: 'outerRight'}).id).toEqual('outerRight'); // Find by grid and part id - expect(workbenchLayout.part({grid: 'workbench', by: {partId: 'outerLeft'}}).id).toEqual('outerLeft'); - expect(workbenchLayout.part({grid: 'mainArea', by: {partId: 'innerLeft'}}).id).toEqual('innerLeft'); - expect(workbenchLayout.part({grid: 'mainArea', by: {partId: 'innerRight'}}).id).toEqual('innerRight'); - expect(workbenchLayout.part({grid: 'workbench', by: {partId: 'outerRight'}}).id).toEqual('outerRight'); + expect(workbenchLayout.part({grid: 'workbench', partId: 'outerLeft'}).id).toEqual('outerLeft'); + expect(workbenchLayout.part({grid: 'mainArea', partId: 'innerLeft'}).id).toEqual('innerLeft'); + expect(workbenchLayout.part({grid: 'mainArea', partId: 'innerRight'}).id).toEqual('innerRight'); + expect(workbenchLayout.part({grid: 'workbench', partId: 'outerRight'}).id).toEqual('outerRight'); // Find by view id - expect(workbenchLayout.part({by: {viewId: 'view.1'}}).id).toEqual('innerLeft'); - expect(workbenchLayout.part({by: {viewId: 'view.2'}}).id).toEqual('innerRight'); - expect(workbenchLayout.part({by: {viewId: 'view.3'}}).id).toEqual('outerLeft'); - expect(workbenchLayout.part({by: {viewId: 'view.4'}}).id).toEqual('outerRight'); + expect(workbenchLayout.part({viewId: 'view.1'}).id).toEqual('innerLeft'); + expect(workbenchLayout.part({viewId: 'view.2'}).id).toEqual('innerRight'); + expect(workbenchLayout.part({viewId: 'view.3'}).id).toEqual('outerLeft'); + expect(workbenchLayout.part({viewId: 'view.4'}).id).toEqual('outerRight'); // Find by grid and view id - expect(workbenchLayout.part({grid: 'mainArea', by: {viewId: 'view.1'}}).id).toEqual('innerLeft'); - expect(workbenchLayout.part({grid: 'mainArea', by: {viewId: 'view.2'}}).id).toEqual('innerRight'); - expect(workbenchLayout.part({grid: 'workbench', by: {viewId: 'view.3'}}).id).toEqual('outerLeft'); - expect(workbenchLayout.part({grid: 'workbench', by: {viewId: 'view.4'}}).id).toEqual('outerRight'); + expect(workbenchLayout.part({grid: 'mainArea', viewId: 'view.1'}).id).toEqual('innerLeft'); + expect(workbenchLayout.part({grid: 'mainArea', viewId: 'view.2'}).id).toEqual('innerRight'); + expect(workbenchLayout.part({grid: 'workbench', viewId: 'view.3'}).id).toEqual('outerLeft'); + expect(workbenchLayout.part({grid: 'workbench', viewId: 'view.4'}).id).toEqual('outerRight'); // Find by part id and view id - expect(workbenchLayout.part({by: {partId: 'innerLeft', viewId: 'view.1'}}).id).toEqual('innerLeft'); - expect(workbenchLayout.part({by: {partId: 'innerRight', viewId: 'view.2'}}).id).toEqual('innerRight'); - expect(workbenchLayout.part({by: {partId: 'outerLeft', viewId: 'view.3'}}).id).toEqual('outerLeft'); - expect(workbenchLayout.part({by: {partId: 'outerRight', viewId: 'view.4'}}).id).toEqual('outerRight'); + expect(workbenchLayout.part({partId: 'innerLeft', viewId: 'view.1'}).id).toEqual('innerLeft'); + expect(workbenchLayout.part({partId: 'innerRight', viewId: 'view.2'}).id).toEqual('innerRight'); + expect(workbenchLayout.part({partId: 'outerLeft', viewId: 'view.3'}).id).toEqual('outerLeft'); + expect(workbenchLayout.part({partId: 'outerRight', viewId: 'view.4'}).id).toEqual('outerRight'); // Find by grid, part id and view id - expect(workbenchLayout.part({grid: 'mainArea', by: {partId: 'innerLeft', viewId: 'view.1'}}).id).toEqual('innerLeft'); - expect(workbenchLayout.part({grid: 'mainArea', by: {partId: 'innerRight', viewId: 'view.2'}}).id).toEqual('innerRight'); - expect(workbenchLayout.part({grid: 'workbench', by: {partId: 'outerLeft', viewId: 'view.3'}}).id).toEqual('outerLeft'); - expect(workbenchLayout.part({grid: 'workbench', by: {partId: 'outerRight', viewId: 'view.4'}}).id).toEqual('outerRight'); + expect(workbenchLayout.part({grid: 'mainArea', partId: 'innerLeft', viewId: 'view.1'}).id).toEqual('innerLeft'); + expect(workbenchLayout.part({grid: 'mainArea', partId: 'innerRight', viewId: 'view.2'}).id).toEqual('innerRight'); + expect(workbenchLayout.part({grid: 'workbench', partId: 'outerLeft', viewId: 'view.3'}).id).toEqual('outerLeft'); + expect(workbenchLayout.part({grid: 'workbench', partId: 'outerRight', viewId: 'view.4'}).id).toEqual('outerRight'); }); it('should throw an error if not finding the part', () => { @@ -1567,12 +1673,12 @@ describe('WorkbenchLayout', () => { .addPart(MAIN_AREA) .addView('view.1', {partId: 'main'}); - expect(() => workbenchLayout.part({by: {partId: 'does-not-exist'}})).toThrowError(/NullPartError/); - expect(() => workbenchLayout.part({by: {partId: 'does-not-exist', viewId: 'view.1'}})).toThrowError(/NullPartError/); - expect(() => workbenchLayout.part({by: {partId: 'main', viewId: 'view.2'}})).toThrowError(/NullPartError/); - expect(() => workbenchLayout.part({grid: 'workbench', by: {partId: 'main', viewId: 'view.1'}})).toThrowError(/NullPartError/); - expect(() => workbenchLayout.part({grid: 'workbench', by: {viewId: 'view.1'}})).toThrowError(/NullPartError/); - expect(() => workbenchLayout.part({grid: 'workbench', by: {partId: 'main'}})).toThrowError(/NullPartError/); + expect(() => workbenchLayout.part({partId: 'does-not-exist'})).toThrowError(/NullPartError/); + expect(() => workbenchLayout.part({partId: 'does-not-exist', viewId: 'view.1'})).toThrowError(/NullPartError/); + expect(() => workbenchLayout.part({partId: 'main', viewId: 'view.2'})).toThrowError(/NullPartError/); + expect(() => workbenchLayout.part({grid: 'workbench', partId: 'main', viewId: 'view.1'})).toThrowError(/NullPartError/); + expect(() => workbenchLayout.part({grid: 'workbench', viewId: 'view.1'})).toThrowError(/NullPartError/); + expect(() => workbenchLayout.part({grid: 'workbench', partId: 'main'})).toThrowError(/NullPartError/); }); it('should return `null` if not finding the part', () => { @@ -1582,12 +1688,12 @@ describe('WorkbenchLayout', () => { .addPart(MAIN_AREA) .addView('view.1', {partId: 'main'}); - expect(workbenchLayout.part({by: {partId: 'does-not-exist'}}, {orElse: null})).toBeNull(); - expect(workbenchLayout.part({by: {partId: 'does-not-exist', viewId: 'view.1'}}, {orElse: null})).toBeNull(); - expect(workbenchLayout.part({by: {partId: 'main', viewId: 'view.2'}}, {orElse: null})).toBeNull(); - expect(workbenchLayout.part({grid: 'workbench', by: {partId: 'main', viewId: 'view.1'}}, {orElse: null})).toBeNull(); - expect(workbenchLayout.part({grid: 'workbench', by: {viewId: 'view.1'}}, {orElse: null})).toBeNull(); - expect(workbenchLayout.part({grid: 'workbench', by: {partId: 'main'}}, {orElse: null})).toBeNull(); + expect(workbenchLayout.part({partId: 'does-not-exist'}, {orElse: null})).toBeNull(); + expect(workbenchLayout.part({partId: 'does-not-exist', viewId: 'view.1'}, {orElse: null})).toBeNull(); + expect(workbenchLayout.part({partId: 'main', viewId: 'view.2'}, {orElse: null})).toBeNull(); + expect(workbenchLayout.part({grid: 'workbench', partId: 'main', viewId: 'view.1'}, {orElse: null})).toBeNull(); + expect(workbenchLayout.part({grid: 'workbench', viewId: 'view.1'}, {orElse: null})).toBeNull(); + expect(workbenchLayout.part({grid: 'workbench', partId: 'main'}, {orElse: null})).toBeNull(); }); it('should return whether a part is contained in the main area', () => { @@ -1622,6 +1728,119 @@ describe('WorkbenchLayout', () => { expect(workbenchLayout.views({grid: 'mainArea'}).map(view => view.id)).toEqual(jasmine.arrayWithExactContents(['view.1', 'view.2'])); }); + it('should find views by URL segments', () => { + const layout = TestBed.inject(ɵWorkbenchLayoutFactory) + .addPart(MAIN_AREA) + .addView('view.1', {partId: MAIN_AREA}) + .addView('view.2', {partId: MAIN_AREA}) + .addView('view.3', {partId: MAIN_AREA}) + .navigateView('view.1', ['path', 'to', 'view', '1']) + .navigateView('view.2', ['path', 'to', 'view', '2']) + .navigateView('view.3', ['path', 'to', 'view', '2']); + + expect(layout + .views({segments: new UrlSegmentMatcher(segments(['path']), {matchWildcardPath: false, matchMatrixParams: false})}) + .map(view => view.id), + ).toEqual([]); + + expect(layout + .views({segments: new UrlSegmentMatcher(segments(['path', 'to', 'view']), {matchWildcardPath: false, matchMatrixParams: false})}) + .map(view => view.id), + ).toEqual([]); + + expect(layout + .views({segments: new UrlSegmentMatcher(segments(['path', 'to', 'view', '1']), {matchWildcardPath: false, matchMatrixParams: false})}) + .map(view => view.id), + ).toEqual(['view.1']); + + expect(layout + .views({segments: new UrlSegmentMatcher(segments(['path', 'to', 'view', '2']), {matchWildcardPath: false, matchMatrixParams: false})}) + .map(view => view.id), + ).toEqual(jasmine.arrayWithExactContents(['view.2', 'view.3'])); + + expect(layout + .views({segments: new UrlSegmentMatcher(segments(['path', 'to', 'view', '*']), {matchWildcardPath: false, matchMatrixParams: false})}) + .map(view => view.id), + ).toEqual([]); + + expect(layout + .views({segments: new UrlSegmentMatcher(segments(['path', 'to', 'view', '*']), {matchWildcardPath: true, matchMatrixParams: false})}) + .map(view => view.id), + ).toEqual(jasmine.arrayWithExactContents(['view.1', 'view.2', 'view.3'])); + }); + + it('should find views by URL segments (matrix params matching)', () => { + const layout = TestBed.inject(ɵWorkbenchLayoutFactory) + .addPart(MAIN_AREA) + .addView('view.1', {partId: MAIN_AREA}) + .addView('view.2', {partId: MAIN_AREA}) + .addView('view.3', {partId: MAIN_AREA}) + .navigateView('view.1', ['path', 'to', 'view']) + .navigateView('view.2', ['path', 'to', 'view', {matrixParam: 'A'}]) + .navigateView('view.3', ['path', 'to', 'view', {matrixParam: 'B'}]); + + expect(layout + .views({segments: new UrlSegmentMatcher(segments(['path', 'to', 'view']), {matchWildcardPath: false, matchMatrixParams: false})}) + .map(view => view.id), + ).toEqual(jasmine.arrayWithExactContents(['view.1', 'view.2', 'view.3'])); + + expect(layout + .views({segments: new UrlSegmentMatcher(segments(['path', 'to', 'view']), {matchWildcardPath: false, matchMatrixParams: true})}) + .map(view => view.id), + ).toEqual(['view.1']); + + expect(layout + .views({segments: new UrlSegmentMatcher(segments(['path', 'to', 'view', {matrixParam: 'A'}]), {matchWildcardPath: false, matchMatrixParams: false})}) + .map(view => view.id), + ).toEqual(jasmine.arrayWithExactContents(['view.1', 'view.2', 'view.3'])); + + expect(layout + .views({segments: new UrlSegmentMatcher(segments(['path', 'to', 'view', {matrixParam: 'A'}]), {matchWildcardPath: false, matchMatrixParams: true})}) + .map(view => view.id), + ).toEqual(['view.2']); + }); + + it('should find views by navigation hint', () => { + const layout = TestBed.inject(ɵWorkbenchLayoutFactory) + .addPart(MAIN_AREA) + .addView('view.1', {partId: MAIN_AREA}) + .addView('view.2', {partId: MAIN_AREA}) + .addView('view.3', {partId: MAIN_AREA}) + .navigateView('view.1', ['path', 'to', 'view', '1']) + .navigateView('view.2', ['path', 'to', 'view', '2'], {hint: 'hint1'}) + .navigateView('view.3', [], {hint: 'hint2'}); + + expect(layout + .views() + .map(view => view.id), + ).toEqual(jasmine.arrayWithExactContents(['view.1', 'view.2', 'view.3'])); + + expect(layout + .views({navigationHint: undefined}) + .map(view => view.id), + ).toEqual(jasmine.arrayWithExactContents(['view.1', 'view.2', 'view.3'])); + + expect(layout + .views({navigationHint: ''}) + .map(view => view.id), + ).toEqual([]); + + expect(layout + .views({navigationHint: null}) + .map(view => view.id), + ).toEqual(['view.1']); + + expect(layout + .views({navigationHint: 'hint1'}) + .map(view => view.id), + ).toEqual(['view.2']); + + expect(layout + .views({navigationHint: 'hint2'}) + .map(view => view.id), + ).toEqual(['view.3']); + }); + it('should activate adjacent view', () => { TestBed.overrideProvider(MAIN_AREA_INITIAL_PART_ID, {useValue: 'main'}); @@ -1633,27 +1852,27 @@ describe('WorkbenchLayout', () => { .addView('view.3', {partId: 'part'}); expect(workbenchLayout.activePart({grid: 'mainArea'})!.id).toEqual('main'); - expect(workbenchLayout.part({by: {partId: 'part'}}).activeViewId).toBeUndefined(); + expect(workbenchLayout.part({partId: 'part'}).activeViewId).toBeUndefined(); // Activate adjacent view workbenchLayout = workbenchLayout.activateAdjacentView('view.2'); expect(workbenchLayout.activePart({grid: 'mainArea'})!.id).toEqual('main'); - expect(workbenchLayout.part({by: {partId: 'part'}}).activeViewId).toEqual('view.1'); + expect(workbenchLayout.part({partId: 'part'}).activeViewId).toEqual('view.1'); // Activate adjacent view workbenchLayout = workbenchLayout.activateAdjacentView('view.3'); expect(workbenchLayout.activePart({grid: 'mainArea'})!.id).toEqual('main'); - expect(workbenchLayout.part({by: {partId: 'part'}}).activeViewId).toEqual('view.2'); + expect(workbenchLayout.part({partId: 'part'}).activeViewId).toEqual('view.2'); // Activate adjacent view workbenchLayout = workbenchLayout.activateAdjacentView('view.1'); expect(workbenchLayout.activePart({grid: 'mainArea'})!.id).toEqual('main'); - expect(workbenchLayout.part({by: {partId: 'part'}}).activeViewId).toEqual('view.2'); + expect(workbenchLayout.part({partId: 'part'}).activeViewId).toEqual('view.2'); // Activate adjacent view workbenchLayout = workbenchLayout.activateAdjacentView('view.2', {activatePart: true}); expect(workbenchLayout.activePart({grid: 'mainArea'})!.id).toEqual('part'); - expect(workbenchLayout.part({by: {partId: 'part'}}).activeViewId).toEqual('view.1'); + expect(workbenchLayout.part({partId: 'part'}).activeViewId).toEqual('view.1'); }); it('should allow activating a part', () => { @@ -1709,54 +1928,48 @@ describe('WorkbenchLayout', () => { expect(changedLayout.views().map(view => view.id)).toEqual(jasmine.arrayWithExactContents(['view.10', 'view.2', 'view.3', 'view.4'])); // Rename 'view.1' to 'view.10' [grid=mainArea] - changedLayout = workbenchLayout.renameView('view.1', 'view.10', {grid: 'mainArea'}); + changedLayout = workbenchLayout.renameView('view.1', 'view.10'); expect(changedLayout.views().map(view => view.id)).toEqual(jasmine.arrayWithExactContents(['view.10', 'view.2', 'view.3', 'view.4'])); - // Rename 'view.1' to 'view.10' [grid=workbench] (wrong grid) - expect(() => workbenchLayout.renameView('view.1', 'view.10', {grid: 'workbench'})).toThrowError(/NullPartError/); - // Rename 'view.3' to 'view.30' changedLayout = workbenchLayout.renameView('view.3', 'view.30'); expect(changedLayout.views().map(view => view.id)).toEqual(jasmine.arrayWithExactContents(['view.1', 'view.2', 'view.30', 'view.4'])); - // Rename 'view.3' to 'view.30' [grid=mainArea] (wrong grid) - expect(() => workbenchLayout.renameView('view.3', 'view.30', {grid: 'mainArea'})).toThrowError(/NullPartError/); - // Rename 'view.3' to 'view.30' [grid=workbench] - changedLayout = workbenchLayout.renameView('view.3', 'view.30', {grid: 'workbench'}); + changedLayout = workbenchLayout.renameView('view.3', 'view.30'); expect(changedLayout.views().map(view => view.id)).toEqual(jasmine.arrayWithExactContents(['view.1', 'view.2', 'view.30', 'view.4'])); // Rename 'view.99' (does not exist) - expect(() => workbenchLayout.renameView('view.99', 'view.999')).toThrowError(/NullPartError/); + expect(() => workbenchLayout.renameView('view.99', 'view.999')).toThrowError(/NullViewError/); // Rename 'view.1' to 'view.2' - expect(() => workbenchLayout.renameView('view.1', 'view.2')).toThrowError(/\[IllegalArgumentError] View id must be unique/); + expect(() => workbenchLayout.renameView('view.1', 'view.2')).toThrowError(/\[ViewRenameError] View id must be unique/); // Rename 'view.2' to 'view.3' - expect(() => workbenchLayout.renameView('view.2', 'view.3')).toThrowError(/\[IllegalArgumentError] View id must be unique/); + expect(() => workbenchLayout.renameView('view.2', 'view.3')).toThrowError(/\[ViewRenameError] View id must be unique/); // Rename 'view.3' to 'view.4' - expect(() => workbenchLayout.renameView('view.3', 'view.4')).toThrowError(/\[IllegalArgumentError] View id must be unique/); + expect(() => workbenchLayout.renameView('view.3', 'view.4')).toThrowError(/\[ViewRenameError] View id must be unique/); // Rename 'view.1' to 'view.10' and expect activated view to be changed. changedLayout = workbenchLayout.renameView('view.1', 'view.10'); expect(changedLayout.views().map(view => view.id)).toEqual(jasmine.arrayWithExactContents(['view.10', 'view.2', 'view.3', 'view.4'])); - expect(changedLayout.part({by: {viewId: 'view.10'}}).activeViewId).toEqual('view.10'); + expect(changedLayout.part({viewId: 'view.10'}).activeViewId).toEqual('view.10'); // Rename 'view.2' to 'view.20' and expect activated view not to be changed. changedLayout = workbenchLayout.renameView('view.2', 'view.20'); expect(changedLayout.views().map(view => view.id)).toEqual(jasmine.arrayWithExactContents(['view.1', 'view.20', 'view.3', 'view.4'])); - expect(changedLayout.part({by: {viewId: 'view.20'}}).activeViewId).toEqual('view.1'); + expect(changedLayout.part({viewId: 'view.20'}).activeViewId).toEqual('view.1'); // Rename 'view.3' to 'view.30' and expect activated view to be changed. changedLayout = workbenchLayout.renameView('view.3', 'view.30'); expect(changedLayout.views().map(view => view.id)).toEqual(jasmine.arrayWithExactContents(['view.1', 'view.2', 'view.30', 'view.4'])); - expect(changedLayout.part({by: {viewId: 'view.30'}}).activeViewId).toEqual('view.30'); + expect(changedLayout.part({viewId: 'view.30'}).activeViewId).toEqual('view.30'); // Rename 'view.4' to 'view.40' and expect activated view not to be changed. changedLayout = workbenchLayout.renameView('view.4', 'view.40'); expect(changedLayout.views().map(view => view.id)).toEqual(jasmine.arrayWithExactContents(['view.1', 'view.2', 'view.3', 'view.40'])); - expect(changedLayout.part({by: {viewId: 'view.40'}}).activeViewId).toEqual('view.3'); + expect(changedLayout.part({viewId: 'view.40'}).activeViewId).toEqual('view.3'); }); it('should allow setting split ratio', () => { @@ -1774,17 +1987,17 @@ describe('WorkbenchLayout', () => { expect(findParentNode('left').ratio).toEqual(.3); // Expect to error if setting the ratio for a node not contained in the layout. - expect(() => workbenchLayout.setSplitRatio('does-not-exist', .3)).toThrowError(/NullNodeError/); + expect(() => workbenchLayout.setSplitRatio('does-not-exist', .3)).toThrowError(/NullElementError/); // Expect to error if setting an illegal ratio. - expect(() => workbenchLayout.setSplitRatio(findParentNode('left').nodeId, -.1)).toThrowError(/IllegalArgumentError/); - expect(() => workbenchLayout.setSplitRatio(findParentNode('left').nodeId, 0)).not.toThrowError(/IllegalArgumentError/); - expect(() => workbenchLayout.setSplitRatio(findParentNode('left').nodeId, .5)).not.toThrowError(/IllegalArgumentError/); - expect(() => workbenchLayout.setSplitRatio(findParentNode('left').nodeId, 1)).not.toThrowError(/IllegalArgumentError/); - expect(() => workbenchLayout.setSplitRatio(findParentNode('left').nodeId, 1.1)).toThrowError(/IllegalArgumentError/); + expect(() => workbenchLayout.setSplitRatio(findParentNode('left').nodeId, -.1)).toThrowError(/LayoutModifyError/); + expect(() => workbenchLayout.setSplitRatio(findParentNode('left').nodeId, 0)).not.toThrowError(/LayoutModifyError/); + expect(() => workbenchLayout.setSplitRatio(findParentNode('left').nodeId, .5)).not.toThrowError(/LayoutModifyError/); + expect(() => workbenchLayout.setSplitRatio(findParentNode('left').nodeId, 1)).not.toThrowError(/LayoutModifyError/); + expect(() => workbenchLayout.setSplitRatio(findParentNode('left').nodeId, 1.1)).toThrowError(/LayoutModifyError/); function findParentNode(partId: string): MTreeNode { - const parent = workbenchLayout.part({by: {partId}}).parent; + const parent = workbenchLayout.part({partId}).parent; if (!parent) { throw Error(`[MTreeNodeNotFoundError] Parent MTreeNode not found [partId=${partId}].`); } diff --git a/projects/scion/workbench/src/lib/layout/workbench-layout.ts b/projects/scion/workbench/src/lib/layout/workbench-layout.ts index 4927af65f..5ef18a078 100644 --- a/projects/scion/workbench/src/lib/layout/workbench-layout.ts +++ b/projects/scion/workbench/src/lib/layout/workbench-layout.ts @@ -8,6 +8,9 @@ * SPDX-License-Identifier: EPL-2.0 */ +import {Commands, ViewState} from '../routing/routing.model'; +import {ActivatedRoute} from '@angular/router'; + /** * The workbench layout is a grid of parts. Parts are aligned relative to each other. A part is a stack of views. Content is * displayed in views. @@ -17,7 +20,7 @@ * the user's workflow. Defining a main area is optional and recommended for applications requiring a dedicated and maximizable * area for user interaction. * - * Multiple layouts, called perspectives, are supported. Perspectives can be switched with one perspective active at a time. + * Multiple layouts, called perspectives, are supported. Perspectives can be switched. Only one perspective is active at a time. * Perspectives share the same main area, if any. * * The layout is an immutable object that provides methods to modify the layout. Modifications have no @@ -31,7 +34,7 @@ export interface WorkbenchLayout { * @param id - Unique id of the part. Use {@link MAIN_AREA} to add the main area. * @param relativeTo - Specifies the reference part to lay out the part. * @param options - Controls how to add the part to the layout. - * @property activate - Controls whether to activate the part. If not set, defaults to `false`. + * @param options.activate - Controls whether to activate the part. Default is `false`. * @return a copy of this layout with the part added. */ addPart(id: string | MAIN_AREA, relativeTo: ReferencePart, options?: {activate?: boolean}): WorkbenchLayout; @@ -41,13 +44,44 @@ export interface WorkbenchLayout { * * @param id - The id of the view to add. * @param options - Controls how to add the view to the layout. - * @property partId - References the part to which to add the view. - * @property position - Specifies the position where to insert the view. The position is zero-based. If not set, adds the view at the end. - * @property activateView - Controls whether to activate the view. If not set, defaults to `false`. - * @property activatePart - Controls whether to activate the part that contains the view. If not set, defaults to `false`. + * @param options.partId - References the part to which to add the view. + * @param options.position - Specifies the position where to insert the view. The position is zero-based. Default is `end`. + * @param options.activateView - Controls whether to activate the view. Default is `false`. + * @param options.activatePart - Controls whether to activate the part that contains the view. Default is `false`. * @return a copy of this layout with the view added. */ - addView(id: string, options: {partId: string; position?: number | 'start' | 'end' | 'before-active-view' | 'after-active-view'; activateView?: boolean; activatePart?: boolean}): WorkbenchLayout; + addView(id: string, options: {partId: string; position?: number | 'start' | 'end' | 'before-active-view' | 'after-active-view'; activateView?: boolean; activatePart?: boolean; cssClass?: string | string[]}): WorkbenchLayout; + + /** + * Navigates the specified view based on the provided array of commands and extras. + * + * A command can be a string or an object literal. A string represents a path segment, an object literal associates data with the preceding path segment. + * Multiple segments can be combined into a single command, separated by a forward slash. + * + * By default, navigation is absolute. Set `relativeTo` in extras for relative navigation. + * + * Usage: + * ``` + * layout.navigateView(viewId, ['path', 'to', 'view', {param1: 'value1', param2: 'value2'}]); + * layout.navigateView(viewId, ['path/to/view', {param1: 'value1', param2: 'value2'}]); + * ``` + * + * @param id - Identifies the view for navigation. + * @param commands - Instructs the router which route to navigate to. + * @param extras - Controls navigation. + * @param extras.hint - Sets a hint to control navigation, e.g., for use in a `CanMatch` guard to differentiate between routes with an identical path. + * For example, views of the initial layout or a perspective are usually navigated to the empty path route to avoid cluttering the URL, + * requiring a navigation hint to differentiate between the routes. See {@link canMatchWorkbenchView} for an example. + * Like the path, a hint affects view resolution. If set, the router will only navigate views with an equivalent hint, or if not set, views without a hint. + * @param extras.relativeTo - Specifies the route for relative navigation, supporting navigational symbols such as '/', './', or '../' in the commands. + * @param extras.state - Associates arbitrary state with a view navigation. + * Navigational state is stored in the browser's session history, supporting back/forward navigation, but is lost on page reload. + * Therefore, a view must be able to restore its state without relying on navigational state. + * Navigational state can be read from {@link WorkbenchView.state} or the browser's session history via `history.state`. + * @param extras.cssClass - Specifies CSS class(es) to add to the view, e.g., to locate the view in tests. + * @return a copy of this layout with the view navigated. + */ + navigateView(id: string, commands: Commands, extras?: {hint?: string; relativeTo?: ActivatedRoute; state?: ViewState; cssClass?: string | string[]}): WorkbenchLayout; /** * Removes given view from the layout. @@ -75,7 +109,7 @@ export interface WorkbenchLayout { * * @param id - The id of the view which to activate. * @param options - Controls view activation. - * @property activatePart - Controls whether to activate the part that contains the view. If not set, defaults to `false`. + * @param options.activatePart - Controls whether to activate the part that contains the view. Default is `false`. * @return a copy of this layout with the view activated. */ activateView(id: string, options?: {activatePart?: boolean}): WorkbenchLayout; diff --git a/projects/scion/workbench/src/lib/layout/workbench-layouts.util.ts b/projects/scion/workbench/src/lib/layout/workbench-layouts.util.ts index e340b2321..6f0340bd8 100644 --- a/projects/scion/workbench/src/lib/layout/workbench-layouts.util.ts +++ b/projects/scion/workbench/src/lib/layout/workbench-layouts.util.ts @@ -8,6 +8,9 @@ * SPDX-License-Identifier: EPL-2.0 */ import {MPart, MTreeNode} from './workbench-layout.model'; +import {MAIN_AREA} from './workbench-layout'; +import {ViewId} from '../view/workbench-view.model'; +import {VIEW_ID_PREFIX} from '../workbench.constants'; /** * Provides helper functions for operating on a workbench layout. @@ -28,4 +31,42 @@ export const WorkbenchLayouts = { } return parts; }, + + /** + * Tests if the given {@link MTreeNode} or {@link MPart} is visible. + * + * - A part is considered visible if it is the main area part or has at least one view. + * - A node is considered visible if it has at least one visible part in its child hierarchy. + */ + isGridElementVisible: (element: MTreeNode | MPart): boolean => { + if (element instanceof MPart) { + return element.id === MAIN_AREA || element.views.length > 0; + } + return WorkbenchLayouts.isGridElementVisible(element.child1) || WorkbenchLayouts.isGridElementVisible(element.child2); + }, + + /** + * Computes the next available view id. + */ + computeNextViewId: (viewIds: Iterable): ViewId => { + const ids = Array.from(viewIds) + .map(viewId => Number(viewId.substring(VIEW_ID_PREFIX.length))) + .reduce((set, id) => set.add(id), new Set()); + + for (let i = 1; i <= ids.size; i++) { + if (!ids.has(i)) { + return VIEW_ID_PREFIX.concat(`${i}`) as ViewId; + } + } + return VIEW_ID_PREFIX.concat(`${ids.size + 1}`) as ViewId; + }, + + /** + * Tests if the given id matches the format of a view identifier (e.g., `view.1`, `view.2`, etc.). + * + * @see ViewId + */ + isViewId: (viewId: string | undefined | null): viewId is ViewId => { + return viewId?.startsWith(VIEW_ID_PREFIX) ?? false; + }, } as const; diff --git a/projects/scion/workbench/src/lib/layout/workench-layout-serializer.service.ts b/projects/scion/workbench/src/lib/layout/workench-layout-serializer.service.ts index e42fe5ae2..6da926f60 100644 --- a/projects/scion/workbench/src/lib/layout/workench-layout-serializer.service.ts +++ b/projects/scion/workbench/src/lib/layout/workench-layout-serializer.service.ts @@ -8,10 +8,15 @@ * SPDX-License-Identifier: EPL-2.0 */ -import {Injectable} from '@angular/core'; +import {inject, Injectable} from '@angular/core'; import {MPart, MPartGrid, MTreeNode, ɵMPartGrid} from './workbench-layout.model'; -import {WorkbenchLayoutMigrator} from './migration/workbench-layout-migrator.service'; import {UUID} from '@scion/toolkit/uuid'; +import {ViewOutlets} from '../routing/routing.model'; +import {UrlSegment} from '@angular/router'; +import {WorkbenchLayoutMigrationV2} from './migration/workbench-layout-migration-v2.service'; +import {WorkbenchLayoutMigrationV3} from './migration/workbench-layout-migration-v3.service'; +import {WorkbenchMigrator} from '../migration/workbench-migrator'; +import {ViewId} from '../view/workbench-view.model'; /** * Serializes and deserializes a base64-encoded JSON into a {@link MPartGrid}. @@ -19,19 +24,20 @@ import {UUID} from '@scion/toolkit/uuid'; @Injectable({providedIn: 'root'}) export class WorkbenchLayoutSerializer { - constructor(private _workbenchLayoutMigrator: WorkbenchLayoutMigrator) { - } + private _workbenchLayoutMigrator = new WorkbenchMigrator() + .registerMigration(1, inject(WorkbenchLayoutMigrationV2)) + .registerMigration(2, inject(WorkbenchLayoutMigrationV3)); /** * Serializes the given grid into a URL-safe base64 string. * * @param grid - Specifies the grid to be serialized. * @param options - Controls the serialization. - * @property includeNodeId - Controls if to include the `nodeId`. By default, if not set, the `nodeId` is excluded from serialization. + * @param options.includeNodeId - Controls if to include the `nodeId`. By default, if not set, the `nodeId` is excluded from serialization. */ - public serialize(grid: MPartGrid, options?: {includeNodeId?: boolean}): string; - public serialize(grid: MPartGrid | undefined | null, options?: {includeNodeId?: boolean}): null | string; - public serialize(grid: MPartGrid | undefined | null, options?: {includeNodeId?: boolean}): string | null { + public serializeGrid(grid: MPartGrid, options?: {includeNodeId?: boolean}): string; + public serializeGrid(grid: MPartGrid | undefined | null, options?: {includeNodeId?: boolean}): null | string; + public serializeGrid(grid: MPartGrid | undefined | null, options?: {includeNodeId?: boolean}): string | null { if (grid === null || grid === undefined) { return null; } @@ -48,13 +54,12 @@ export class WorkbenchLayoutSerializer { } /** - * Deserializes the given base64-serialized grid. + * Deserializes the given base64-serialized grid, applying necessary migrations if the serialized grid is outdated. */ - public deserialize(serializedGrid: string): ɵMPartGrid { - const [jsonGrid, jsonGridVersion] = window.atob(serializedGrid).split(VERSION_SEPARATOR, 2); + public deserializeGrid(serialized: string): ɵMPartGrid { + const [jsonGrid, jsonGridVersion] = window.atob(serialized).split(VERSION_SEPARATOR, 2); const gridVersion = Number.isNaN(Number(jsonGridVersion)) ? 1 : Number(jsonGridVersion); - const isGridOutdated = gridVersion < WORKBENCH_LAYOUT_VERSION; - const migratedJsonGrid = isGridOutdated ? this._workbenchLayoutMigrator.migrate(gridVersion, jsonGrid) : jsonGrid; + const migratedJsonGrid = this._workbenchLayoutMigrator.migrate(jsonGrid, {from: gridVersion, to: WORKBENCH_LAYOUT_VERSION}); // Parse the JSON. const grid: MPartGrid = JSON.parse(migratedJsonGrid, (key, value) => { @@ -77,7 +82,29 @@ export class WorkbenchLayoutSerializer { } })(grid.root, undefined); - return {...grid, migrated: isGridOutdated}; + return (gridVersion < WORKBENCH_LAYOUT_VERSION) ? {...grid, migrated: true} : grid; + } + + /** + * Serializes the given outlets. + */ + public serializeViewOutlets(viewOutlets: ViewOutlets): string { + return JSON.stringify(Object.fromEntries(Object.entries(viewOutlets) + .map(([viewId, segments]: [string, UrlSegment[]]): [string, MUrlSegment[]] => { + return [viewId, segments.map(segment => ({path: segment.path, parameters: segment.parameters}))]; + }))); + } + + /** + * Deserializes the given outlets. + */ + public deserializeViewOutlets(serialized: string): ViewOutlets { + const viewOutlets: {[viewId: ViewId]: MUrlSegment[]} = JSON.parse(serialized); + + return Object.fromEntries(Object.entries(viewOutlets) + .map(([viewId, segments]: [string, MUrlSegment[]]): [string, UrlSegment[]] => { + return [viewId, segments.map(segment => new UrlSegment(segment.path, segment.parameters))]; + })); } } @@ -86,9 +113,10 @@ export class WorkbenchLayoutSerializer { * * Increment this version and write a migrator when introducting a breaking layout model change. * - * @see WorkbenchLayoutMigrator + * @see WorkbenchMigrator */ -const WORKBENCH_LAYOUT_VERSION = 2; +export const WORKBENCH_LAYOUT_VERSION = 3; + /** * Fields not serialized into JSON representation. */ @@ -99,3 +127,13 @@ const TRANSIENT_FIELDS = new Set().add('parent').add('migrated'); * Format: // */ const VERSION_SEPARATOR = '//'; + +/** + * Represents a segment in the URL. + * + * The M-prefix indicates this object is a model object that is serialized and stored, requiring migration on breaking change. + */ +interface MUrlSegment { + path: string; + parameters: {[name: string]: string}; +} diff --git "a/projects/scion/workbench/src/lib/layout/\311\265workbench-layout.factory.ts" "b/projects/scion/workbench/src/lib/layout/\311\265workbench-layout.factory.ts" index dd050630c..d3338561d 100644 --- "a/projects/scion/workbench/src/lib/layout/\311\265workbench-layout.factory.ts" +++ "b/projects/scion/workbench/src/lib/layout/\311\265workbench-layout.factory.ts" @@ -13,6 +13,7 @@ import {MPart, MPartGrid} from './workbench-layout.model'; import {WorkbenchLayoutFactory} from './workbench-layout.factory'; import {EnvironmentInjector, Injectable, Injector, runInInjectionContext} from '@angular/core'; import {MAIN_AREA} from './workbench-layout'; +import {ViewOutlets, ViewStates} from '../routing/routing.model'; /** * @inheritDoc @@ -38,12 +39,15 @@ export class ɵWorkbenchLayoutFactory implements WorkbenchLayoutFactory { * - If not specifying the workbench grid, creates a workbench grid with a main area. * - If not specifying the main area grid, but the workbench grid has a main area part, creates a main area grid with an initial part. * To control the identity of the initial part, pass an injector and set the DI token {@link MAIN_AREA_INITIAL_PART_ID}. + * - Grids and outlets can be passed in serialized or deserialized form. */ - public create(options?: {workbenchGrid?: string | MPartGrid | null; mainAreaGrid?: string | MPartGrid | null; injector?: Injector; maximized?: boolean}): ɵWorkbenchLayout { + public create(options?: {workbenchGrid?: string | MPartGrid | null; mainAreaGrid?: string | MPartGrid | null; viewOutlets?: ViewOutlets | string; viewStates?: ViewStates; injector?: Injector; maximized?: boolean}): ɵWorkbenchLayout { return runInInjectionContext(options?.injector ?? this._environmentInjector, () => new ɵWorkbenchLayout({ workbenchGrid: options?.workbenchGrid, mainAreaGrid: options?.mainAreaGrid, maximized: options?.maximized, + viewOutlets: options?.viewOutlets, + viewStates: options?.viewStates, })); } } diff --git "a/projects/scion/workbench/src/lib/layout/\311\265workbench-layout.ts" "b/projects/scion/workbench/src/lib/layout/\311\265workbench-layout.ts" index 3a128a314..de7214f53 100644 --- "a/projects/scion/workbench/src/lib/layout/\311\265workbench-layout.ts" +++ "b/projects/scion/workbench/src/lib/layout/\311\265workbench-layout.ts" @@ -8,14 +8,22 @@ * SPDX-License-Identifier: EPL-2.0 */ import {MPart, MPartGrid, MTreeNode, MView, ɵMPartGrid} from './workbench-layout.model'; -import {VIEW_ID_PREFIX} from '../workbench.constants'; import {assertType} from '../common/asserts.util'; import {UUID} from '@scion/toolkit/uuid'; import {MAIN_AREA, ReferencePart, WorkbenchLayout} from './workbench-layout'; import {WorkbenchLayoutSerializer} from './workench-layout-serializer.service'; import {WorkbenchViewRegistry} from '../view/workbench-view.registry'; import {WorkbenchPartRegistry} from '../part/workbench-part.registry'; -import {inject, Injectable, InjectionToken, Injector, runInInjectionContext} from '@angular/core'; +import {inject, Injectable, InjectionToken, Injector, Predicate, runInInjectionContext} from '@angular/core'; +import {RouterUtils} from '../routing/router.util'; +import {Commands, ViewOutlets, ViewState, ViewStates} from '../routing/routing.model'; +import {ActivatedRoute, UrlSegment} from '@angular/router'; +import {ViewId} from '../view/workbench-view.model'; +import {Arrays} from '@scion/toolkit/util'; +import {UrlSegmentMatcher} from '../routing/url-segment-matcher'; +import {Objects} from '../common/objects.util'; +import {WorkbenchLayouts} from './workbench-layouts.util'; +import {Logger} from '../logging'; /** * @inheritDoc @@ -33,6 +41,8 @@ import {inject, Injectable, InjectionToken, Injector, runInInjectionContext} fro export class ɵWorkbenchLayout implements WorkbenchLayout { private readonly _grids: Grids; + private readonly _viewOutlets: Map; + private readonly _viewStates: Map; private readonly _gridNames: Array; private readonly _partActivationInstantProvider = inject(PartActivationInstantProvider); private readonly _viewActivationInstantProvider = inject(ViewActivationInstantProvider); @@ -42,39 +52,86 @@ export class ɵWorkbenchLayout implements WorkbenchLayout { private _maximized: boolean; /** @internal **/ - constructor(config: {workbenchGrid?: string | MPartGrid | null; mainAreaGrid?: string | MPartGrid | null; maximized?: boolean}) { + constructor(config: {workbenchGrid?: string | MPartGrid | null; mainAreaGrid?: string | MPartGrid | null; viewOutlets?: string | ViewOutlets | null; viewStates?: ViewStates | null; maximized?: boolean}) { this._grids = { - workbench: coerceMPartGrid(config.workbenchGrid ?? createDefaultWorkbenchGrid()), + workbench: coerceMPartGrid(config.workbenchGrid, {default: createDefaultWorkbenchGrid}), }; if (this.hasPart(MAIN_AREA, {grid: 'workbench'})) { - this._grids.mainArea = coerceMPartGrid(config.mainAreaGrid ?? createInitialMainAreaGrid()); + this._grids.mainArea = coerceMPartGrid(config.mainAreaGrid, {default: createInitialMainAreaGrid}); } - this._gridNames = Object.keys(this._grids) as Array; + this._gridNames = Objects.keys(this._grids); this._maximized = config.maximized ?? false; + this._viewOutlets = new Map(Objects.entries(coerceViewOutlets(config.viewOutlets))); + this._viewStates = new Map(Objects.entries(config.viewStates ?? {})); this.parts().forEach(part => assertType(part, {toBeOneOf: [MTreeNode, MPart]})); } /** - * Reference to the grid of the workbench. + * Reference to the main workbench grid. */ public get workbenchGrid(): Readonly<ɵMPartGrid> { return this._grids.workbench; } /** - * Reference to the grid of the main area, if any. + * Reference to the main area grid, if any. * - * The main area grid is a sub-grid included by the main area part, if any. It defines the arrangement of parts in the main area. + * The main area grid is a sub-grid included by the {@link MAIN_AREA} part. It defines the arrangement of parts in the main area. */ public get mainAreaGrid(): Readonly<ɵMPartGrid> | null { return this._grids.mainArea ?? null; } /** - * Tests if given part is contained in specified grid. + * Tests if given part is contained in the specified grid. + */ + public hasPart(id: string, options?: {grid?: keyof Grids}): boolean { + return this.part({partId: id, grid: options?.grid}, {orElse: null}) !== null; + } + + /** + * Tests if given view is contained in the specified grid. + */ + public hasView(id: string, options?: {grid?: keyof Grids}): boolean { + return this.views({grid: options?.grid, id: id}).length > 0; + } + + /** + * Finds the URL of views based on the specified filter. + * + * @param findBy - Defines the search scope. + * @param findBy.grid - Searches for views contained in the specified grid. + * @return outlets of views matching the filter criteria. + */ + public viewOutlets(findBy?: {grid?: keyof Grids}): ViewOutlets { + const viewOutletEntries = this.views({grid: findBy?.grid}).map(view => [view.id, this._viewOutlets.get(view.id) ?? []]); + return Object.fromEntries(viewOutletEntries); + } + + /** + * Finds the navigational state of views based on the specified filter. + * + * @param findBy - Defines the search scope. + * @param findBy.grid - Searches for views contained in the specified grid. + * @return view state matching the filter criteria. + */ + public viewStates(findBy?: {grid?: keyof Grids}): ViewStates { + const viewStateEntries = this.views({grid: findBy?.grid}).map(view => [view.id, this._viewStates.get(view.id) ?? {}]); + return Object.fromEntries(viewStateEntries); + } + + /** + * Finds the navigational state of specified view. */ - public hasPart(partId: string, options?: {grid?: keyof Grids}): boolean { - return this.part({by: {partId}, grid: options?.grid}, {orElse: null}) !== null; + public viewState(findBy: {viewId: ViewId}): ViewState { + return this._viewStates.get(findBy.viewId) ?? {}; + } + + /** + * Finds the URL of specified view. + */ + public urlSegments(findBy: {viewId: ViewId}): UrlSegment[] { + return this._viewOutlets.get(findBy.viewId) ?? []; } /** @@ -83,7 +140,9 @@ export class ɵWorkbenchLayout implements WorkbenchLayout { * @return a copy of this layout with the maximization changed. */ public toggleMaximized(): ɵWorkbenchLayout { - return this.workingCopy().__toggleMaximized(); + const workingCopy = this.workingCopy(); + workingCopy.__toggleMaximized(); + return workingCopy; } /** @@ -94,49 +153,48 @@ export class ɵWorkbenchLayout implements WorkbenchLayout { } /** - * Returns parts contained in the specified grid, or parts in any grid if not specifying a search grid. + * Finds parts based on the specified filter. * - * @param find - Search constraints - * @property grid - Limits the search scope. If not specified, all grids are searched. + * @param findBy - Defines the search scope. + * @param findBy.grid - Searches for parts contained in the specified grid. * @return parts matching the filter criteria. */ - public parts(find?: {grid?: keyof Grids}): readonly MPart[] { - return this.findTreeElements((element: MTreeNode | MPart): element is MPart => element instanceof MPart, {grid: find?.grid}); + public parts(findBy?: {grid?: keyof Grids}): readonly MPart[] { + return this.findTreeElements((element: MTreeNode | MPart): element is MPart => element instanceof MPart, {grid: findBy?.grid}); } /** - * Returns the part matching the given criteria. If not found, by default, throws an error unless setting the `orElseNull` option. + * Finds a part based on the specified filter. If not found, by default, throws an error unless setting the `orElseNull` option. * - * @param find - Search constraints - * @property by - * @property partId - If specified, searches the part of given identity. - * @property viewId - If specified, searches the part that contains given view. - * @property grid - Limits the search scope. If not specified, all grids are searched. - * @param options - Search options - * @property orElse - If set, returns `null` instead of throwing an error if no part is found. + * @param findBy - Defines the search scope. + * @param findBy.partId - Searches for a part with the specified id. + * @param findBy.viewId - Searches for a part that contains the specified view. + * @param findBy.grid - Searches for a part contained in the specified grid. + * @param options - Controls the search. + * @param options.orElse - Controls to return `null` instead of throwing an error if no part is found. * @return part matching the filter criteria. */ - public part(find: {by: {partId?: string; viewId?: string}; grid?: keyof Grids}): MPart; - public part(find: {by: {partId?: string; viewId?: string}; grid?: keyof Grids}, options: {orElse: null}): MPart | null; - public part(find: {by: {partId?: string; viewId?: string}; grid?: keyof Grids}, options?: {orElse: null}): MPart | null { - if (!find.by.partId && !find.by.viewId) { - throw Error('[IllegalArgumentError] Missing required argument. Specify either "partId" or "viewId".'); + public part(findBy: {partId?: string; viewId?: string; grid?: keyof Grids}): MPart; + public part(findBy: {partId?: string; viewId?: string; grid?: keyof Grids}, options: {orElse: null}): MPart | null; + public part(findBy: {partId?: string; viewId?: string; grid?: keyof Grids}, options?: {orElse: null}): MPart | null { + if (!findBy.partId && !findBy.viewId) { + throw Error(`[PartFindError] Missing required argument. Specify either 'partId' or 'viewId'.`); } const part = this.findTreeElements((element: MTreeNode | MPart): element is MPart => { if (!(element instanceof MPart)) { return false; } - if (find.by.partId && element.id !== find.by.partId) { + if (findBy.partId !== undefined && element.id !== findBy.partId) { return false; } - if (find.by.viewId && !element.views.some(view => view.id === find.by.viewId)) { + if (findBy.viewId !== undefined && !element.views.some(matchViewById(findBy.viewId!))) { return false; } return true; - }, {findFirst: true, grid: find.grid})[0]; + }, {findFirst: true, grid: findBy.grid})[0]; if (!part && !options) { - throw Error(`[NullPartError] No part found matching "${JSON.stringify(find)}".`); + throw Error(`[NullPartError] No matching part found: [${stringifyFilter(findBy)}]`); } return part ?? null; } @@ -146,19 +204,23 @@ export class ɵWorkbenchLayout implements WorkbenchLayout { * * @param id - @inheritDoc * @param relativeTo - @inheritDoc - * @param options - Controls how to add the part to the layout. - * @property structural - Specifies whether this is a structural part. A structural part is not removed - * from the layout when removing its last view. If not set, defaults to `true`. + * @param options - @inheritDoc + * @param options.activate - @inheritDoc + * @param options.structural - Specifies if this is a structural part. A structural part will not be removed when removing its last view. Default is `true`. */ public addPart(id: string | MAIN_AREA, relativeTo: ReferenceElement, options?: {activate?: boolean; structural?: boolean}): ɵWorkbenchLayout { - return this.workingCopy().__addPart(id, relativeTo, options); + const workingCopy = this.workingCopy(); + workingCopy.__addPart(id, relativeTo, options); + return workingCopy; } /** * @inheritDoc */ public removePart(id: string): ɵWorkbenchLayout { - return this.workingCopy().__removePart(id); + const workingCopy = this.workingCopy(); + workingCopy.__removePart(id); + return workingCopy; } /** @@ -171,39 +233,101 @@ export class ɵWorkbenchLayout implements WorkbenchLayout { if (!grid) { return null; } - return this.part({by: {partId: grid.activePartId}, grid: find.grid}); + return this.part({partId: grid.activePartId, grid: find.grid}); } /** * @inheritDoc */ public activatePart(id: string): ɵWorkbenchLayout { - return this.workingCopy().__activatePart(id); + const workingCopy = this.workingCopy(); + workingCopy.__activatePart(id); + return workingCopy; } /** - * Returns views contained in the specified grid, or views in any grid if not specifying any. + * Finds a view based on the specified filter. If not found, by default, throws an error unless setting the `orElseNull` option. * - * @param find - Search constraints - * @property grid - Limits the search scope. If not specified, all grids are searched. - * @return views maching the filter criteria. + * @param findBy - Defines the search scope. + * @param findBy.viewId - Searches for a view with the specified id. + * @param options - Controls the search. + * @param options.orElse - Controls to return `null` instead of throwing an error if no view is found. + * @return view matching the filter criteria. */ - public views(find?: {grid?: keyof Grids}): readonly MView[] { - return this.parts(find).reduce((views, part) => views.concat(part.views), new Array()); + public view(findBy: {viewId: ViewId}): MView; + public view(findBy: {viewId: ViewId}, options: {orElse: null}): MView | null; + public view(findBy: {viewId: ViewId}, options?: {orElse: null}): MView | null { + const view = this.views({id: findBy.viewId}).at(0); + if (!view && !options) { + throw Error(`[NullViewError] No view found with id '${findBy.viewId}'.`); + } + return view ?? null; + } + + /** + * Finds views based on the specified filter. + * + * @param findBy - Defines the search scope. + * @param findBy.id - Searches for views with the specified id. + * @param findBy.segments - Searches for views navigated to the specified URL. + * @param findBy.navigationHint - Searches for views navigated with given hint. Passing `null` searches for views navigated without a hint. + * @param findBy.grid - Searches for views contained in the specified grid. + * @param options - Controls the search. + * @param options.orElse - Controls to error if no view is found. + * @return views matching the filter criteria. + */ + public views(findBy?: {id?: string; segments?: UrlSegmentMatcher; navigationHint?: string | null; grid?: keyof Grids}, options?: {orElse: 'throwError'}): readonly MView[] { + const views = this.parts({grid: findBy?.grid}) + .flatMap(part => part.views) + .filter(view => { + if (findBy?.id !== undefined && !matchViewById(findBy.id)(view)) { + return false; + } + if (findBy?.segments && !findBy.segments.matches(this.urlSegments({viewId: view.id}))) { + return false; + } + if (findBy?.navigationHint !== undefined && findBy.navigationHint !== (view.navigation?.hint ?? null)) { + return false; + } + return true; + }); + + if (findBy && !views.length && options) { + throw Error(`[NullViewError] No matching view found: [${stringifyFilter(findBy)}]`); + } + return views; } /** * @inheritDoc */ - public addView(id: string, options: {partId: string; position?: number | 'start' | 'end' | 'before-active-view' | 'after-active-view'; activateView?: boolean; activatePart?: boolean}): ɵWorkbenchLayout { - return this.workingCopy().__addView(id, options); + public addView(id: string, options: {partId: string; position?: number | 'start' | 'end' | 'before-active-view' | 'after-active-view'; activateView?: boolean; activatePart?: boolean; cssClass?: string | string[]}): ɵWorkbenchLayout { + const workingCopy = this.workingCopy(); + if (WorkbenchLayouts.isViewId(id)) { + workingCopy.__addView({id}, options); + } + else { + workingCopy.__addView({id: this.computeNextViewId(), alternativeId: id}, options); + } + return workingCopy; + } + + /** + * @inheritDoc + */ + public navigateView(id: string, commands: Commands, extras?: {hint?: string; relativeTo?: ActivatedRoute | null; state?: ViewState; cssClass?: string | string[]}): ɵWorkbenchLayout { + const workingCopy = this.workingCopy(); + workingCopy.views({id}, {orElse: 'throwError'}).forEach(view => workingCopy.__navigateView(view, commands, extras)); + return workingCopy; } /** * @inheritDoc */ public removeView(id: string, options?: {grid?: keyof Grids}): ɵWorkbenchLayout { - return this.workingCopy().__removeView(id, options); + const workingCopy = this.workingCopy(); + workingCopy.views({id}, {orElse: 'throwError'}).forEach(view => workingCopy.__removeView(view, {grid: options?.grid})); + return workingCopy; } /** @@ -211,103 +335,100 @@ export class ɵWorkbenchLayout implements WorkbenchLayout { * * @param id - The id of the view to be moved. * @param targetPartId - The id of the part to which to move the view. - * @param options - Controls how to move the view in the layout. - * @property position - Specifies the position where to move the view in the target part. The position is zero-based. If not set and moving the view to a different part, adds it at the end. - * @property activateView - Controls whether to activate the view. If not set, defaults to `false`. - * @property activatePart - Controls whether to activate the target part. If not set, defaults to `false`. - * + * @param options - Controls moving of the view. + * @param options.position - Specifies the position where to move the view in the target part. The position is zero-based. Default is `end` when moving the view to a different part. + * @param options.activateView - Controls if to activate the view. Default is `false`. + * @param options.activatePart - Controls if to activate the target part. Default is `false`. * @return a copy of this layout with the view moved. */ public moveView(id: string, targetPartId: string, options?: {position?: number | 'start' | 'end' | 'before-active-view' | 'after-active-view'; activateView?: boolean; activatePart?: boolean}): ɵWorkbenchLayout { - return this.workingCopy().__moveView(id, targetPartId, options); + const workingCopy = this.workingCopy(); + workingCopy.views({id}, {orElse: 'throwError'}).forEach(view => workingCopy.__moveView(view, targetPartId, options)); + return workingCopy; } /** * @inheritDoc */ public activateView(id: string, options?: {activatePart?: boolean}): ɵWorkbenchLayout { - return this.workingCopy().__activateView(id, options); + const workingCopy = this.workingCopy(); + workingCopy.views({id}, {orElse: 'throwError'}).forEach(view => workingCopy.__activateView(view, options)); + return workingCopy; } /** * Activates the preceding view if it exists, or the subsequent view otherwise. * - * @param id - The id of the view for which to activate its adjacent view. + * @param id - The id of the view to activate its adjacent view. * @param options - Controls view activation. - * @property activatePart - Controls whether to activate the part. If not set, defaults to `false`. + * @param options.activatePart - Controls if to activate the part. Default is `false`. * @return a copy of this layout with the adjacent view activated. */ public activateAdjacentView(id: string, options?: {activatePart?: boolean}): ɵWorkbenchLayout { - return this.workingCopy().__activateAdjacentView(id, options); + const workingCopy = this.workingCopy(); + workingCopy.views({id}, {orElse: 'throwError'}).forEach(view => workingCopy.__activateAdjacentView(view, options)); + return workingCopy; } /** - * Gives a view a new identity. + * Renames a view. * - * @param id - The id of the view which to give a new identity. + * @param id - The id of the view which to rename. * @param newViewId - The new identity of the view. - * @param options - Controls how to locate the view. - * @property grid - Grid to constrain where to find the view for rename. * @return a copy of this layout with the view renamed. */ - public renameView(id: string, newViewId: string, options?: {grid?: keyof Grids}): ɵWorkbenchLayout { - return this.workingCopy().__renameView(id, newViewId, options); + public renameView(id: ViewId, newViewId: ViewId): ɵWorkbenchLayout { + const workingCopy = this.workingCopy(); + workingCopy.__renameView(workingCopy.view({viewId: id}), newViewId); + return workingCopy; } /** - * Serializes this layout into a URL-safe base64 string. + * Sets the split ratio for the two children of a {@link MTreeNode}. + * + * @param nodeId - The id of the node to set the split ratio for. + * @param ratio - The proportional size between the two children, expressed as closed interval [0,1]. + * Example: To give 1/3 of the space to the first child, set the ratio to `0.3`. + * @return a copy of this layout with the split ratio set. */ - public serialize(): {workbenchGrid: string; mainAreaGrid: string | null} { - const isMainAreaEmpty = (this.mainAreaGrid?.root instanceof MPart && this.mainAreaGrid.root.views.length === 0) ?? true; - return { - workbenchGrid: this._serializer.serialize(this.workbenchGrid), - mainAreaGrid: isMainAreaEmpty ? null : this._serializer.serialize(this._grids.mainArea), - }; + public setSplitRatio(nodeId: string, ratio: number): ɵWorkbenchLayout { + const workingCopy = this.workingCopy(); + workingCopy.__setSplitRatio(nodeId, ratio); + return workingCopy; } /** - * Computes the next available view id to be the target of a primary route. - * - * @see VIEW_ID_PREFIX + * Serializes this layout into a URL-safe base64 string. */ - public computeNextViewId(): string { - const ids = this.views() - .filter(view => view.id.startsWith(VIEW_ID_PREFIX)) - .map(view => Number(view.id.substring(VIEW_ID_PREFIX.length))) - .reduce((set, viewId) => set.add(viewId), new Set()); - - for (let i = 1; i <= ids.size; i++) { - if (!ids.has(i)) { - return VIEW_ID_PREFIX.concat(`${i}`); - } - } - return VIEW_ID_PREFIX.concat(`${ids.size + 1}`); + public serialize(): SerializedWorkbenchLayout { + const isMainAreaEmpty = (this.mainAreaGrid?.root instanceof MPart && this.mainAreaGrid.root.views.length === 0) ?? true; + return { + workbenchGrid: this._serializer.serializeGrid(this.workbenchGrid), + mainAreaGrid: isMainAreaEmpty ? null : this._serializer.serializeGrid(this._grids.mainArea), + workbenchViewOutlets: this._serializer.serializeViewOutlets(this.viewOutlets({grid: 'workbench'})), + mainAreaViewOutlets: this._serializer.serializeViewOutlets(this.viewOutlets({grid: 'mainArea'})), + }; } /** - * Sets the split ratio for the two children of a {@link MTreeNode}. - * - * @param nodeId - The id of the node to set the split ratio for. - * @param ratio - The proportional size between the two children, expressed as closed interval [0,1]. - * Example: To give 1/3 of the space to the first child, set the ratio to `0.3`. - * @return a copy of this layout with the split ratio set. + * Computes the next available view id. */ - public setSplitRatio(nodeId: string, ratio: number): ɵWorkbenchLayout { - return this.workingCopy().__setSplitRatio(nodeId, ratio); + public computeNextViewId(): ViewId { + return WorkbenchLayouts.computeNextViewId(this.views().map(view => view.id)); } /** * Note: This method name begins with underscores, indicating that it does not operate on a working copy, but modifies this layout instead. */ - private __addPart(id: string, relativeTo: ReferenceElement, options?: {activate?: boolean; structural?: boolean}): this { + private __addPart(id: string, relativeTo: ReferenceElement, options?: {activate?: boolean; structural?: boolean}): void { if (this.hasPart(id)) { - throw Error(`[IllegalArgumentError] Part id must be unique. The layout already contains a part with the id '${id}'.`); + throw Error(`[PartAddError] Part id must be unique. The layout already contains a part with the id '${id}'.`); } const newPart = new MPart({id, structural: options?.structural ?? true}); // Find the reference element, if specified, or use the layout root as reference otherwise. - const referenceElement = relativeTo.relativeTo ? this.element({by: {id: relativeTo.relativeTo}}) : this.workbenchGrid.root; + const referenceElement = relativeTo.relativeTo ? this.findTreeElement({id: relativeTo.relativeTo}) : this.workbenchGrid.root; const addBefore = relativeTo.align === 'left' || relativeTo.align === 'top'; const ratio = relativeTo.ratio ?? .5; @@ -323,7 +444,7 @@ export class ɵWorkbenchLayout implements WorkbenchLayout { // Add the tree node to the layout. if (!referenceElement.parent) { - this.grid({by: {element: referenceElement}}).root = newTreeNode; // top-level node + this.grid({element: referenceElement}).root = newTreeNode; // top-level node } else if (referenceElement.parent.child1 === referenceElement) { referenceElement.parent.child1 = newTreeNode; @@ -338,22 +459,20 @@ export class ɵWorkbenchLayout implements WorkbenchLayout { if (options?.activate) { this.__activatePart(newPart.id); } - - return this; } /** * Note: This method name begins with underscores, indicating that it does not operate on a working copy, but modifies this layout instead. */ - private __removePart(id: string): this { - const part = this.part({by: {partId: id}}); - const grid = this.grid({by: {element: part}}); + private __removePart(id: string): void { + const part = this.part({partId: id}); + const grid = this.grid({element: part}); const gridName = this._gridNames.find(gridName => this._grids[gridName] === grid); // The last part is never removed. const parts = this.parts({grid: gridName}); if (parts.length === 1) { - return this; + return; } // Remove the part. @@ -369,6 +488,12 @@ export class ɵWorkbenchLayout implements WorkbenchLayout { part.parent!.parent.child2 = siblingElement; } + // Remove outlets and states of views contained in the part. + part.views.forEach(view => { + this._viewOutlets.delete(view.id); + this._viewStates.delete(view.id); + }); + // If the removed part was the active part, make the last used part the active part. if (grid.activePartId === id) { const activePart = parts @@ -380,49 +505,77 @@ export class ɵWorkbenchLayout implements WorkbenchLayout { })[0]; grid.activePartId = activePart!.id; } - - return this; } /** * Note: This method name begins with underscores, indicating that it does not operate on a working copy, but modifies this layout instead. */ - private __addView(id: string, options: {partId: string; position?: number | 'start' | 'end' | 'before-active-view' | 'after-active-view'; activateView?: boolean; activatePart?: boolean}): this { - if (this.views().find(view => view.id === id)) { - throw Error(`[IllegalArgumentError] View id must be unique. The layout already contains a view with the id '${id}'.`); + private __addView(view: MView, options: {partId: string; position?: number | 'start' | 'end' | 'before-active-view' | 'after-active-view'; activateView?: boolean; activatePart?: boolean; cssClass?: string | string[]}): void { + if (this.hasView(view.id)) { + throw Error(`[ViewAddError] View id must be unique. The layout already contains a view with the id '${view.id}'.`); } - const part = this.part({by: {partId: options.partId}}); + const part = this.part({partId: options.partId}); const position = coercePosition(options.position ?? 'end', part); - part.views.splice(position, 0, {id}); + part.views.splice(position, 0, view); - // Activate view and part. if (options.activateView) { - this.__activateView(id); + this.__activateView(view); } if (options.activatePart) { this.__activatePart(options.partId); } - return this; + if (options.cssClass) { + view.cssClass = Arrays.coerce(options.cssClass); + } + } + + /** + * Note: This method name begins with underscores, indicating that it does not operate on a working copy, but modifies this layout instead. + */ + private __navigateView(view: MView, commands: Commands, extras?: {hint?: string; relativeTo?: ActivatedRoute | null; state?: ViewState; cssClass?: string | string[]}): void { + if (!commands.length && !extras?.hint && !extras?.relativeTo) { + throw Error('[NavigateError] Commands, relativeTo or hint must be set.'); + } + + const urlSegments = runInInjectionContext(this._injector, () => RouterUtils.commandsToSegments(commands, {relativeTo: extras?.relativeTo})); + if (urlSegments.length) { + this._viewOutlets.set(view.id, urlSegments); + } + else { + this._viewOutlets.delete(view.id); + } + + if (extras?.state && Objects.keys(extras.state).length) { + this._viewStates.set(view.id, extras.state); + } + else { + this._viewStates.delete(view.id); + } + + view.navigation = Objects.withoutUndefinedEntries({ + hint: extras?.hint, + cssClass: extras?.cssClass ? Arrays.coerce(extras.cssClass) : undefined, + }); } /** * Note: This method name begins with underscores, indicating that it does not operate on a working copy, but modifies this layout instead. */ - private __moveView(id: string, targetPartId: string, options?: {position?: number | 'start' | 'end' | 'before-active-view' | 'after-active-view'; activateView?: boolean; activatePart?: boolean}): this { - const sourcePart = this.part({by: {viewId: id}}); - const targetPart = this.part({by: {partId: targetPartId}}); + private __moveView(view: MView, targetPartId: string, options?: {position?: number | 'start' | 'end' | 'before-active-view' | 'after-active-view'; activateView?: boolean; activatePart?: boolean}): void { + const sourcePart = this.part({viewId: view.id}); + const targetPart = this.part({partId: targetPartId}); // Move the view. if (sourcePart !== targetPart) { - this.__removeView(id); - this.__addView(id, {partId: targetPartId, position: options?.position}); + this.__removeView(view, {removeOutlet: false, removeState: false}); + this.__addView(view, {partId: targetPartId, position: options?.position}); } else if (options?.position !== undefined) { const position = coercePosition(options.position, targetPart); const referenceView: MView | undefined = sourcePart.views.at(position); - sourcePart.views.splice(sourcePart.views.findIndex(view => view.id === id), 1); - sourcePart.views.splice(referenceView ? sourcePart.views.indexOf(referenceView) : sourcePart.views.length, 0, {id}); + sourcePart.views.splice(sourcePart.views.indexOf(view), 1); + sourcePart.views.splice(referenceView ? sourcePart.views.indexOf(referenceView) : sourcePart.views.length, 0, view); } // Activate view and part. @@ -430,137 +583,143 @@ export class ɵWorkbenchLayout implements WorkbenchLayout { this.__activatePart(targetPartId); } if (options?.activateView) { - this.__activateView(id); + this.__activateView(view); } - - return this; } /** * Note: This method name begins with underscores, indicating that it does not operate on a working copy, but modifies this layout instead. */ - private __removeView(id: string, options?: {grid?: keyof Grids}): this { - const part = this.part({by: {viewId: id}, grid: options?.grid}); + private __removeView(view: MView, options?: {grid?: keyof Grids; removeOutlet?: false; removeState?: false}): void { + const part = this.part({viewId: view.id, grid: options?.grid}); + + // Remove view. + part.views.splice(part.views.indexOf(view), 1); - // Remove the view. - const viewIndex = part.views.findIndex(view => view.id === id); - if (viewIndex === -1) { - throw Error(`[IllegalArgumentError] View not found in the part [part=${part.id}, view=${id}]`); + // Remove outlet. + if (options?.removeOutlet ?? true) { + this._viewOutlets.delete(view.id); } - part.views.splice(viewIndex, 1); + // Remove state. + if (options?.removeState ?? true) { + this._viewStates.delete(view.id); + } // Activate the last used view if this view was active. - if (part.activeViewId === id) { + if (part.activeViewId === view.id) { part.activeViewId = part.views .map(view => view.id) .sort((viewId1, viewId2) => { const activationInstantView1 = this._viewActivationInstantProvider.getActivationInstant(viewId1); const activationInstantView2 = this._viewActivationInstantProvider.getActivationInstant(viewId2); return activationInstantView2 - activationInstantView1; - })[0]; + }).at(0); } // Remove the part if this is the last view of the part and not a structural part. if (part.views.length === 0 && !part.structural) { this.__removePart(part.id); } - - return this; } /** * Note: This method name begins with underscores, indicating that it does not operate on a working copy, but modifies this layout instead. */ - private __activateView(id: string, options?: {activatePart?: boolean}): this { + private __activateView(view: MView, options?: {activatePart?: boolean}): void { // Activate the view. - const part = this.part({by: {viewId: id}}); - part.activeViewId = id; + const part = this.part({viewId: view.id}); + part.activeViewId = view.id; // Activate the part. if (options?.activatePart) { this.__activatePart(part.id); } - return this; } /** * Note: This method name begins with underscores, indicating that it does not operate on a working copy, but modifies this layout instead. */ - private __activateAdjacentView(id: string, options?: {activatePart?: boolean}): this { - const part = this.part({by: {viewId: id}}); - const viewIndex = part.views.findIndex(view => view.id === id); + private __activateAdjacentView(view: MView, options?: {activatePart?: boolean}): void { + const part = this.part({viewId: view.id}); + const viewIndex = part.views.indexOf(view); part.activeViewId = (part.views[viewIndex - 1] || part.views[viewIndex + 1])?.id; // is `undefined` if it is the last view of the part // Activate the part. if (options?.activatePart) { this.__activatePart(part.id); } - return this; } /** * Note: This method name begins with underscores, indicating that it does not operate on a working copy, but modifies this layout instead. */ - private __setSplitRatio(nodeId: string, ratio: number): this { + private __setSplitRatio(nodeId: string, ratio: number): void { if (ratio < 0 || ratio > 1) { - throw Error(`[IllegalArgumentError] Ratio for node '${nodeId}' must be in the closed interval [0,1], but was '${ratio}'.`); + throw Error(`[LayoutModifyError] Ratio for node '${nodeId}' must be in the closed interval [0,1], but was '${ratio}'.`); } - this.node({by: {nodeId}}).ratio = ratio; - return this; + this.findTreeElement({id: nodeId}).ratio = ratio; } /** * Note: This method name begins with underscores, indicating that it does not operate on a working copy, but modifies this layout instead. */ - private __activatePart(id: string): this { - const part = this.part({by: {partId: id}}); - this.grid({by: {element: part}}).activePartId = id; - - return this; + private __activatePart(id: string): void { + const part = this.part({partId: id}); + this.grid({element: part}).activePartId = id; } /** * Note: This method name begins with underscores, indicating that it does not operate on a working copy, but modifies this layout instead. */ - private __toggleMaximized(): this { + private __toggleMaximized(): void { this._maximized = !this._maximized; - return this; } /** * Note: This method name begins with underscores, indicating that it does not operate on a working copy, but modifies this layout instead. */ - private __renameView(id: string, newViewId: string, options?: {grid?: keyof Grids}): this { - if (this.views().find(view => view.id === newViewId)) { - throw Error(`[IllegalArgumentError] View id must be unique. The layout already contains a view with the id '${newViewId}'.`); + private __renameView(view: MView, newViewId: ViewId): void { + if (this.hasView(newViewId)) { + throw Error(`[ViewRenameError] View id must be unique. The layout already contains a view with the id '${newViewId}'.`); } - const part = this.part({by: {viewId: id}, grid: options?.grid}); - const viewIndex = part.views.findIndex(view => view.id === id); - part.views[viewIndex] = {...part.views[viewIndex], id: newViewId}; + const part = this.part({viewId: view.id}); - if (part.activeViewId === id) { + if (this._viewOutlets.has(view.id)) { + this._viewOutlets.set(newViewId, this._viewOutlets.get(view.id)!); + this._viewOutlets.delete(view.id); + } + if (this._viewStates.has(view.id)) { + this._viewStates.set(newViewId, this._viewStates.get(view.id)!); + this._viewStates.delete(view.id); + } + + if (part.activeViewId === view.id) { part.activeViewId = newViewId; } - return this; + view.id = newViewId; } /** - * Returns the grid that contains the given element. If not found, throws an error. + * Finds a grid based on the specified filter. If not found, throws an error. + * + * @param findBy - Defines the search scope. + * @param findBy.element - Searches for a grid that contains the specified element. + * @return Grid matching the filter criteria. */ - private grid(find: {by: {element: MPart | MTreeNode}}): MPartGrid { + private grid(findBy: {element: MPart | MTreeNode}): MPartGrid { const gridName = this._gridNames.find(gridName => { - return this.findTreeElements((element: MTreeNode | MPart): element is MPart | MTreeNode => element === find.by.element, {findFirst: true, grid: gridName}).length > 0; + return this.findTreeElements((element: MTreeNode | MPart): element is MPart | MTreeNode => element === findBy.element, {findFirst: true, grid: gridName}).length > 0; }); if (!gridName) { - if (find.by.element instanceof MPart) { - throw Error(`[NullGridError] No grid found that contains the part '${find.by.element.id}'".`); + if (findBy.element instanceof MPart) { + throw Error(`[NullGridError] No grid found that contains the part '${findBy.element.id}'".`); } else { - throw Error(`[NullGridError] No grid found that contains the node '${find.by.element.nodeId}'".`); + throw Error(`[NullGridError] No grid found that contains the node '${findBy.element.nodeId}'".`); } } @@ -568,47 +727,31 @@ export class ɵWorkbenchLayout implements WorkbenchLayout { } /** - * Returns the element of given id. If not found, by default, throws an error unless setting the `orElseNull` option. + * Traverses the tree to find an element that matches the given predicate. * - * @param find - Search constraints - * @property by - * @property id - Specifies the identity of the element. - * @property grid - Limits the search scope. If not specified, all grids are searched. - * @param options - Search options - * @property orElse - If set, returns `null` instead of throwing an error if no element is found. - * @return part maching the filter criteria. + * @param findBy - Defines the search scope. + * @param findBy.id - Searches for an element with the specified id. + * @return Element matching the filter criteria. */ - private element(find: {by: {id: string}; grid?: keyof Grids}): MPart | MTreeNode; - private element(find: {by: {id: string}; grid?: keyof Grids}, options: {orElse: null}): MPart | MTreeNode | null; - private element(find: {by: {id: string}; grid?: keyof Grids}, options?: {orElse: null}): MPart | MTreeNode | null { - const element = this.findTreeElements((element: MTreeNode | MPart): element is MPart | MTreeNode => { - return element instanceof MPart ? element.id === find.by.id : element.nodeId === find.by.id; - }, {findFirst: true, grid: find.grid})[0]; - - if (!element && !options) { - throw Error(`[NullElementError] Element with id '${find.by.id}' not found in the layout.`); - } - return element ?? null; - } + private findTreeElement(findBy: {id: string}): T { + const element = this.findTreeElements((element: MTreeNode | MPart): element is T => { + return element instanceof MPart ? element.id === findBy.id : element.nodeId === findBy.id; + }, {findFirst: true}).at(0); - /** - * Returns the node of given id. If not found, throws an error. - */ - private node(find: {by: {nodeId: string}}): MTreeNode { - const node = this.findTreeElements((element: MTreeNode | MPart): element is MTreeNode => element instanceof MTreeNode && element.nodeId === find.by.nodeId, {findFirst: true})[0]; - if (!node) { - throw Error(`[NullNodeError] Node '${find.by.nodeId}' not found.`); + if (!element) { + throw Error(`[NullElementError] No element found with id '${findBy.id}'.`); } - return node; + return element; } /** * Traverses the tree to find elements that match the given predicate. * * @param predicateFn - Predicate function to match. - * @param options - Search options - * @property findFirst - If specified, stops traversing on first match. If not set, defaults to `false`. - * @property grid - Limits the search scope. If not specified, all grids are searched. + * @param options - Defines search scope and options. + * @param options.findFirst - If specified, stops traversing on first match. If not set, defaults to `false`. + * @param options.grid - Searches for an element contained in the specified grid. + * @return Elements matching the filter criteria. */ private findTreeElements(predicateFn: (element: MTreeNode | MPart) => element is T, options?: {findFirst?: boolean; grid?: keyof Grids}): T[] { if (options?.grid && !this._grids[options.grid]) { @@ -650,8 +793,10 @@ export class ɵWorkbenchLayout implements WorkbenchLayout { */ private workingCopy(): ɵWorkbenchLayout { return runInInjectionContext(this._injector, () => new ɵWorkbenchLayout({ - workbenchGrid: this._serializer.serialize(this.workbenchGrid, {includeNodeId: true}), - mainAreaGrid: this._serializer.serialize(this._grids.mainArea, {includeNodeId: true}), + workbenchGrid: this._serializer.serializeGrid(this.workbenchGrid, {includeNodeId: true}), + mainAreaGrid: this._serializer.serializeGrid(this._grids.mainArea, {includeNodeId: true}), + viewOutlets: Object.fromEntries(this._viewOutlets), + viewStates: Object.fromEntries(this._viewStates), maximized: this._maximized, })); } @@ -682,11 +827,41 @@ function createInitialMainAreaGrid(): MPartGrid { /** * Coerces {@link MPartGrid}, applying necessary migrations if the serialized grid is outdated. */ -function coerceMPartGrid(grid: string | MPartGrid): ɵMPartGrid { - if (typeof grid === 'string') { - return {...inject(WorkbenchLayoutSerializer).deserialize(grid)}; +function coerceMPartGrid(grid: string | MPartGrid | null | undefined, options: {default: () => MPartGrid}): ɵMPartGrid { + grid ??= options.default(); + + if (typeof grid === 'object') { + return grid; + } + + try { + return inject(WorkbenchLayoutSerializer).deserializeGrid(grid); + } + catch (error) { + inject(Logger).error('[SerializeError] Failed to deserialize workbench layout. Please clear your browser storage and reload the application.', error); + return {...options.default(), migrated: true}; + } +} + +/** + * Coerces {@link ViewOutlets}, applying necessary migrations if the serialized outlets are outdated. + */ +function coerceViewOutlets(viewOutlets: string | ViewOutlets | null | undefined): ViewOutlets { + if (!viewOutlets) { + return {}; + } + + if (typeof viewOutlets === 'object') { + return viewOutlets; + } + + try { + return inject(WorkbenchLayoutSerializer).deserializeViewOutlets(viewOutlets); + } + catch (error) { + inject(Logger).error('[SerializeError] Failed to deserialize view outlets. Please clear your browser storage and reload the application.', error); + return {}; } - return {...grid, migrated: false}; } /** @@ -705,19 +880,6 @@ interface Grids { mainArea?: ɵMPartGrid; } -/** - * Tests if the given {@link MTreeNode} or {@link MPart} is visible. - * - * - A part is considered visible if it is the main area part or has at least one view. - * - A node is considered visible if it has at least one visible part in its child hierarchy. - */ -export function isGridElementVisible(element: MTreeNode | MPart): boolean { - if (element instanceof MPart) { - return element.id === MAIN_AREA || element.views.length > 0; - } - return isGridElementVisible(element.child1) || isGridElementVisible(element.child2); -} - /** * Returns the position if a number, or computes it from the given literal otherwise. */ @@ -809,7 +971,36 @@ export class ViewActivationInstantProvider { /** * Returns the instant when the specified view was last activated. */ - public getActivationInstant(viewId: string): number { + public getActivationInstant(viewId: ViewId): number { return this._viewRegistry.get(viewId, {orElse: null})?.activationInstant ?? 0; } } + +/** + * Creates a predicate to match a view by its primary or alternative id, depending on the type of the passed id. + */ +function matchViewById(id: string): Predicate { + if (WorkbenchLayouts.isViewId(id)) { + return view => view.id === id; + } + else { + return view => view.alternativeId === id; + } +} + +/** + * Stringifies the given filter to be used in error messages. + */ +function stringifyFilter(filter: {[property: string]: unknown}): string { + return Object.entries(filter).map(([key, value]) => `${key}=${value}`).join(', '); +} + +/** + * Serialized artifacts of the workbench layout. + */ +export interface SerializedWorkbenchLayout { + workbenchGrid: string; + workbenchViewOutlets: string; + mainAreaGrid: string | null; + mainAreaViewOutlets: string; +} diff --git a/projects/scion/workbench/src/lib/message-box/workbench-message-box.options.ts b/projects/scion/workbench/src/lib/message-box/workbench-message-box.options.ts index d6f790cc0..8b528664d 100644 --- a/projects/scion/workbench/src/lib/message-box/workbench-message-box.options.ts +++ b/projects/scion/workbench/src/lib/message-box/workbench-message-box.options.ts @@ -8,6 +8,7 @@ * SPDX-License-Identifier: EPL-2.0 */ import {Injector} from '@angular/core'; +import {ViewId} from '../view/workbench-view.model'; /** * Controls the appearance and behavior of a message box. @@ -92,7 +93,7 @@ export interface WorkbenchMessageBoxOptions { injector?: Injector; /** - * Specifies CSS class(es) to be added to the message box, useful in end-to-end tests for locating the message box. + * Specifies CSS class(es) to add to the message box, e.g., to locate the message box in tests. */ cssClass?: string | string[]; @@ -105,6 +106,6 @@ export interface WorkbenchMessageBoxOptions { * * By default, if opening the message box in the context of a view, that view is used as the contextual view. */ - viewId?: string; + viewId?: ViewId; }; } diff --git a/projects/scion/workbench/src/lib/microfrontend-platform/microfrontend-host-dialog/microfrontend-host-dialog.component.ts b/projects/scion/workbench/src/lib/microfrontend-platform/microfrontend-host-dialog/microfrontend-host-dialog.component.ts index 5e0c25ab1..e96fdd545 100644 --- a/projects/scion/workbench/src/lib/microfrontend-platform/microfrontend-host-dialog/microfrontend-host-dialog.component.ts +++ b/projects/scion/workbench/src/lib/microfrontend-platform/microfrontend-host-dialog/microfrontend-host-dialog.component.ts @@ -8,7 +8,7 @@ * SPDX-License-Identifier: EPL-2.0 */ -import {Component, inject, Injector, Input, OnDestroy, OnInit, StaticProvider} from '@angular/core'; +import {Component, inject, Injector, Input, OnDestroy, OnInit, runInInjectionContext, StaticProvider} from '@angular/core'; import {WorkbenchDialog as WorkbenchClientDialog, WorkbenchDialogCapability} from '@scion/workbench-client'; import {RouterUtils} from '../../routing/router.util'; import {Commands} from '../../routing/routing.model'; @@ -72,7 +72,7 @@ export class MicrofrontendHostDialogComponent implements OnDestroy, OnInit { private navigate(path: string | null, extras?: {params?: Map}): Promise { path = Microfrontends.substituteNamedParameters(path, extras?.params); - const outletCommands: Commands | null = (path !== null ? RouterUtils.segmentsToCommands(RouterUtils.parsePath(this._router, path)) : null); + const outletCommands: Commands | null = (path !== null ? runInInjectionContext(this._injector, () => RouterUtils.pathToCommands(path!)) : null); const commands: Commands = [{outlets: {[this.outletName]: outletCommands}}]; return this._router.navigate(commands, {skipLocationChange: true, queryParamsHandling: 'preserve'}); } diff --git a/projects/scion/workbench/src/lib/microfrontend-platform/microfrontend-host-popup/microfrontend-host-popup.component.ts b/projects/scion/workbench/src/lib/microfrontend-platform/microfrontend-host-popup/microfrontend-host-popup.component.ts index 6ba8bab53..257282b3f 100644 --- a/projects/scion/workbench/src/lib/microfrontend-platform/microfrontend-host-popup/microfrontend-host-popup.component.ts +++ b/projects/scion/workbench/src/lib/microfrontend-platform/microfrontend-host-popup/microfrontend-host-popup.component.ts @@ -8,7 +8,7 @@ * SPDX-License-Identifier: EPL-2.0 */ -import {Component, inject, Injector, OnDestroy, StaticProvider} from '@angular/core'; +import {Component, inject, Injector, OnDestroy, runInInjectionContext, StaticProvider} from '@angular/core'; import {WorkbenchPopup, ɵPopupContext} from '@scion/workbench-client'; import {RouterUtils} from '../../routing/router.util'; import {Commands} from '../../routing/routing.model'; @@ -41,7 +41,7 @@ export class MicrofrontendHostPopupComponent implements OnDestroy { public readonly outletInjector: Injector; constructor(popup: Popup<ɵPopupContext>, - injector: Injector, + private _injector: Injector, private _router: Router) { const popupContext = popup.input!; const capability = popupContext.capability; @@ -49,7 +49,7 @@ export class MicrofrontendHostPopupComponent implements OnDestroy { const params = popupContext.params; this.outletName = POPUP_ID_PREFIX.concat(popupContext.popupId); this.outletInjector = Injector.create({ - parent: injector, + parent: this._injector, providers: [provideWorkbenchPopupHandle(popupContext)], }); @@ -67,7 +67,7 @@ export class MicrofrontendHostPopupComponent implements OnDestroy { private navigate(path: string | null, extras: {outletName: string; params?: Map}): Promise { path = Microfrontends.substituteNamedParameters(path, extras.params); - const outletCommands: Commands | null = (path !== null ? RouterUtils.segmentsToCommands(RouterUtils.parsePath(this._router, path)) : null); + const outletCommands: Commands | null = (path !== null ? runInInjectionContext(this._injector, () => RouterUtils.pathToCommands(path!)) : null); const commands: Commands = [{outlets: {[extras.outletName]: outletCommands}}]; return this._router.navigate(commands, {skipLocationChange: true, queryParamsHandling: 'preserve'}); } diff --git a/projects/scion/workbench/src/lib/microfrontend-platform/microfrontend-view/microfrontend-view-command-handler.service.ts b/projects/scion/workbench/src/lib/microfrontend-platform/microfrontend-view/microfrontend-view-command-handler.service.ts index 64caa4454..322320f4e 100644 --- a/projects/scion/workbench/src/lib/microfrontend-platform/microfrontend-view/microfrontend-view-command-handler.service.ts +++ b/projects/scion/workbench/src/lib/microfrontend-platform/microfrontend-view/microfrontend-view-command-handler.service.ts @@ -11,7 +11,7 @@ import {Injectable, OnDestroy} from '@angular/core'; import {Message, MessageClient, MessageHeaders} from '@scion/microfrontend-platform'; import {Logger} from '../../logging'; -import {WorkbenchView} from '../../view/workbench-view.model'; +import {ViewId, WorkbenchView} from '../../view/workbench-view.model'; import {WorkbenchViewRegistry} from '../../view/workbench-view.registry'; import {map, switchMap} from 'rxjs/operators'; import {ɵWorkbenchCommands} from '@scion/workbench-client'; @@ -61,7 +61,7 @@ export class MicrofrontendViewCommandHandler implements OnDestroy { */ private installViewTitleCommandHandler(): Subscription { return this._messageClient.onMessage(ɵWorkbenchCommands.viewTitleTopic(':viewId'), message => { - const viewId = message.params!.get('viewId')!; + const viewId = message.params!.get('viewId') as ViewId; this.runIfPrivileged(viewId, message, view => { view.title = message.body; }); @@ -73,7 +73,7 @@ export class MicrofrontendViewCommandHandler implements OnDestroy { */ private installViewHeadingCommandHandler(): Subscription { return this._messageClient.onMessage(ɵWorkbenchCommands.viewHeadingTopic(':viewId'), message => { - const viewId = message.params!.get('viewId')!; + const viewId = message.params!.get('viewId') as ViewId; this.runIfPrivileged(viewId, message, view => { view.heading = message.body; }); @@ -85,7 +85,7 @@ export class MicrofrontendViewCommandHandler implements OnDestroy { */ private installViewDirtyCommandHandler(): Subscription { return this._messageClient.onMessage(ɵWorkbenchCommands.viewDirtyTopic(':viewId'), message => { - const viewId = message.params!.get('viewId')!; + const viewId = message.params!.get('viewId') as ViewId; this.runIfPrivileged(viewId, message, view => { view.dirty = message.body; }); @@ -97,7 +97,7 @@ export class MicrofrontendViewCommandHandler implements OnDestroy { */ private installViewClosableCommandHandler(): Subscription { return this._messageClient.onMessage(ɵWorkbenchCommands.viewClosableTopic(':viewId'), message => { - const viewId = message.params!.get('viewId')!; + const viewId = message.params!.get('viewId') as ViewId; this.runIfPrivileged(viewId, message, view => { view.closable = message.body; }); @@ -109,7 +109,7 @@ export class MicrofrontendViewCommandHandler implements OnDestroy { */ private installViewCloseCommandHandler(): Subscription { return this._messageClient.onMessage(ɵWorkbenchCommands.viewCloseTopic(':viewId'), message => { - const viewId = message.params!.get('viewId')!; + const viewId = message.params!.get('viewId') as ViewId; this.runIfPrivileged(viewId, message, view => { view.close().then(); }); @@ -120,7 +120,7 @@ export class MicrofrontendViewCommandHandler implements OnDestroy { * Runs the given runnable only if the microfrontend displayed in the view is actually provided by the sender, * thus preventing other apps from updating other apps' views. */ - private runIfPrivileged(viewId: string, message: Message, runnable: (view: WorkbenchView) => void): void { + private runIfPrivileged(viewId: ViewId, message: Message, runnable: (view: WorkbenchView) => void): void { const view = this._viewRegistry.get(viewId); const sender = message.headers.get(MessageHeaders.AppSymbolicName); if (view.adapt(MicrofrontendWorkbenchView)?.capability.metadata!.appSymbolicName === sender) { diff --git a/projects/scion/workbench/src/lib/microfrontend-platform/microfrontend-view/microfrontend-view.component.ts b/projects/scion/workbench/src/lib/microfrontend-platform/microfrontend-view/microfrontend-view.component.ts index 0b3b68505..f53a0bc24 100644 --- a/projects/scion/workbench/src/lib/microfrontend-platform/microfrontend-view/microfrontend-view.component.ts +++ b/projects/scion/workbench/src/lib/microfrontend-platform/microfrontend-view/microfrontend-view.component.ts @@ -34,6 +34,7 @@ import {MicrofrontendSplashComponent} from '../microfrontend-splash/microfronten import {GLASS_PANE_BLOCKABLE, GlassPaneDirective} from '../../glass-pane/glass-pane.directive'; import {MicrofrontendWorkbenchView} from './microfrontend-workbench-view.model'; import {Microfrontends} from '../common/microfrontend.util'; +import {Objects} from '../../common/objects.util'; /** * Embeds the microfrontend of a view capability. @@ -205,15 +206,16 @@ export class MicrofrontendViewComponent implements OnInit, OnDestroy, WorkbenchV const paramsHandling = request.body!.paramsHandling; const currentParams = this._route.snapshot.params; const newParams = Dictionaries.coerce(request.body!.params); // coerce params for backward compatibility - const mergedParams = Dictionaries.withoutUndefinedEntries(paramsHandling === 'merge' ? {...currentParams, ...newParams} : newParams); + const mergedParams = Objects.withoutUndefinedEntries(paramsHandling === 'merge' ? {...currentParams, ...newParams} : newParams); const {urlParams, transientParams} = MicrofrontendViewRoutes.splitParams(mergedParams, viewCapability); - return { - layout, - viewOutlets: {[this.view.id]: [urlParams]}, - viewStates: {[this.view.id]: {[MicrofrontendViewRoutes.STATE_TRANSIENT_PARAMS]: transientParams}}, - }; - }, {relativeTo: this._route}); + return layout.navigateView(this.view.id, [urlParams], { + relativeTo: this._route, + state: Objects.withoutUndefinedEntries({ + [MicrofrontendViewRoutes.STATE_TRANSIENT_PARAMS]: transientParams, + }), + }); + }); await this._messageClient.publish(replyTo, success, {headers: new Map().set(MessageHeaders.Status, ResponseStatusCodes.TERMINAL)}); } diff --git a/projects/scion/workbench/src/lib/microfrontend-platform/routing/microfrontend-view-intent-handler.interceptor.ts b/projects/scion/workbench/src/lib/microfrontend-platform/routing/microfrontend-view-intent-handler.interceptor.ts index f1c88f976..0c78de050 100644 --- a/projects/scion/workbench/src/lib/microfrontend-platform/routing/microfrontend-view-intent-handler.interceptor.ts +++ b/projects/scion/workbench/src/lib/microfrontend-platform/routing/microfrontend-view-intent-handler.interceptor.ts @@ -18,6 +18,7 @@ import {Beans} from '@scion/toolkit/bean-manager'; import {Arrays, Dictionaries} from '@scion/toolkit/util'; import {WorkbenchViewRegistry} from '../../view/workbench-view.registry'; import {MicrofrontendWorkbenchView} from '../microfrontend-view/microfrontend-workbench-view.model'; +import {Objects} from '../../common/objects.util'; /** * Handles microfrontend view intents, instructing the workbench to navigate to the microfrontend of the resolved capability. @@ -56,20 +57,20 @@ export class MicrofrontendViewIntentHandler implements IntentInterceptor { const intent = message.intent; const extras: WorkbenchNavigationExtras = message.body ?? {}; - const intentParams = Dictionaries.withoutUndefinedEntries(Dictionaries.coerce(intent.params)); + const intentParams = Objects.withoutUndefinedEntries(Dictionaries.coerce(intent.params)); const {urlParams, transientParams} = MicrofrontendViewRoutes.splitParams(intentParams, viewCapability); const targets = this.resolveTargets(message, extras); - const routerNavigateCommand = extras.close ? [] : MicrofrontendViewRoutes.createMicrofrontendNavigateCommands(viewCapability.metadata!.id, urlParams); + const commands = extras.close ? [] : MicrofrontendViewRoutes.createMicrofrontendNavigateCommands(viewCapability.metadata!.id, urlParams); - this._logger.debug(() => `Navigating to: ${viewCapability.properties.path}`, LoggerNames.MICROFRONTEND_ROUTING, routerNavigateCommand, viewCapability, transientParams); + this._logger.debug(() => `Navigating to: ${viewCapability.properties.path}`, LoggerNames.MICROFRONTEND_ROUTING, commands, viewCapability, transientParams); const navigations = await Promise.all(Arrays.coerce(targets).map(target => { - return this._workbenchRouter.navigate(routerNavigateCommand, { + return this._workbenchRouter.navigate(commands, { target, activate: extras.activate, close: extras.close, blankInsertionIndex: extras.blankInsertionIndex, cssClass: extras.cssClass, - state: Dictionaries.withoutUndefinedEntries({ + state: Objects.withoutUndefinedEntries({ [MicrofrontendViewRoutes.STATE_TRANSIENT_PARAMS]: transientParams, }), }); @@ -83,7 +84,7 @@ export class MicrofrontendViewIntentHandler implements IntentInterceptor { private resolveTargets(intentMessage: IntentMessage, extras: WorkbenchNavigationExtras): string | string[] { // Closing a microfrontend view by viewId is not allowed, as this would violate the concept of intent-based view navigation. if (extras.close && extras.target) { - throw Error(`[WorkbenchRouterError][IllegalArgumentError] The target must be empty if closing a view [target=${(extras.target)}]`); + throw Error(`[NavigateError] The target must be empty if closing a view [target=${(extras.target)}]`); } if (extras.close) { return this.resolvePresentViewIds(intentMessage, {matchWildcardParams: true}) ?? []; diff --git a/projects/scion/workbench/src/lib/microfrontend-platform/routing/microfrontend-view-routes.ts b/projects/scion/workbench/src/lib/microfrontend-platform/routing/microfrontend-view-routes.ts index a01fb8a45..4c15ff6ed 100644 --- a/projects/scion/workbench/src/lib/microfrontend-platform/routing/microfrontend-view-routes.ts +++ b/projects/scion/workbench/src/lib/microfrontend-platform/routing/microfrontend-view-routes.ts @@ -8,12 +8,12 @@ * SPDX-License-Identifier: EPL-2.0 */ -import {Params, PRIMARY_OUTLET, Route, UrlMatcher, UrlMatchResult, UrlSegment, UrlSegmentGroup} from '@angular/router'; +import {Params, Route, UrlMatcher, UrlMatchResult, UrlSegment, UrlSegmentGroup} from '@angular/router'; import {WorkbenchViewCapability, ɵMicrofrontendRouteParams} from '@scion/workbench-client'; -import {inject, Injector, runInInjectionContext} from '@angular/core'; -import {RouterUtils} from '../../routing/router.util'; -import {WorkbenchNavigationalStates} from '../../routing/workbench-navigational-states'; +import {inject, Injector} from '@angular/core'; import {Commands} from '../../routing/routing.model'; +import {WorkbenchRouter} from '../../routing/workbench-router.service'; +import {WorkbenchLayouts} from '../../layout/workbench-layouts.util'; /** * Provides functions and constants specific to microfrontend routes. @@ -47,14 +47,16 @@ export const MicrofrontendViewRoutes = { const injector = inject(Injector); return (segments: UrlSegment[], group: UrlSegmentGroup, route: Route): UrlMatchResult | null => { - if (!RouterUtils.isPrimaryRouteTarget(route.outlet ?? PRIMARY_OUTLET)) { + if (!WorkbenchLayouts.isViewId(route.outlet)) { return null; } if (!MicrofrontendViewRoutes.isMicrofrontendRoute(segments)) { return null; } - const viewState = runInInjectionContext(injector, () => WorkbenchNavigationalStates.resolveViewState(route.outlet!)); - const transientParams = viewState?.[MicrofrontendViewRoutes.STATE_TRANSIENT_PARAMS] ?? {}; + + const {layout} = injector.get(WorkbenchRouter).getCurrentNavigationContext(); + const viewState = layout.viewState({viewId: route.outlet}); + const transientParams = viewState[MicrofrontendViewRoutes.STATE_TRANSIENT_PARAMS] ?? {}; const posParams = Object.entries(transientParams).map(([name, value]) => [name, new UrlSegment(value, {})]); return { diff --git a/projects/scion/workbench/src/lib/migration/workbench-migration.ts b/projects/scion/workbench/src/lib/migration/workbench-migration.ts new file mode 100644 index 000000000..46cff7a97 --- /dev/null +++ b/projects/scion/workbench/src/lib/migration/workbench-migration.ts @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2018-2024 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 + */ + +/** + * Represents a migration to migrate a workbench object to the next version. + */ +export interface WorkbenchMigration { + + /** + * Migrates serialized workbench data to the next version. + */ + migrate(json: string): string; +} diff --git a/projects/scion/workbench/src/lib/migration/workbench-migrator.ts b/projects/scion/workbench/src/lib/migration/workbench-migrator.ts new file mode 100644 index 000000000..088a69c25 --- /dev/null +++ b/projects/scion/workbench/src/lib/migration/workbench-migrator.ts @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2018-2024 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 {WorkbenchMigration} from './workbench-migration'; + +/** + * Migrates serialized workbench data to the latest version. + */ +export class WorkbenchMigrator { + + private _migrators = new Map(); + + /** + * Registers a migration from a specific version to the next version. + */ + public registerMigration(fromVersion: number, migration: WorkbenchMigration): this { + this._migrators.set(fromVersion, migration); + return this; + } + + /** + * Migrates serialized workbench data to the latest version. + */ + public migrate(json: string, version: {from: number; to: number}): string { + for (let v = version.from; v < version.to; v++) { + const migrator = this._migrators.get(v); + if (!migrator) { + throw Error(`[NullMigrationError] Cannot perform workbench data migration. No migration registered for version ${v}.`); + } + json = migrator.migrate(json); + } + return json; + } +} diff --git a/projects/scion/workbench/src/lib/notification/notification.config.ts b/projects/scion/workbench/src/lib/notification/notification.config.ts index 73e8c5ad6..ff43e6d7e 100644 --- a/projects/scion/workbench/src/lib/notification/notification.config.ts +++ b/projects/scion/workbench/src/lib/notification/notification.config.ts @@ -101,7 +101,7 @@ export interface NotificationConfig { groupInputReduceFn?: (prevInput: any, currInput: any) => any; /** - * Specifies CSS class(es) to be added to the notification, useful in end-to-end tests for locating the notification. + * Specifies CSS class(es) to add to the notification, e.g., to locate the notification in tests. */ cssClass?: string | string[]; } diff --git a/projects/scion/workbench/src/lib/notification/notification.ts b/projects/scion/workbench/src/lib/notification/notification.ts index 19c2005a9..de3352a64 100644 --- a/projects/scion/workbench/src/lib/notification/notification.ts +++ b/projects/scion/workbench/src/lib/notification/notification.ts @@ -41,9 +41,7 @@ export abstract class Notification { public abstract setDuration(duration: 'short' | 'medium' | 'long' | 'infinite' | number): void; /** - * Specifies CSS class(es) to be added to the notification, useful in end-to-end tests for locating the notification. - * - * This operation is additive, that is, it does not override CSS classes set by the notification opener. + * Specifies CSS class(es) to add to the notification, e.g., to locate the notification in tests. */ public abstract setCssClass(cssClass: string | string[]): void; } diff --git a/projects/scion/workbench/src/lib/page-not-found/format-url.pipe.ts b/projects/scion/workbench/src/lib/page-not-found/format-url.pipe.ts new file mode 100644 index 000000000..dc3ba4af8 --- /dev/null +++ b/projects/scion/workbench/src/lib/page-not-found/format-url.pipe.ts @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2018-2024 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 {Pipe, PipeTransform} from '@angular/core'; +import {UrlSegment} from '@angular/router'; + +/** + * Formats given URL. + */ +@Pipe({name: 'appFormatUrl', standalone: true}) +export class FormatUrlPipe implements PipeTransform { + + public transform(url: UrlSegment[]): string { + return url.map(segment => segment.path).join('/'); + } +} diff --git a/projects/scion/workbench/src/lib/page-not-found/page-not-found.component.html b/projects/scion/workbench/src/lib/page-not-found/page-not-found.component.html new file mode 100644 index 000000000..c6992b333 --- /dev/null +++ b/projects/scion/workbench/src/lib/page-not-found/page-not-found.component.html @@ -0,0 +1,15 @@ +
Page Not Found
+ +
+ The requested page {{view.urlSegments | appFormatUrl}} was not found. +
+ The URL may have changed. Try to open the view again. +
+ + + +@if (isDevMode) { +
+ You can create a custom "Page Not Found" component and register it in the workbench configuration to personalize this page. +
+} diff --git a/projects/scion/workbench/src/lib/page-not-found/page-not-found.component.scss b/projects/scion/workbench/src/lib/page-not-found/page-not-found.component.scss new file mode 100644 index 000000000..5ada9eaf2 --- /dev/null +++ b/projects/scion/workbench/src/lib/page-not-found/page-not-found.component.scss @@ -0,0 +1,49 @@ +:host { + display: flex; + flex-direction: column; + gap: 2em; + padding: 1em; + align-items: center; + + > header { + font-weight: bold; + font-size: 1.3rem; + } + + > section.message { + text-align: center; + line-height: 1.75; + + > span.url { + font-weight: bold; + } + } + + > section.developer-hint { + border: 1px solid var(--sci-color-accent); + border-radius: var(--sci-corner); + padding: 1em; + max-width: 550px; + color: var(--sci-color-accent); + font-family: monospace; + text-align: center; + } + + > button { + all: unset; + cursor: pointer; + padding: .5em 1.5em; + color: var(--sci-color-accent-inverse); + background-color: var(--sci-color-accent); + background-clip: padding-box; + border: 1px solid var(--sci-color-accent); + border-radius: var(--sci-corner); + text-align: center; + + &:focus, &:active { + border-color: transparent; + outline: 1px solid var(--sci-color-accent); + color: var(--sci-color-accent-inverse); + } + } +} diff --git a/projects/scion/workbench/src/lib/page-not-found/page-not-found.component.ts b/projects/scion/workbench/src/lib/page-not-found/page-not-found.component.ts new file mode 100644 index 000000000..683a6f073 --- /dev/null +++ b/projects/scion/workbench/src/lib/page-not-found/page-not-found.component.ts @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2018-2024 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, inject, isDevMode} from '@angular/core'; +import {WorkbenchView} from '../view/workbench-view.model'; +import {FormatUrlPipe} from './format-url.pipe'; + +@Component({ + selector: 'wb-page-not-found', + templateUrl: './page-not-found.component.html', + styleUrls: ['./page-not-found.component.scss'], + standalone: true, + imports: [ + FormatUrlPipe, + ], +}) +export default class PageNotFoundComponent { + + protected isDevMode = isDevMode(); + protected view = inject(WorkbenchView); +} diff --git a/projects/scion/workbench/src/lib/part/part-action-bar/part-action.directive.ts b/projects/scion/workbench/src/lib/part/part-action-bar/part-action.directive.ts index 2b0715ed5..cf1de8a0a 100644 --- a/projects/scion/workbench/src/lib/part/part-action-bar/part-action.directive.ts +++ b/projects/scion/workbench/src/lib/part/part-action-bar/part-action.directive.ts @@ -60,7 +60,7 @@ export class WorkbenchPartActionDirective implements OnInit, OnDestroy { public canMatch?: CanMatchPartFn; /** - * Specifies CSS class(es) to be associated with the action, useful in end-to-end tests for locating it. + * Specifies CSS class(es) to add to the action, e.g., to locate the action in tests. */ @Input() public cssClass?: string | string[] | undefined; diff --git a/projects/scion/workbench/src/lib/part/part-bar/part-bar.component.ts b/projects/scion/workbench/src/lib/part/part-bar/part-bar.component.ts index 975d6ef87..ebe4551a5 100644 --- a/projects/scion/workbench/src/lib/part/part-bar/part-bar.component.ts +++ b/projects/scion/workbench/src/lib/part/part-bar/part-bar.component.ts @@ -27,6 +27,7 @@ import {ViewListButtonComponent} from '../view-list-button/view-list-button.comp import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; import {fromDimension$} from '@scion/toolkit/observable'; import {WORKBENCH_ID} from '../../workbench-id'; +import {ViewId} from '../../view/workbench-view.model'; /** * Renders view tabs and actions of a {@link WorkbenchPart}. @@ -165,7 +166,7 @@ export class PartBarComponent implements OnInit { event.stopPropagation(); } - public get viewIds$(): Observable { + public get viewIds$(): Observable { return this._part.viewIds$; } @@ -303,7 +304,9 @@ export class PartBarComponent implements OnInit { workbenchId: this._dragData!.workbenchId, partId: this._dragData!.partId, viewId: this._dragData!.viewId, + alternativeViewId: this._dragData!.alternativeViewId, viewUrlSegments: this._dragData!.viewUrlSegments, + navigationHint: this._dragData!.navigationHint, classList: this._dragData!.classList, }, target: { diff --git a/projects/scion/workbench/src/lib/part/part.component.ts b/projects/scion/workbench/src/lib/part/part.component.ts index 63c8c20b0..3158a09f3 100644 --- a/projects/scion/workbench/src/lib/part/part.component.ts +++ b/projects/scion/workbench/src/lib/part/part.component.ts @@ -93,7 +93,9 @@ export class PartComponent implements OnInit, OnDestroy { workbenchId: event.dragData.workbenchId, partId: event.dragData.partId, viewId: event.dragData.viewId, + alternativeViewId: event.dragData.alternativeViewId, viewUrlSegments: event.dragData.viewUrlSegments, + navigationHint: event.dragData.navigationHint, classList: event.dragData.classList, }, target: { diff --git a/projects/scion/workbench/src/lib/part/view-context-menu/view-menu.directive.ts b/projects/scion/workbench/src/lib/part/view-context-menu/view-menu.directive.ts index 507eadab6..fb21f1a1e 100644 --- a/projects/scion/workbench/src/lib/part/view-context-menu/view-menu.directive.ts +++ b/projects/scion/workbench/src/lib/part/view-context-menu/view-menu.directive.ts @@ -49,7 +49,7 @@ export class WorkbenchViewMenuItemDirective implements OnDestroy { public disabled = false; /** - * Specifies CSS class(es) to be added to the menu item, useful in end-to-end tests for locating the menu item. + * Specifies CSS class(es) to add to the menu item, e.g., to locate the menu item in tests. */ @Input() public cssClass?: string | string[] | undefined; diff --git a/projects/scion/workbench/src/lib/part/view-context-menu/view-menu.service.ts b/projects/scion/workbench/src/lib/part/view-context-menu/view-menu.service.ts index e5da46cc4..b99a1ee2d 100644 --- a/projects/scion/workbench/src/lib/part/view-context-menu/view-menu.service.ts +++ b/projects/scion/workbench/src/lib/part/view-context-menu/view-menu.service.ts @@ -22,7 +22,7 @@ import {MenuItemConfig, WorkbenchModuleConfig} from '../../workbench-module-conf import {WorkbenchService} from '../../workbench.service'; import {filterArray, observeInside, subscribeInside} from '@scion/toolkit/operators'; import {ɵWorkbenchView} from '../../view/ɵworkbench-view.model'; -import {WorkbenchView} from '../../view/workbench-view.model'; +import {ViewId, WorkbenchView} from '../../view/workbench-view.model'; import {provideViewContext} from '../../view/view-context-provider'; import {Arrays} from '@scion/toolkit/util'; @@ -61,7 +61,7 @@ export class ViewMenuService { * * @see {@link WorkbenchView.registerViewMenuItem} */ - public async showMenu(location: Point, viewId: string): Promise { + public async showMenu(location: Point, viewId: ViewId): Promise { const view = this._viewRegistry.get(viewId); const menuItems = await firstValueFrom(view.menuItems$); diff --git a/projects/scion/workbench/src/lib/part/view-list-item/view-list-item.component.ts b/projects/scion/workbench/src/lib/part/view-list-item/view-list-item.component.ts index 594243a7f..5635fdcc7 100644 --- a/projects/scion/workbench/src/lib/part/view-list-item/view-list-item.component.ts +++ b/projects/scion/workbench/src/lib/part/view-list-item/view-list-item.component.ts @@ -15,7 +15,7 @@ import {WorkbenchViewRegistry} from '../../view/workbench-view.registry'; import {ViewTabContentComponent} from '../view-tab-content/view-tab-content.component'; import {NgIf} from '@angular/common'; import {WorkbenchModuleConfig} from '../../workbench-module-config'; -import {WorkbenchView} from '../../view/workbench-view.model'; +import {ViewId, WorkbenchView} from '../../view/workbench-view.model'; import {VIEW_TAB_RENDERING_CONTEXT, ViewTabRenderingContext} from '../../workbench.constants'; @Component({ @@ -35,7 +35,7 @@ export class ViewListItemComponent { public viewTabContentPortal!: ComponentPortal; @Input({required: true}) - public set viewId(viewId: string) { + public set viewId(viewId: ViewId) { this.view = this._viewRegistry.get(viewId); this.viewTabContentPortal = this.createViewTabContentPortal(); } diff --git a/projects/scion/workbench/src/lib/part/view-tab/view-tab.component.ts b/projects/scion/workbench/src/lib/part/view-tab/view-tab.component.ts index 6c5eb65a2..fa0a4fab7 100644 --- a/projects/scion/workbench/src/lib/part/view-tab/view-tab.component.ts +++ b/projects/scion/workbench/src/lib/part/view-tab/view-tab.component.ts @@ -20,7 +20,7 @@ import {WorkbenchModuleConfig} from '../../workbench-module-config'; import {ViewTabContentComponent} from '../view-tab-content/view-tab-content.component'; import {ViewMenuService} from '../view-context-menu/view-menu.service'; import {ɵWorkbenchView} from '../../view/ɵworkbench-view.model'; -import {WorkbenchView} from '../../view/workbench-view.model'; +import {ViewId, WorkbenchView} from '../../view/workbench-view.model'; import {WorkbenchRouter} from '../../routing/workbench-router.service'; import {subscribeInside} from '@scion/toolkit/operators'; import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; @@ -52,7 +52,7 @@ export class ViewTabComponent implements OnChanges { @Input({required: true}) @HostBinding('attr.data-viewid') - public viewId!: string; + public viewId!: ViewId; @HostBinding('attr.draggable') public draggable = true; @@ -149,6 +149,8 @@ export class ViewTabComponent implements OnChanges { viewClosable: this.view.closable, viewDirty: this.view.dirty, viewUrlSegments: this.view.urlSegments, + alternativeViewId: this.view.alternativeId, + navigationHint: this.view.navigationHint, partId: this.view.part.id, viewTabPointerOffsetX: event.offsetX, viewTabPointerOffsetY: event.offsetY, diff --git a/projects/scion/workbench/src/lib/part/workbench-part.model.ts b/projects/scion/workbench/src/lib/part/workbench-part.model.ts index 13feffe51..24b8800f8 100644 --- a/projects/scion/workbench/src/lib/part/workbench-part.model.ts +++ b/projects/scion/workbench/src/lib/part/workbench-part.model.ts @@ -1,5 +1,6 @@ import {Observable} from 'rxjs'; import {WorkbenchPartAction} from '../workbench.model'; +import {ViewId} from '../view/workbench-view.model'; /** * Represents a part of the workbench layout. @@ -35,24 +36,24 @@ export abstract class WorkbenchPart { /** * Emits the currently active view in this part. */ - public abstract readonly activeViewId$: Observable; + public abstract readonly activeViewId$: Observable; /** * The currently active view, if any. */ - public abstract readonly activeViewId: string | null; + public abstract readonly activeViewId: ViewId | null; /** * Emits the views opened in this part. * * Upon subscription, emits the current views, and then each time the views change. The observable never completes. */ - public abstract readonly viewIds$: Observable; + public abstract readonly viewIds$: Observable; /** * The currently opened views in this part. */ - public abstract readonly viewIds: string[]; + public abstract readonly viewIds: ViewId[]; /** * Emits actions associated with this part. diff --git a/projects/scion/workbench/src/lib/part/workbench-part.spec.ts b/projects/scion/workbench/src/lib/part/workbench-part.spec.ts index ae66448ae..7e545e04c 100644 --- a/projects/scion/workbench/src/lib/part/workbench-part.spec.ts +++ b/projects/scion/workbench/src/lib/part/workbench-part.spec.ts @@ -27,13 +27,14 @@ describe('WorkbenchPart', () => { layout: factory => factory .addPart('left-top') .addPart('left-bottom', {relativeTo: 'left-top', align: 'bottom'}) - .addView('view-1', {partId: 'left-top', activateView: true}) - .addView('view-2', {partId: 'left-bottom', activateView: true}) + .addView('view.101', {partId: 'left-top', activateView: true}) + .addView('view.102', {partId: 'left-bottom', activateView: true}) + .navigateView('view.101', ['test-page']) + .navigateView('view.102', ['test-page']) .activatePart('left-top'), }), RouterTestingModule.withRoutes([ - {path: '', outlet: 'view-1', component: TestComponent}, - {path: '', outlet: 'view-2', component: TestComponent}, + {path: 'test-page', component: TestComponent}, ]), ], }); @@ -45,7 +46,7 @@ describe('WorkbenchPart', () => { expect(TestBed.inject(WorkbenchPartRegistry).get('left-bottom').active).toBeFalse(); // WHEN activating already active view - await TestBed.inject(WorkbenchViewRegistry).get('view-2').activate(); + await TestBed.inject(WorkbenchViewRegistry).get('view.102').activate(); // THEN expect part to be activated. expect(TestBed.inject(WorkbenchPartRegistry).get('left-top').active).toBeFalse(); diff --git "a/projects/scion/workbench/src/lib/part/\311\265workbench-part.model.ts" "b/projects/scion/workbench/src/lib/part/\311\265workbench-part.model.ts" index 43be7ccb8..387a6ced3 100644 --- "a/projects/scion/workbench/src/lib/part/\311\265workbench-part.model.ts" +++ "b/projects/scion/workbench/src/lib/part/\311\265workbench-part.model.ts" @@ -21,6 +21,7 @@ import {filterArray} from '@scion/toolkit/operators'; import {distinctUntilChanged, filter, map, takeUntil} from 'rxjs/operators'; import {ɵWorkbenchLayout} from '../layout/ɵworkbench-layout'; import {WorkbenchLayoutService} from '../layout/workbench-layout.service'; +import {ViewId} from '../view/workbench-view.model'; export class ɵWorkbenchPart implements WorkbenchPart { @@ -35,8 +36,8 @@ export class ɵWorkbenchPart implements WorkbenchPart { private readonly _destroy$ = new Subject(); public readonly active$ = new BehaviorSubject(false); - public readonly viewIds$ = new BehaviorSubject([]); - public readonly activeViewId$ = new BehaviorSubject(null); + public readonly viewIds$ = new BehaviorSubject([]); + public readonly activeViewId$ = new BehaviorSubject(null); public readonly actions$: Observable; private _isInMainArea: boolean | undefined; @@ -66,7 +67,7 @@ export class ɵWorkbenchPart implements WorkbenchPart { */ public onLayoutChange(layout: ɵWorkbenchLayout): void { this._isInMainArea ??= layout.hasPart(this.id, {grid: 'mainArea'}); - const part = layout.part({by: {partId: this.id}}); + const part = layout.part({partId: this.id}); const active = layout.activePart({grid: this._isInMainArea ? 'mainArea' : 'workbench'})?.id === this.id; const prevViewIds = this.viewIds$.value; const currViewIds = part.views.map(view => view.id); @@ -87,11 +88,11 @@ export class ɵWorkbenchPart implements WorkbenchPart { } } - public get viewIds(): string[] { + public get viewIds(): ViewId[] { return this.viewIds$.value; } - public get activeViewId(): string | null { + public get activeViewId(): ViewId | null { return this.activeViewId$.value; } diff --git a/projects/scion/workbench/src/lib/perspective/migration/model/workbench-perspective-migration-v1.model.ts b/projects/scion/workbench/src/lib/perspective/migration/model/workbench-perspective-migration-v1.model.ts new file mode 100644 index 000000000..7cff9fe69 --- /dev/null +++ b/projects/scion/workbench/src/lib/perspective/migration/model/workbench-perspective-migration-v1.model.ts @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2018-2024 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 {Commands} from '../../../routing/routing.model'; + +export interface PerspectiveDataV1 { + initialWorkbenchGrid: string; + workbenchGrid: string; + viewOutlets: {[viewId: string]: Commands}; +} diff --git a/projects/scion/workbench/src/lib/perspective/migration/model/workbench-perspective-migration-v2.model.ts b/projects/scion/workbench/src/lib/perspective/migration/model/workbench-perspective-migration-v2.model.ts new file mode 100644 index 000000000..157ed1096 --- /dev/null +++ b/projects/scion/workbench/src/lib/perspective/migration/model/workbench-perspective-migration-v2.model.ts @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2018-2024 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 interface MPerspectiveLayoutV2 { + referenceLayout: { + workbenchGrid: string; + viewOutlets: string; + }; + userLayout: { + workbenchGrid: string; + viewOutlets: string; + }; +} diff --git a/projects/scion/workbench/src/lib/perspective/migration/workbench-perspective-migration-v2.service.ts b/projects/scion/workbench/src/lib/perspective/migration/workbench-perspective-migration-v2.service.ts new file mode 100644 index 000000000..9f0f12e5e --- /dev/null +++ b/projects/scion/workbench/src/lib/perspective/migration/workbench-perspective-migration-v2.service.ts @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2018-2024 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 {PerspectiveDataV1} from './model/workbench-perspective-migration-v1.model'; +import {MPerspectiveLayoutV2} from './model/workbench-perspective-migration-v2.model'; +import {Commands} from '../../routing/routing.model'; +import {WorkbenchMigration} from '../../migration/workbench-migration'; + +/** + * Migrates the perspective layout from version 1 to version 2. + * + * TODO [Angular 20] Remove migrator. + */ +@Injectable({providedIn: 'root'}) +export class WorkbenchPerspectiveMigrationV2 implements WorkbenchMigration { + + public migrate(json: string): string { + const perspectiveDataV1: PerspectiveDataV1 = JSON.parse(json); + const perspectiveLayoutV2: MPerspectiveLayoutV2 = { + userLayout: { + workbenchGrid: perspectiveDataV1.workbenchGrid, + viewOutlets: this.migrateViewOutlets(perspectiveDataV1.viewOutlets), + }, + referenceLayout: { + workbenchGrid: perspectiveDataV1.initialWorkbenchGrid, + viewOutlets: JSON.stringify({}), + }, + }; + return JSON.stringify(perspectiveLayoutV2); + } + + private migrateViewOutlets(viewOutlets: {[viewId: string]: Commands}): string { + return JSON.stringify(Object.fromEntries(Object.entries(viewOutlets) + .map(([viewId, commands]: [string, Commands]): [string, MUrlSegmentV2[]] => { + return [viewId, commandsToSegments(commands)]; + }), + )); + } +} + +function commandsToSegments(commands: Commands): MUrlSegmentV2[] { + const segments = new Array(); + + commands.forEach(command => { + if (typeof command === 'string') { + segments.push({path: command, parameters: {}}); + } + else { + segments.at(-1)!.parameters = command; + } + }); + + return segments; +} + +interface MUrlSegmentV2 { + path: string; + parameters: {[name: string]: string}; +} diff --git a/projects/scion/workbench/src/lib/perspective/workbench-grid-merger.service.spec.ts b/projects/scion/workbench/src/lib/perspective/workbench-grid-merger.service.spec.ts index 39b5faec9..5fdf4fc85 100644 --- a/projects/scion/workbench/src/lib/perspective/workbench-grid-merger.service.spec.ts +++ b/projects/scion/workbench/src/lib/perspective/workbench-grid-merger.service.spec.ts @@ -12,21 +12,15 @@ import {TestBed} from '@angular/core/testing'; import {WorkbenchTestingModule} from '../testing/workbench-testing.module'; import {RouterTestingModule} from '@angular/router/testing'; import {toEqualWorkbenchLayoutCustomMatcher} from '../testing/jasmine/matcher/to-equal-workbench-layout.matcher'; -import {ɵWorkbenchLayout} from '../layout/ɵworkbench-layout'; import {MAIN_AREA} from '../layout/workbench-layout'; import {WorkbenchGridMerger} from './workbench-grid-merger.service'; import {MPart, MTreeNode} from '../layout/workbench-layout.model'; import {expect} from '../testing/jasmine/matcher/custom-matchers.definition'; import {ɵWorkbenchLayoutFactory} from '../layout/ɵworkbench-layout.factory'; +import {segments} from '../testing/testing.util'; describe('WorkbenchGridMerger', () => { - let workbenchGridMerger: WorkbenchGridMerger; - - let local: ɵWorkbenchLayout; - let base: ɵWorkbenchLayout; - let remote: ɵWorkbenchLayout; - beforeEach(() => { jasmine.addMatchers(toEqualWorkbenchLayoutCustomMatcher); TestBed.configureTestingModule({ @@ -35,32 +29,32 @@ describe('WorkbenchGridMerger', () => { RouterTestingModule.withRoutes([]), ], }); + }); - workbenchGridMerger = TestBed.inject(WorkbenchGridMerger); - - local = TestBed.inject(ɵWorkbenchLayoutFactory) + it('should preserve local changes when no diff between base and remote', () => { + const base = TestBed.inject(ɵWorkbenchLayoutFactory) .addPart(MAIN_AREA) .addPart('topLeft', {relativeTo: MAIN_AREA, align: 'left', ratio: .25}) .addPart('bottomLeft', {relativeTo: 'topLeft', align: 'bottom', ratio: .5}) .addView('view.1', {partId: 'topLeft'}) .addView('view.2', {partId: 'topLeft'}) - .addView('view.3', {partId: 'topLeft'}) - .addView('view.4', {partId: 'bottomLeft'}) - .addView('view.5', {partId: 'bottomLeft'}) - .addView('view.6', {partId: 'bottomLeft'}); - base = local; - remote = local; - }); + .addView('view.3', {partId: 'bottomLeft'}) + .navigateView('view.1', ['path/to/view/1']) + .navigateView('view.2', ['path/to/view/2']) + .navigateView('view.3', [], {hint: 'hint-3'}); - it('should do nothing if no diff between remote and base', () => { - const mergedGrid = TestBed.inject(ɵWorkbenchLayoutFactory).create({ - workbenchGrid: workbenchGridMerger.merge({ - local: local.workbenchGrid, - base: base.workbenchGrid, - remote: remote.workbenchGrid, - }), + const mergedLayout = TestBed.inject(WorkbenchGridMerger).merge({ + local: base + .removeView('view.2') + .addView('view.100', {partId: 'topLeft'}) + .navigateView('view.100', ['path/to/view/100']) + .navigateView('view.1', ['PATH/TO/VIEW/1']), + base, + remote: base, }); - expect(mergedGrid).toEqualWorkbenchLayout({ + + // Expect local changes not to be discarded. + expect(mergedLayout).toEqualWorkbenchLayout({ workbenchGrid: { root: new MTreeNode({ direction: 'row', @@ -68,49 +62,67 @@ describe('WorkbenchGridMerger', () => { child1: new MTreeNode({ direction: 'column', ratio: .5, - child1: new MPart({id: 'topLeft', views: [{id: 'view.1'}, {id: 'view.2'}, {id: 'view.3'}]}), - child2: new MPart({id: 'bottomLeft', views: [{id: 'view.4'}, {id: 'view.5'}, {id: 'view.6'}]}), + child1: new MPart({ + id: 'topLeft', + views: [ + {id: 'view.1', navigation: {}}, // additional assertion below to assert the hint not to be present + {id: 'view.100', navigation: {}}, // additional assertion below to assert the hint not to be present + ], + }), + child2: new MPart({ + id: 'bottomLeft', + views: [ + {id: 'view.3', navigation: {hint: 'hint-3'}}, + ], + }), }), child2: new MPart({id: MAIN_AREA}), }), }, }); - }); - it('should add views that are added to the remote', () => { - const mergedGrid = TestBed.inject(ɵWorkbenchLayoutFactory).create({ - workbenchGrid: workbenchGridMerger.merge({ - local: local.workbenchGrid, - base: base.workbenchGrid, - remote: remote.addView('view.7', {partId: 'topLeft'}).workbenchGrid, - }), - }); - expect(mergedGrid).toEqualWorkbenchLayout({ - workbenchGrid: { - root: new MTreeNode({ - direction: 'row', - ratio: .25, - child1: new MTreeNode({ - direction: 'column', - ratio: .5, - child1: new MPart({id: 'topLeft', views: [{id: 'view.1'}, {id: 'view.2'}, {id: 'view.3'}, {id: 'view.7'}]}), - child2: new MPart({id: 'bottomLeft', views: [{id: 'view.4'}, {id: 'view.5'}, {id: 'view.6'}]}), - }), - child2: new MPart({id: MAIN_AREA}), - }), - }, + // Expect hint not to be present. + expect(mergedLayout.view({viewId: 'view.1'}).navigation).toEqual({}); + expect(mergedLayout.view({viewId: 'view.3'}).navigation).toEqual({hint: 'hint-3'}); + expect(mergedLayout.view({viewId: 'view.100'}).navigation).toEqual({}); + + expect(mergedLayout.viewOutlets()).toEqual({ + 'view.1': segments(['PATH/TO/VIEW/1']), + 'view.3': [], + 'view.100': segments(['path/to/view/100']), }); }); - it('should remove views that are removed from the remote', () => { - const mergedGrid = TestBed.inject(ɵWorkbenchLayoutFactory).create({ - workbenchGrid: workbenchGridMerger.merge({ - local: local.workbenchGrid, - base: base.workbenchGrid, - remote: remote.removeView('view.4').workbenchGrid, - }), + /** + * TODO [#452] The current implementation of 'WorkbenchGridMerger' discards local changes when a new layout is available. + */ + it('should discard local changes when diff between base and remote grids', () => { + const base = TestBed.inject(ɵWorkbenchLayoutFactory) + .addPart(MAIN_AREA) + .addPart('topLeft', {relativeTo: MAIN_AREA, align: 'left', ratio: .25}) + .addPart('bottomLeft', {relativeTo: 'topLeft', align: 'bottom', ratio: .5}) + .addView('view.1', {partId: 'topLeft'}) + .addView('view.2', {partId: 'topLeft'}) + .addView('view.3', {partId: 'bottomLeft'}) + .navigateView('view.1', ['path/to/view/1']) + .navigateView('view.2', ['path/to/view/2']) + .navigateView('view.3', [], {hint: 'hint-3'}); + + const mergedLayout = TestBed.inject(WorkbenchGridMerger).merge({ + local: base + .removeView('view.2') + .addView('view.100', {partId: 'topLeft'}) + .navigateView('view.100', ['path/to/view/100']) + .navigateView('view.3', ['path/to/view/3']), + base, + remote: base + .removeView('view.1') + .addView('view.100', {partId: 'bottomLeft'}) + .navigateView('view.100', ['PATH/TO/VIEW/100']), }); - expect(mergedGrid).toEqualWorkbenchLayout({ + + // Expect local changes to be discarded. + expect(mergedLayout).toEqualWorkbenchLayout({ workbenchGrid: { root: new MTreeNode({ direction: 'row', @@ -118,49 +130,58 @@ describe('WorkbenchGridMerger', () => { child1: new MTreeNode({ direction: 'column', ratio: .5, - child1: new MPart({id: 'topLeft', views: [{id: 'view.1'}, {id: 'view.2'}, {id: 'view.3'}]}), - child2: new MPart({id: 'bottomLeft', views: [{id: 'view.5'}, {id: 'view.6'}]}), + child1: new MPart({ + id: 'topLeft', + views: [ + {id: 'view.2', navigation: {}}, // additional assertion below to assert the hint not to be present + ], + }), + child2: new MPart({ + id: 'bottomLeft', + views: [ + {id: 'view.3', navigation: {hint: 'hint-3'}}, + {id: 'view.100', navigation: {}}, // additional assertion below to assert the hint not to be present + ], + }), }), child2: new MPart({id: MAIN_AREA}), }), }, }); - }); - it('should not remove views that are added to the local', () => { - const mergedGrid = TestBed.inject(ɵWorkbenchLayoutFactory).create({ - workbenchGrid: workbenchGridMerger.merge({ - local: local.addView('view.7', {partId: 'topLeft'}).workbenchGrid, - base: base.workbenchGrid, - remote: remote.workbenchGrid, - }), - }); - expect(mergedGrid).toEqualWorkbenchLayout({ - workbenchGrid: { - root: new MTreeNode({ - direction: 'row', - ratio: .25, - child1: new MTreeNode({ - direction: 'column', - ratio: .5, - child1: new MPart({id: 'topLeft', views: [{id: 'view.1'}, {id: 'view.2'}, {id: 'view.3'}, {id: 'view.7'}]}), - child2: new MPart({id: 'bottomLeft', views: [{id: 'view.4'}, {id: 'view.5'}, {id: 'view.6'}]}), - }), - child2: new MPart({id: MAIN_AREA}), - }), - }, + // Expect hint not to be present. + expect(mergedLayout.view({viewId: 'view.2'}).navigation).toEqual({}); + expect(mergedLayout.view({viewId: 'view.3'}).navigation).toEqual({hint: 'hint-3'}); + expect(mergedLayout.view({viewId: 'view.100'}).navigation).toEqual({}); + + expect(mergedLayout.viewOutlets()).toEqual({ + 'view.2': segments(['path/to/view/2']), + 'view.3': [], + 'view.100': segments(['PATH/TO/VIEW/100']), }); }); - it('should not re-add views that are removed from the local (1)', () => { - const mergedGrid = TestBed.inject(ɵWorkbenchLayoutFactory).create({ - workbenchGrid: workbenchGridMerger.merge({ - local: local.removeView('view.1').workbenchGrid, - base: base.workbenchGrid, - remote: remote.workbenchGrid, - }), + /** + * TODO [#452] The current implementation of 'WorkbenchGridMerger' discards local changes when a new layout is available. + */ + it('should discard local changes when diff between base and remote paths', () => { + const base = TestBed.inject(ɵWorkbenchLayoutFactory) + .addPart(MAIN_AREA) + .addPart('topLeft', {relativeTo: MAIN_AREA, align: 'left', ratio: .25}) + .addPart('bottomLeft', {relativeTo: 'topLeft', align: 'bottom', ratio: .5}) + .addView('view.1', {partId: 'topLeft'}) + .addView('view.2', {partId: 'bottomLeft'}) + .navigateView('view.1', ['path/to/view/1']) + .navigateView('view.2', ['path/to/view/2']); + + const mergedLayout = TestBed.inject(WorkbenchGridMerger).merge({ + local: base.navigateView('view.2', ['path/to/view/2a']), + base, + remote: base.navigateView('view.2', ['path/to/view/2b']), }); - expect(mergedGrid).toEqualWorkbenchLayout({ + + // Expect local changes to be discarded. + expect(mergedLayout).toEqualWorkbenchLayout({ workbenchGrid: { root: new MTreeNode({ direction: 'row', @@ -168,66 +189,55 @@ describe('WorkbenchGridMerger', () => { child1: new MTreeNode({ direction: 'column', ratio: .5, - child1: new MPart({id: 'topLeft', views: [{id: 'view.2'}, {id: 'view.3'}]}), - child2: new MPart({id: 'bottomLeft', views: [{id: 'view.4'}, {id: 'view.5'}, {id: 'view.6'}]}), + child1: new MPart({ + id: 'topLeft', + views: [ + {id: 'view.1', navigation: {}}, // additional assertion below to assert the hint not to be present + ], + }), + child2: new MPart({ + id: 'bottomLeft', + views: [ + {id: 'view.2', navigation: {}}, // additional assertion below to assert the hint not to be present + ], + }), }), child2: new MPart({id: MAIN_AREA}), }), }, }); - }); - it('should not re-add views that are removed from the local (2)', () => { - const mergedGrid = TestBed.inject(ɵWorkbenchLayoutFactory).create({ - workbenchGrid: workbenchGridMerger.merge({ - local: {root: new MPart({id: MAIN_AREA}), activePartId: MAIN_AREA}, - base: remote.workbenchGrid, - remote: remote.workbenchGrid, - }), - }); - expect(mergedGrid).toEqualWorkbenchLayout({ - workbenchGrid: { - root: new MPart({id: MAIN_AREA}), - }, - }); - }); + // Expect hint not to be present. + expect(mergedLayout.view({viewId: 'view.1'}).navigation).toEqual({}); + expect(mergedLayout.view({viewId: 'view.2'}).navigation).toEqual({}); - it('should not re-add views that are moved in the local', () => { - const mergedGrid = TestBed.inject(ɵWorkbenchLayoutFactory).create({ - workbenchGrid: workbenchGridMerger.merge({ - local: local.moveView('view.1', 'bottomLeft').workbenchGrid, - base: base.workbenchGrid, - remote: remote.workbenchGrid, - }), - }); - expect(mergedGrid).toEqualWorkbenchLayout({ - workbenchGrid: { - root: new MTreeNode({ - direction: 'row', - ratio: .25, - child1: new MTreeNode({ - direction: 'column', - ratio: .5, - child1: new MPart({id: 'topLeft', views: [{id: 'view.2'}, {id: 'view.3'}]}), - child2: new MPart({id: 'bottomLeft', views: [{id: 'view.4'}, {id: 'view.5'}, {id: 'view.6'}, {id: 'view.1'}]}), - }), - child2: new MPart({id: MAIN_AREA}), - }), - }, + expect(mergedLayout.viewOutlets()).toEqual({ + 'view.1': segments(['path/to/view/1']), + 'view.2': segments(['path/to/view/2b']), }); }); - it('should add views of new remote parts to "any" local part (1)', () => { - // This test is not very useful and should be removed when implemented issue #452. - // TODO [#452]: Support for merging newly added or moved parts into the user's layout) - const mergedGrid = TestBed.inject(ɵWorkbenchLayoutFactory).create({ - workbenchGrid: workbenchGridMerger.merge({ - local: local.workbenchGrid, - base: base.workbenchGrid, - remote: remote.addPart('right', {relativeTo: MAIN_AREA, align: 'right', ratio: .25}).addView('view.7', {partId: 'right'}).workbenchGrid, - }), + /** + * TODO [#452] The current implementation of 'WorkbenchGridMerger' discards local changes when a new layout is available. + */ + it('should discard local changes when diff between base and remote hints', () => { + const base = TestBed.inject(ɵWorkbenchLayoutFactory) + .addPart(MAIN_AREA) + .addPart('topLeft', {relativeTo: MAIN_AREA, align: 'left', ratio: .25}) + .addPart('bottomLeft', {relativeTo: 'topLeft', align: 'bottom', ratio: .5}) + .addView('view.1', {partId: 'topLeft'}) + .addView('view.2', {partId: 'bottomLeft'}) + .navigateView('view.1', ['path/to/view/1']) + .navigateView('view.2', [], {hint: 'hint-2'}); + + const mergedLayout = TestBed.inject(WorkbenchGridMerger).merge({ + local: base.navigateView('view.2', [], {hint: 'hint-2a'}), + base, + remote: base.navigateView('view.2', [], {hint: 'hint-2b'}), }); - expect(mergedGrid).toEqualWorkbenchLayout({ + + // Expect local changes to be discarded. + expect(mergedLayout).toEqualWorkbenchLayout({ workbenchGrid: { root: new MTreeNode({ direction: 'row', @@ -235,34 +245,31 @@ describe('WorkbenchGridMerger', () => { child1: new MTreeNode({ direction: 'column', ratio: .5, - child1: new MPart({id: 'topLeft', views: [{id: 'view.1'}, {id: 'view.2'}, {id: 'view.3'}, {id: 'view.7'}]}), - child2: new MPart({id: 'bottomLeft', views: [{id: 'view.4'}, {id: 'view.5'}, {id: 'view.6'}]}), + child1: new MPart({ + id: 'topLeft', + views: [ + {id: 'view.1', navigation: {}}, // additional assertion below to assert the hint not to be present + ], + }), + child2: new MPart({ + id: 'bottomLeft', + views: [ + {id: 'view.2', navigation: {hint: 'hint-2b'}}, + ], + }), }), child2: new MPart({id: MAIN_AREA}), }), }, }); - }); - it('should add views of new remote parts to "any" local part (2)', () => { - // This test is not very useful and should be removed when implemented issue #452. - // TODO [#452]: Support for merging newly added or moved parts into the user's layout) - const mergedGrid = TestBed.inject(ɵWorkbenchLayoutFactory).create({ - workbenchGrid: workbenchGridMerger.merge({ - local: {root: new MPart({id: MAIN_AREA}), activePartId: MAIN_AREA}, - base: {root: new MPart({id: MAIN_AREA}), activePartId: MAIN_AREA}, - remote: remote.workbenchGrid, - }), - }); - expect(mergedGrid).toEqualWorkbenchLayout({ - workbenchGrid: { - root: new MTreeNode({ - direction: 'row', - ratio: .5, - child1: new MPart({id: 'topLeft', views: [{id: 'view.1'}, {id: 'view.2'}, {id: 'view.3'}, {id: 'view.4'}, {id: 'view.5'}, {id: 'view.6'}]}), - child2: new MPart({id: MAIN_AREA}), - }), - }, + // Expect hint not to be present. + expect(mergedLayout.view({viewId: 'view.1'}).navigation).toEqual({}); + expect(mergedLayout.view({viewId: 'view.2'}).navigation).toEqual({hint: 'hint-2b'}); + + expect(mergedLayout.viewOutlets()).toEqual({ + 'view.1': segments(['path/to/view/1']), + 'view.2': [], }); }); }); diff --git a/projects/scion/workbench/src/lib/perspective/workbench-grid-merger.service.ts b/projects/scion/workbench/src/lib/perspective/workbench-grid-merger.service.ts index ab52ba380..d4907a0b8 100644 --- a/projects/scion/workbench/src/lib/perspective/workbench-grid-merger.service.ts +++ b/projects/scion/workbench/src/lib/perspective/workbench-grid-merger.service.ts @@ -7,68 +7,30 @@ * * SPDX-License-Identifier: EPL-2.0 */ -import {inject, Injectable, IterableChanges, IterableDiffers} from '@angular/core'; -import {MPartGrid, MView} from '../layout/workbench-layout.model'; +import {Injectable} from '@angular/core'; import {ɵWorkbenchLayout} from '../layout/ɵworkbench-layout'; -import {MAIN_AREA} from '../layout/workbench-layout'; -import {ɵWorkbenchLayoutFactory} from '../layout/ɵworkbench-layout.factory'; /** - * Performs a three-way merge of the changes from the local and remote grid, using the base grid (common ancestor) as the base of the merge operation. + * Performs a three-way merge of the local and remote layouts, using the base layout (common ancestor) as the base of the merge operation. + * + * TODO [#452] This implementation discards local changes when a new layout is available. */ @Injectable({providedIn: 'root'}) export class WorkbenchGridMerger { - private _differs = inject(IterableDiffers).find([]); - - constructor(private _workbenchLayoutFactory: ɵWorkbenchLayoutFactory, iterableDiffers: IterableDiffers) { - this._differs = iterableDiffers.find([]); - } - - /** - * Performs a merge of given local and remote grids, using the base grid as the common ancestor. - */ - public merge(grids: {local: MPartGrid; remote: MPartGrid; base: MPartGrid}): MPartGrid { - const localLayout = this._workbenchLayoutFactory.create({workbenchGrid: grids.local}); - const baseLayout = this._workbenchLayoutFactory.create({workbenchGrid: grids.base}); - const remoteLayout = this._workbenchLayoutFactory.create({workbenchGrid: grids.remote}); - - let mergedLayout: ɵWorkbenchLayout = localLayout; - const viewsChanges = this.viewsDiff(baseLayout, remoteLayout); - - viewsChanges?.forEachAddedItem(({item: addedView}) => { - // If the local grid contains the part, add the view to that part. - const part = remoteLayout.part({by: {viewId: addedView.id}}); - if (mergedLayout.hasPart(part.id)) { - mergedLayout = mergedLayout.addView(addedView.id, {partId: part.id}); - } - // If the local grid does not contain the part, add the part to an existing part or create a new part. - else { - const existingPart = mergedLayout.parts({grid: 'workbench'}).filter(part => part.id !== MAIN_AREA)[0]; - if (existingPart) { - mergedLayout = mergedLayout.addView(addedView.id, {partId: existingPart.id}); - } - else { - mergedLayout = mergedLayout - .addPart(part.id, {align: 'left'}) - .addView(addedView.id, {partId: part.id}); - } - } - }); - - viewsChanges?.forEachRemovedItem(({item: removedView}) => { - mergedLayout = mergedLayout.removeView(removedView.id); - }); - - return mergedLayout.workbenchGrid; - } - /** - * Computes the diff of views added or removed in layout 2. + * Performs a merge of given local and remote layouts, using the base layout as the common ancestor. */ - private viewsDiff(layout1: ɵWorkbenchLayout, layout2: ɵWorkbenchLayout): IterableChanges | null { - const differ = this._differs.create((index, view) => view.id); - differ.diff(layout1.views({grid: 'workbench'})); - return differ.diff(layout2.views({grid: 'workbench'})); + public merge(grids: {local: ɵWorkbenchLayout; remote: ɵWorkbenchLayout; base: ɵWorkbenchLayout}): ɵWorkbenchLayout { + const serializedBaseLayout = grids.base.serialize(); + const serializedRemoteLayout = grids.remote.serialize(); + + if (serializedBaseLayout.workbenchGrid !== serializedRemoteLayout.workbenchGrid) { + return grids.remote; + } + if (serializedBaseLayout.workbenchViewOutlets !== serializedRemoteLayout.workbenchViewOutlets) { + return grids.remote; + } + return grids.local; } } diff --git a/projects/scion/workbench/src/lib/perspective/workbench-perspective-storage.service.ts b/projects/scion/workbench/src/lib/perspective/workbench-perspective-storage.service.ts index 3c54a9037..13972c9db 100644 --- a/projects/scion/workbench/src/lib/perspective/workbench-perspective-storage.service.ts +++ b/projects/scion/workbench/src/lib/perspective/workbench-perspective-storage.service.ts @@ -10,7 +10,9 @@ import {Injectable} from '@angular/core'; import {WorkbenchStorage} from '../storage/workbench-storage'; -import {Commands} from '../routing/routing.model'; +import {MPerspectiveLayout} from './workbench-perspective.model'; +import {WorkbenchPerspectiveSerializer} from './workench-perspective-serializer.service'; +import {Logger} from '../logging'; /** * Provides API to read/write perspective data from/to {@link WorkbenchStorage}. @@ -18,29 +20,32 @@ import {Commands} from '../routing/routing.model'; @Injectable({providedIn: 'root'}) export class WorkbenchPerspectiveStorageService { - constructor(private _storage: WorkbenchStorage) { + constructor(private _storage: WorkbenchStorage, + private _workbenchPerspectiveSerializer: WorkbenchPerspectiveSerializer, + private _logger: Logger) { } /** - * Reads perspective data for a given perspective from storage. + * Reads the layout of given perspective from storage, applying necessary migrations if the serialized layout is outdated. */ - public async loadPerspectiveData(perspectiveId: string): Promise { - const storageKey = storageKeys.perspectiveData(perspectiveId); + public async loadPerspectiveLayout(perspectiveId: string): Promise { + const storageKey = storageKeys.perspectiveLayout(perspectiveId); const serialized = await this._storage.load(storageKey); - if (!serialized?.length) { + try { + return this._workbenchPerspectiveSerializer.deserialize(serialized); + } + catch (error) { + this._logger.error(`[SerializeError] Failed to deserialize perspective '${perspectiveId}'. Please clear your browser storage and reload the application.`, error); return null; } - const json = window.atob(serialized); - return JSON.parse(json); } /** - * Writes perspective data for a given perspective to storage. + * Writes the layout of a perspective to storage. */ - public async storePerspectiveData(perspectiveId: string, data: PerspectiveData): Promise { - const storageKey = storageKeys.perspectiveData(perspectiveId); - const json = JSON.stringify(data); - const serialized = window.btoa(json); + public async storePerspectiveLayout(perspectiveId: string, data: MPerspectiveLayout): Promise { + const serialized = this._workbenchPerspectiveSerializer.serialize(data); + const storageKey = storageKeys.perspectiveLayout(perspectiveId); await this._storage.store(storageKey, serialized); } @@ -63,27 +68,6 @@ export class WorkbenchPerspectiveStorageService { * Represents keys to associate data in the storage. */ const storageKeys = { - perspectiveData: (perspectiveId: string): string => `scion.workbench.perspectives.${perspectiveId}`, + perspectiveLayout: (perspectiveId: string): string => `scion.workbench.perspectives.${perspectiveId}`, activePerspectiveId: 'scion.workbench.perspective', }; - -/** - * Perspective data stored in persistent storage. - */ -export interface PerspectiveData { - /** - * The actual workbench grid. - * - * When activated the perspective for the first time, this grid is identical to the {@link initialWorkbenchGrid}, - * but changes when the user customizes the layout. - */ - workbenchGrid: string | null; - /** - * The initial definition used to create the workbench grid. - */ - initialWorkbenchGrid: string | null; - /** - * Commands of views contained in the workbench grid. - */ - viewOutlets: {[viewId: string]: Commands}; -} diff --git a/projects/scion/workbench/src/lib/perspective/workbench-perspective-storage.spec.ts b/projects/scion/workbench/src/lib/perspective/workbench-perspective-storage.spec.ts index 90f33a142..b2e8c072e 100644 --- a/projects/scion/workbench/src/lib/perspective/workbench-perspective-storage.spec.ts +++ b/projects/scion/workbench/src/lib/perspective/workbench-perspective-storage.spec.ts @@ -20,9 +20,8 @@ import {RouterTestingModule} from '@angular/router/testing'; import {WorkbenchLayoutComponent} from '../layout/workbench-layout.component'; import {MPart, MTreeNode} from '../layout/workbench-layout.model'; import {WorkbenchService} from '../workbench.service'; - -import {PerspectiveData} from './workbench-perspective-storage.service'; import {ɵWorkbenchLayoutFactory} from '../layout/ɵworkbench-layout.factory'; +import {WorkbenchPerspectiveStorageService} from './workbench-perspective-storage.service'; describe('WorkbenchPerspectiveStorage', () => { @@ -61,7 +60,7 @@ describe('WorkbenchPerspectiveStorage', () => { await waitUntilStable(); // THEN: Expect the layout to be stored. - expect(deserializePerspectiveData(localStorage.getItem('scion.workbench.perspectives.perspective'))).toEqualWorkbenchLayout({ + expect(await loadPerspectiveLayoutFromStorage('perspective')).toEqualWorkbenchLayout({ workbenchGrid: { root: new MTreeNode({ child1: new MTreeNode({ @@ -78,7 +77,7 @@ describe('WorkbenchPerspectiveStorage', () => { await waitUntilStable(); // THEN: Expect the layout to be stored. - expect(deserializePerspectiveData(localStorage.getItem('scion.workbench.perspectives.perspective'))).toEqualWorkbenchLayout({ + expect(await loadPerspectiveLayoutFromStorage('perspective')).toEqualWorkbenchLayout({ workbenchGrid: { root: new MTreeNode({ child1: new MTreeNode({ @@ -191,7 +190,7 @@ describe('WorkbenchPerspectiveStorage', () => { startup: {launcher: 'APP_INITIALIZER'}, }), RouterTestingModule.withRoutes([ - {path: 'view', component: TestComponent}, + {path: 'path/to/view', component: TestComponent}, ]), ], }); @@ -199,11 +198,11 @@ describe('WorkbenchPerspectiveStorage', () => { await waitForInitialWorkbenchLayout(); // WHEN: Opening view.1 in perspective-1 - await TestBed.inject(WorkbenchRouter).navigate(['view'], {blankPartId: 'left', target: 'view.1'}); + await TestBed.inject(WorkbenchRouter).navigate(['path/to/view'], {blankPartId: 'left', target: 'view.1'}); await waitUntilStable(); // THEN: Expect the layout of perspective-1 to be stored. - expect(deserializePerspectiveData(localStorage.getItem('scion.workbench.perspectives.perspective-1'))).toEqualWorkbenchLayout({ + expect(await loadPerspectiveLayoutFromStorage('perspective-1')).toEqualWorkbenchLayout({ workbenchGrid: { root: new MTreeNode({ child1: new MPart({id: 'left', views: [{id: 'view.1'}], activeViewId: 'view.1'}), @@ -212,7 +211,7 @@ describe('WorkbenchPerspectiveStorage', () => { }, }); // THEN: Expect the layout of perspective-2 not to be stored. - expect(deserializePerspectiveData(localStorage.getItem('scion.workbench.perspectives.perspective-2'))).toBeNull(); + expect(await loadPerspectiveLayoutFromStorage('perspective-2')).toBeNull(); // Switch to perspective-2. await TestBed.inject(WorkbenchService).switchPerspective('perspective-2'); @@ -222,11 +221,11 @@ describe('WorkbenchPerspectiveStorage', () => { localStorage.clear(); // WHEN: Opening view.1 in perspective-2 - await TestBed.inject(WorkbenchRouter).navigate(['view'], {blankPartId: 'left', target: 'view.1'}); + await TestBed.inject(WorkbenchRouter).navigate(['path/to/view'], {blankPartId: 'left', target: 'view.1'}); await waitUntilStable(); // THEN: Expect the layout of perspective-2 to be stored. - expect(deserializePerspectiveData(localStorage.getItem('scion.workbench.perspectives.perspective-2'))).toEqualWorkbenchLayout({ + expect(await loadPerspectiveLayoutFromStorage('perspective-2')).toEqualWorkbenchLayout({ workbenchGrid: { root: new MTreeNode({ child1: new MPart({id: 'left', views: [{id: 'view.1'}], activeViewId: 'view.1'}), @@ -235,17 +234,18 @@ describe('WorkbenchPerspectiveStorage', () => { }, }); // THEN: Expect the layout of perspective-1 not to be stored. - expect(deserializePerspectiveData(localStorage.getItem('scion.workbench.perspectives.perspective-1'))).toBeNull(); + expect(await loadPerspectiveLayoutFromStorage('perspective-1')).toBeNull(); }); }); -/** - * Deserializes given perspective data. - */ -function deserializePerspectiveData(serializedPerspectiveData: string | null): WorkbenchLayout | null { - if (!serializedPerspectiveData) { +async function loadPerspectiveLayoutFromStorage(perspectiveId: string): Promise { + const perspectiveLayout = await TestBed.inject(WorkbenchPerspectiveStorageService).loadPerspectiveLayout(perspectiveId); + if (!perspectiveLayout) { return null; } - const perspectiveData: PerspectiveData = JSON.parse(window.atob(serializedPerspectiveData)); - return TestBed.inject(ɵWorkbenchLayoutFactory).create({workbenchGrid: perspectiveData.workbenchGrid}); + + return TestBed.inject(ɵWorkbenchLayoutFactory).create({ + workbenchGrid: perspectiveLayout.userLayout.workbenchGrid, + viewOutlets: perspectiveLayout.userLayout.viewOutlets, + }); } diff --git a/projects/scion/workbench/src/lib/perspective/workbench-perspective-view-conflict-resolver.service.ts b/projects/scion/workbench/src/lib/perspective/workbench-perspective-view-conflict-resolver.service.ts index 62c75d555..743d4f87a 100644 --- a/projects/scion/workbench/src/lib/perspective/workbench-perspective-view-conflict-resolver.service.ts +++ b/projects/scion/workbench/src/lib/perspective/workbench-perspective-view-conflict-resolver.service.ts @@ -8,61 +8,43 @@ * SPDX-License-Identifier: EPL-2.0 */ import {Injectable} from '@angular/core'; -import {MPartGrid} from '../layout/workbench-layout.model'; -import {Arrays, Dictionaries, Maps} from '@scion/toolkit/util'; -import {ɵWorkbenchLayoutFactory} from '../layout/ɵworkbench-layout.factory'; -import {RouterUtils} from '../routing/router.util'; -import {Commands} from '../routing/routing.model'; +import {Arrays} from '@scion/toolkit/util'; +import {ViewId} from '../view/workbench-view.model'; +import {WorkbenchLayouts} from '../layout/workbench-layouts.util'; +import {ɵWorkbenchLayout} from '../layout/ɵworkbench-layout'; /** - * Detects and resolves name conflicts of view names, that may occur when switching between perspectives. + * Detects and resolves conflicting view ids, that may occur when switching between perspectives. */ @Injectable({providedIn: 'root'}) export class WorkbenchPerspectiveViewConflictResolver { - constructor(private _workbenchLayoutFactory: ɵWorkbenchLayoutFactory) { - } - /** - * Detects and resolves name clashes between views defined by the perspective and views in the main area. - * - * Conflict resolution for views defined by the perspective: - * - Assigns views a new identity if target of a primary route. The id of such views begin with the view prefix. - * - Removes views if target of a secondary route. The id of such views does not begin with the view prefix. + * Detects and resolves id clashes between views defined by the perspective and views contained in the main area, + * assigning views of the perspective a new identity. * - * @param mainAreaGrid - The grid of the main area. - * @param perspective - The workbench grid and views of the perspective. - * @return workbench grid and views of the provided perspective with conflicts resolved, if any. + * @param currentLayout - The current workbench layout. + * @param perspectiveLayout - The layout of the perspective to activate. + * @return layout of the perspective with conflicts resolved. */ - public resolve(mainAreaGrid: MPartGrid, perspective: {workbenchGrid: MPartGrid; viewOutlets: {[viewId: string]: Commands}}): {workbenchGrid: MPartGrid; viewOutlets: {[viewId: string]: Commands}} { - const conflictingLayout = this._workbenchLayoutFactory.create({mainAreaGrid, workbenchGrid: perspective.workbenchGrid}); - const conflictingViewIds = Arrays.intersect( - conflictingLayout.views({grid: 'workbench'}).map(view => view.id), - conflictingLayout.views({grid: 'mainArea'}).map(view => view.id), - ); + public resolve(currentLayout: ɵWorkbenchLayout, perspectiveLayout: ɵWorkbenchLayout): ɵWorkbenchLayout { + const perspectiveViewIds = perspectiveLayout.views({grid: 'workbench'}).map(view => view.id); + const mainAreaViewIds = currentLayout.views({grid: 'mainArea'}).map(view => view.id); + + // Test if there are conflicts. + const conflictingViewIds = Arrays.intersect(perspectiveViewIds, mainAreaViewIds); if (!conflictingViewIds.length) { - return perspective; + return perspectiveLayout; } - const viewOutlets = Maps.coerce(perspective.viewOutlets); - const resolvedLayout = conflictingViewIds.reduce((layout, conflictingViewId) => { - if (RouterUtils.isPrimaryRouteTarget(conflictingViewId)) { - const newViewId = layout.computeNextViewId(); - const path = viewOutlets.get(conflictingViewId)!; - viewOutlets.delete(conflictingViewId); - - // Rename view in the perspective grid. - viewOutlets.set(newViewId, path); - return layout.renameView(conflictingViewId, newViewId, {grid: 'workbench'}); - } - else { - return layout.removeView(conflictingViewId, {grid: 'workbench'}); - } - }, conflictingLayout); + // Rename conflicting views. + const usedViewIds = new Set(perspectiveViewIds.concat(mainAreaViewIds)); + conflictingViewIds.forEach(conflictingViewId => { + const newViewId = WorkbenchLayouts.computeNextViewId(usedViewIds); + perspectiveLayout = perspectiveLayout.renameView(conflictingViewId, newViewId); + usedViewIds.add(newViewId); + }); - return { - workbenchGrid: resolvedLayout.workbenchGrid, - viewOutlets: Dictionaries.coerce(viewOutlets), - }; + return perspectiveLayout; } } diff --git a/projects/scion/workbench/src/lib/perspective/workbench-perspective.model.ts b/projects/scion/workbench/src/lib/perspective/workbench-perspective.model.ts index db0875900..22808acd4 100644 --- a/projects/scion/workbench/src/lib/perspective/workbench-perspective.model.ts +++ b/projects/scion/workbench/src/lib/perspective/workbench-perspective.model.ts @@ -72,6 +72,8 @@ export interface WorkbenchPerspectiveDefinition { id: string; /** * Function to create the initial layout for the perspective. The function can call `inject` to get any required dependencies. + * + * See {@link WorkbenchLayoutFn} for more information and an example. */ layout: WorkbenchLayoutFn; /** @@ -91,27 +93,95 @@ export interface WorkbenchPerspectiveDefinition { /** * Signature of a function to provide a workbench layout. * - * The function is passed a factory to create the layout. The layout has methods to modify it. - * Each modification creates a new layout instance that can be used for further modifications. - * - * The layout is an immutable object, i.e., modifications have no side effects. + * The workbench will invoke this function with a factory to create the layout. The layout is immutable, so each modification creates a new instance. + * Use the instance for further modifications and finally return it. * * The function can call `inject` to get any required dependencies. * + * ## Workbench Layout + * The workbench layout is a grid of parts. Parts are aligned relative to each other. A part is a stack of views. Content is displayed in views. + * + * The layout can be divided into a main and a peripheral area, with the main area as the primary place for opening views. + * The peripheral area arranges parts around the main area to provide navigation or context-sensitive assistance to support + * the user's workflow. Defining a main area is optional and recommended for applications requiring a dedicated and maximizable + * area for user interaction. + * + * ## Steps to create the layout + * Start by adding the first part. From there, you can gradually add more parts and align them relative to each other. + * Next, add views to the layout, specifying to which part to add the views. + * The final step is to navigate the views. A view can be navigated to any route. + * + * To avoid cluttering the initial URL, we recommend navigating the views of the initial layout to empty path routes and using a navigation hint to differentiate. + * + * ## Example + * The following example defines a layout with a main area and three parts in the peripheral area: + * + * ```plain + * +--------+----------------+ + * | top | main area | + * | left | | + * |--------+ | + * | bottom | | + * | left | | + * +--------+----------------+ + * | bottom | + * +-------------------------+ + * ``` + * * ```ts * function defineLayout(factory: WorkbenchLayoutFactory): WorkbenchLayout { - * return factory.addPart(MAIN_AREA) - * .addPart('topLeft', {align: 'left', ratio: .25}) - * .addPart('bottomLeft', {relativeTo: 'topLeft', align: 'bottom', ratio: .5}) - * .addPart('bottom', {align: 'bottom', ratio: .3}) - * .addView('navigator', {partId: 'topLeft', activateView: true}) - * .addView('explorer', {partId: 'topLeft'}) - * .addView('repositories', {partId: 'bottomLeft', activateView: true}) - * .addView('console', {partId: 'bottom', activateView: true}) - * .addView('problems', {partId: 'bottom'}) - * .addView('search', {partId: 'bottom'}); + * return factory + * // Add parts to the layout. + * .addPart(MAIN_AREA) + * .addPart('topLeft', {relativeTo: MAIN_AREA, align: 'left', ratio: .25}) + * .addPart('bottomLeft', {relativeTo: 'topLeft', align: 'bottom', ratio: .5}) + * .addPart('bottom', {align: 'bottom', ratio: .3}) + * + * // Add views to the layout. + * .addView('navigator', {partId: 'topLeft'}) + * .addView('explorer', {partId: 'bottomLeft'}) + * .addView('console', {partId: 'bottom'}) + * .addView('problems', {partId: 'bottom'}) + * .addView('search', {partId: 'bottom'}) + * + * // Navigate views. + * .navigateView('navigator', ['path/to/navigator']) + * .navigateView('explorer', ['path/to/explorer']) + * .navigateView('console', [], {hint: 'console'}) // Set hint to differentiate between routes with an empty path. + * .navigateView('problems', [], {hint: 'problems'}) // Set hint to differentiate between routes with an empty path. + * .navigateView('search', ['path/to/search']) + * + * // Decide which views to activate. + * .activateView('navigator') + * .activateView('explorer') + * .activateView('console'); * } * ``` + * + * The layout requires the following routes. + * + * ```ts + * import {bootstrapApplication} from '@angular/platform-browser'; + * import {provideRouter} from '@angular/router'; + * import {canMatchWorkbenchView} from '@scion/workbench'; + * + * bootstrapApplication(AppComponent, { + * providers: [ + * provideRouter([ + * // Navigator View + * {path: 'path/to/navigator', loadComponent: () => import('./navigator/navigator.component')}, + * // Explorer View + * {path: 'path/to/explorer', loadComponent: () => import('./explorer/explorer.component')}, + * // Search view + * {path: 'path/to/search', loadComponent: () => import('./search/search.component')}, + * // Console view + * {path: '', canMatch: [canMatchWorkbenchView('console')], loadComponent: () => import('./console/console.component')}, + * // Problems view + * {path: '', canMatch: [canMatchWorkbenchView('problems')], loadComponent: () => import('./problems/problems.component')}, + * ]), + * ], + * }); + * ``` */ export type WorkbenchLayoutFn = (factory: WorkbenchLayoutFactory) => Promise | WorkbenchLayout; @@ -121,3 +191,43 @@ export type WorkbenchLayoutFn = (factory: WorkbenchLayoutFactory) => Promise Promise | WorkbenchPerspective | null; + +/** + * Contains different versions of a perspective layout. + * + * The M-prefix indicates this object is a model object that is serialized and stored, requiring migration on breaking change. + * + * @see WORKBENCH_PERSPECTIVE_MODEL_VERSION + */ +export interface MPerspectiveLayout { + /** + * Layout before any user personalization (initial layout). + */ + referenceLayout: { + /** + * @see WorkbenchLayoutSerializer.serializeGrid + * @see WorkbenchLayoutSerializer.deserializeGrid + */ + workbenchGrid: string; + /** + * @see WorkbenchLayoutSerializer.serializeViewOutlets + * @see WorkbenchLayoutSerializer.deserializeViewOutlets + */ + viewOutlets: string; + }; + /** + * Layout personalized by the user. + */ + userLayout: { + /** + * @see WorkbenchLayoutSerializer.serializeGrid + * @see WorkbenchLayoutSerializer.deserializeGrid + */ + workbenchGrid: string; + /** + * @see WorkbenchLayoutSerializer.serializeViewOutlets + * @see WorkbenchLayoutSerializer.deserializeViewOutlets + */ + viewOutlets: string; + }; +} diff --git a/projects/scion/workbench/src/lib/perspective/workbench-perspective.service.ts b/projects/scion/workbench/src/lib/perspective/workbench-perspective.service.ts index f0605096a..ad39a4d02 100644 --- a/projects/scion/workbench/src/lib/perspective/workbench-perspective.service.ts +++ b/projects/scion/workbench/src/lib/perspective/workbench-perspective.service.ts @@ -90,13 +90,18 @@ export class WorkbenchPerspectiveService implements WorkbenchInitializer { /** * Switches to the specified perspective. The main area will not change, if any. + * + * @param id - Specifies the id of the perspective to activate. + * @param options - Controls activation of the perspective. + * @param options.storePerspectiveAsActive - Controls if to store the perspective as the active perspective. Default is `true`. + * @return `true` if activated the perspective, otherwise `false`. */ - public async switchPerspective(id: string): Promise { + public async switchPerspective(id: string, options?: {storePerspectiveAsActive?: boolean}): Promise { if (this.activePerspective?.id === id) { return true; } const activated = await this._perspectiveRegistry.get(id).activate(); - if (activated) { + if (activated && (options?.storePerspectiveAsActive ?? true)) { await this._workbenchPerspectiveStorageService.storeActivePerspectiveId(id); window.name = generatePerspectiveWindowName(id); } @@ -144,7 +149,7 @@ export class WorkbenchPerspectiveService implements WorkbenchInitializer { // Select initial perspective. if (initialPerspectiveId) { - await this.switchPerspective(initialPerspectiveId); + await this.switchPerspective(initialPerspectiveId, {storePerspectiveAsActive: false}); } } diff --git a/projects/scion/workbench/src/lib/perspective/workbench-perspective.spec.ts b/projects/scion/workbench/src/lib/perspective/workbench-perspective.spec.ts index d664da061..0309b59b4 100644 --- a/projects/scion/workbench/src/lib/perspective/workbench-perspective.spec.ts +++ b/projects/scion/workbench/src/lib/perspective/workbench-perspective.spec.ts @@ -25,6 +25,7 @@ import {toEqualWorkbenchLayoutCustomMatcher} from '../testing/jasmine/matcher/to import {MAIN_AREA} from '../layout/workbench-layout'; import {WorkbenchLayoutFactory} from '../layout/workbench-layout.factory'; import {WorkbenchRouter} from '../routing/workbench-router.service'; +import {canMatchWorkbenchView} from '../view/workbench-view-route-guards'; describe('Workbench Perspective', () => { @@ -83,12 +84,13 @@ describe('Workbench Perspective', () => { layout: factory => factory .addPart(MAIN_AREA) .addPart('left', {relativeTo: MAIN_AREA, align: 'left', ratio: .25}) - .addView('navigator', {partId: 'left', activateView: true}), + .addView('view.101', {partId: 'left', activateView: true}) + .navigateView('view.101', [], {hint: 'navigator'}), }), RouterTestingModule.withRoutes([ { path: '', - outlet: 'navigator', + canMatch: [canMatchWorkbenchView('navigator')], component: TestComponent, canActivate: [ () => of(true).pipe(delay(1000)), // simulate slow initial navigation @@ -104,7 +106,7 @@ describe('Workbench Perspective', () => { expect(fixture.debugElement.query(By.directive(WorkbenchLayoutComponent))).toEqualWorkbenchLayout({ workbenchGrid: { root: new MTreeNode({ - child1: new MPart({id: 'left', views: [{id: 'navigator'}], activeViewId: 'navigator'}), + child1: new MPart({id: 'left', views: [{id: 'view.101'}], activeViewId: 'view.101'}), child2: new MPart({id: MAIN_AREA}), direction: 'row', ratio: .25, @@ -113,20 +115,22 @@ describe('Workbench Perspective', () => { }); }); - it('should open an unnamed view in the active part of perspective without main area', async () => { + it('should open a empty-path view in the active part of perspective without main area', async () => { TestBed.configureTestingModule({ imports: [ WorkbenchTestingModule.forTest({ layout: factory => factory .addPart('left') .addPart('right', {align: 'right'}) - .addView('list', {partId: 'left', activateView: true}) - .addView('overview', {partId: 'right', activateView: true}) + .addView('view.101', {partId: 'left', activateView: true}) + .addView('view.102', {partId: 'right', activateView: true}) + .navigateView('view.101', [], {hint: 'list'}) + .navigateView('view.102', [], {hint: 'overview'}) .activatePart('right'), }), RouterTestingModule.withRoutes([ - {path: '', outlet: 'list', component: TestComponent}, - {path: '', outlet: 'overview', component: TestComponent}, + {path: '', canMatch: [canMatchWorkbenchView('list')], component: TestComponent}, + {path: '', canMatch: [canMatchWorkbenchView('overview')], component: TestComponent}, {path: 'details/:id', component: TestComponent}, ]), ], @@ -138,8 +142,8 @@ describe('Workbench Perspective', () => { expect(fixture.debugElement.query(By.directive(WorkbenchLayoutComponent))).toEqualWorkbenchLayout({ workbenchGrid: { root: new MTreeNode({ - child1: new MPart({id: 'left', views: [{id: 'list'}], activeViewId: 'list'}), - child2: new MPart({id: 'right', views: [{id: 'overview'}], activeViewId: 'overview'}), + child1: new MPart({id: 'left', views: [{id: 'view.101'}], activeViewId: 'view.101'}), + child2: new MPart({id: 'right', views: [{id: 'view.102'}], activeViewId: 'view.102'}), direction: 'row', ratio: .5, }), @@ -150,12 +154,12 @@ describe('Workbench Perspective', () => { await TestBed.inject(WorkbenchRouter).navigate(['details/1']); await waitUntilStable(); - // unnamed view should be opened in the active part (right) of the workbench grid + // empty-path view should be opened in the active part (right) of the workbench grid expect(fixture.debugElement.query(By.directive(WorkbenchLayoutComponent))).toEqualWorkbenchLayout({ workbenchGrid: { root: new MTreeNode({ - child1: new MPart({id: 'left', views: [{id: 'list'}], activeViewId: 'list'}), - child2: new MPart({id: 'right', views: [{id: 'overview'}, {id: 'view.1'}], activeViewId: 'view.1'}), + child1: new MPart({id: 'left', views: [{id: 'view.101'}], activeViewId: 'view.101'}), + child2: new MPart({id: 'right', views: [{id: 'view.102'}, {id: 'view.1'}], activeViewId: 'view.1'}), direction: 'row', ratio: .5, }), diff --git a/projects/scion/workbench/src/lib/perspective/workench-perspective-serializer.service.ts b/projects/scion/workbench/src/lib/perspective/workench-perspective-serializer.service.ts new file mode 100644 index 000000000..1abdb9269 --- /dev/null +++ b/projects/scion/workbench/src/lib/perspective/workench-perspective-serializer.service.ts @@ -0,0 +1,62 @@ +/* + * 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} from '@angular/core'; +import {MPerspectiveLayout} from '../perspective/workbench-perspective.model'; +import {WorkbenchMigrator} from '../migration/workbench-migrator'; +import {WorkbenchPerspectiveMigrationV2} from './migration/workbench-perspective-migration-v2.service'; + +/** + * Serializes and deserializes a base64-encoded JSON into a {@link MPerspectiveLayout}. + */ +@Injectable({providedIn: 'root'}) +export class WorkbenchPerspectiveSerializer { + + private _workbenchPerspectiveMigrator = new WorkbenchMigrator() + .registerMigration(1, inject(WorkbenchPerspectiveMigrationV2)); + + /** + * Serializes the given perspective layout into a URL-safe base64 string. + */ + public serialize(data: MPerspectiveLayout): string { + const json = JSON.stringify(data); + return window.btoa(`${json}${VERSION_SEPARATOR}${WORKBENCH_PERSPECTIVE_LAYOUT_VERSION}`); + } + + /** + * Deserializes the given base64-serialized perspective layout, applying necessary migrations if the serialized layout is outdated. + */ + public deserialize(serialized: string | null | undefined): MPerspectiveLayout | null { + if (!serialized?.length) { + return null; + } + + const [json, version] = window.atob(serialized).split(VERSION_SEPARATOR, 2); + const serializedVersion = Number.isNaN(Number(version)) ? 1 : Number(version); + const migrated = this._workbenchPerspectiveMigrator.migrate(json, {from: serializedVersion, to: WORKBENCH_PERSPECTIVE_LAYOUT_VERSION}); + return JSON.parse(migrated); + } +} + +/** + * Represents the current version of the workbench perspective layout. + * + * Increment this version and write a migrator when introducting a breaking change. + * + * @see WorkbenchMigrator + */ +export const WORKBENCH_PERSPECTIVE_LAYOUT_VERSION = 2; + +/** + * Separates the serialized JSON model and its version in the base64-encoded string. + * + * Format: // + */ +const VERSION_SEPARATOR = '//'; diff --git "a/projects/scion/workbench/src/lib/perspective/\311\265workbench-perspective.model.ts" "b/projects/scion/workbench/src/lib/perspective/\311\265workbench-perspective.model.ts" index 4a4d12c21..425809ae5 100644 --- "a/projects/scion/workbench/src/lib/perspective/\311\265workbench-perspective.model.ts" +++ "b/projects/scion/workbench/src/lib/perspective/\311\265workbench-perspective.model.ts" @@ -8,22 +8,21 @@ * SPDX-License-Identifier: EPL-2.0 */ import {ɵWorkbenchLayoutFactory} from '../layout/ɵworkbench-layout.factory'; -import {MPartGrid} from '../layout/workbench-layout.model'; import {EnvironmentInjector, inject, InjectionToken, runInInjectionContext} from '@angular/core'; import {WorkbenchLayoutFn, WorkbenchPerspective, WorkbenchPerspectiveDefinition} from './workbench-perspective.model'; -import {BehaviorSubject, Observable, Subject} from 'rxjs'; -import {WorkbenchNavigation, WorkbenchRouter} from '../routing/workbench-router.service'; +import {BehaviorSubject, Observable} from 'rxjs'; +import {WorkbenchRouter} from '../routing/workbench-router.service'; import {ɵWorkbenchLayout} from '../layout/ɵworkbench-layout'; import {WorkbenchGridMerger} from './workbench-grid-merger.service'; import {WorkbenchPerspectiveStorageService} from './workbench-perspective-storage.service'; import {WorkbenchLayoutService} from '../layout/workbench-layout.service'; -import {filter, map, takeUntil} from 'rxjs/operators'; -import {WorkbenchLayoutSerializer} from '../layout/workench-layout-serializer.service'; -import {RouterUtils} from '../routing/router.util'; -import {Router} from '@angular/router'; +import {filter, map} from 'rxjs/operators'; import {WorkbenchPerspectiveViewConflictResolver} from './workbench-perspective-view-conflict-resolver.service'; import {serializeExecution} from '../common/operators'; -import {Commands} from '../routing/routing.model'; +import {UrlSegment} from '@angular/router'; +import {MAIN_AREA} from '../layout/workbench-layout'; +import {ɵDestroyRef} from '../common/ɵdestroy-ref'; +import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; /** * DI token that holds the identity of the active perspective. @@ -42,18 +41,15 @@ export class ɵWorkbenchPerspective implements WorkbenchPerspective { private _workbenchGridMerger = inject(WorkbenchGridMerger); private _workbenchPerspectiveStorageService = inject(WorkbenchPerspectiveStorageService); private _workbenchLayoutService = inject(WorkbenchLayoutService); - private _workbenchLayoutSerializer = inject(WorkbenchLayoutSerializer); private _workbenchRouter = inject(WorkbenchRouter); - private _router = inject(Router); private _environmentInjector = inject(EnvironmentInjector); private _initialLayoutFn: WorkbenchLayoutFn; private _activePerspectiveId$ = inject(ACTIVE_PERSPECTIVE_ID$); private _perspectiveViewConflictResolver = inject(WorkbenchPerspectiveViewConflictResolver); - private _destroy$ = new Subject(); + private _destroyRef = new ɵDestroyRef(); - private _initialWorkbenchGrid: MPartGrid | undefined; - private _workbenchGrid: MPartGrid | undefined; - private _viewOutlets: {[viewId: string]: Commands} = {}; + private _initialPerspectiveLayout: ɵWorkbenchLayout | undefined; + private _perspectiveLayout: ɵWorkbenchLayout | undefined; public id: string; public transient: boolean; @@ -66,7 +62,7 @@ export class ɵWorkbenchPerspective implements WorkbenchPerspective { this.data = definition.data ?? {}; this.active$ = this._activePerspectiveId$.pipe(map(activePerspectiveId => activePerspectiveId === this.id)); this._initialLayoutFn = definition.layout; - this.onLayoutChange(layout => this.storePerspectiveLayout(layout)); + this.onPerspectiveLayoutChange(layout => this.storePerspectiveLayout(layout)); } /** @@ -74,22 +70,10 @@ export class ɵWorkbenchPerspective implements WorkbenchPerspective { */ public async activate(): Promise { // Create the initial workbench grid when constructed for the first time. - this._initialWorkbenchGrid ??= await this.createInitialWorkbenchGrid(); - - // Load perspective data from storage. - const perspectiveData = !this.transient ? await this._workbenchPerspectiveStorageService.loadPerspectiveData(this.id) : null; - if (perspectiveData) { - this._workbenchGrid = this._workbenchGridMerger.merge({ - local: this._workbenchLayoutFactory.create({workbenchGrid: perspectiveData.workbenchGrid}).workbenchGrid, - base: this._workbenchLayoutFactory.create({workbenchGrid: perspectiveData.initialWorkbenchGrid}).workbenchGrid, - remote: this._initialWorkbenchGrid, - }); - this._viewOutlets = perspectiveData.viewOutlets; - } - else { - this._workbenchGrid ??= this._initialWorkbenchGrid; - this._viewOutlets ??= {}; - } + this._initialPerspectiveLayout ??= await this.createInitialPerspectiveLayout(); + + // Load the layout from the storage, if present, or use the initial layout otherwise. + this._perspectiveLayout = (await this.loadPerspectiveLayout()) ?? this._initialPerspectiveLayout; // Memoize currently active perspective for a potential rollback in case the activation fails. const currentActivePerspectiveId = this._activePerspectiveId$.value; @@ -103,8 +87,8 @@ export class ɵWorkbenchPerspective implements WorkbenchPerspective { // (2) Enables routes to evaluate the active perspective in a `canMatch` guard, e.g., to display a perspective-specific start page. this._activePerspectiveId$.next(this.id); - // Apply the layout of this perspective. - return this.createActivationNavigation(currentLayout); + // Create layout with the workbench grid of this perspective and the main area of the current layout. + return this.createLayoutForActivation(currentLayout); }); if (!navigated) { this._activePerspectiveId$.next(currentActivePerspectiveId); @@ -116,50 +100,53 @@ export class ɵWorkbenchPerspective implements WorkbenchPerspective { * Resets this perspective to its initial layout. */ public async reset(): Promise { - this._workbenchGrid = this._initialWorkbenchGrid; - this._viewOutlets = {}; + this._perspectiveLayout = this._initialPerspectiveLayout; - // Apply the initial perspective layout. - await this._workbenchRouter.ɵnavigate(currentLayout => this.createActivationNavigation(currentLayout)); + // Reset to the initial layout. + await this._workbenchRouter.ɵnavigate(currentLayout => this.createLayoutForActivation(currentLayout)); } /** - * Creates the {@link WorkbenchNavigation} object to activate this perspective. + * Creates layout with the workbench grid of this perspective and the main area of the current layout. * - * When switching perspective, name clashes between the views contained in the perspective - * and the views contained in the main area are possible. The navigation detects and resolves name conflicts, + * When switching perspective, id clashes between the views contained in the perspective and the + * views contained in the main area are possible. The activation detects and resolves conflicts, * changing the layout of this perspective if necessary. */ - private createActivationNavigation(currentLayout: ɵWorkbenchLayout): WorkbenchNavigation { - if (!this._workbenchGrid) { - throw Error('[WorkbenchPerspectiveError] Perspective not yet constructed.'); + private createLayoutForActivation(currentLayout: ɵWorkbenchLayout): ɵWorkbenchLayout { + if (!this._perspectiveLayout) { + throw Error(`[PerspectiveActivateError] Perspective '${this.id}' not constructed.`); } - // If the current layout has a main area, resolve name clashes between views of this perspective and views contained in the main area. - if (currentLayout.mainAreaGrid) { - const resolved = this._perspectiveViewConflictResolver.resolve(currentLayout.mainAreaGrid, {workbenchGrid: this._workbenchGrid, viewOutlets: this._viewOutlets}); - this._workbenchGrid = resolved.workbenchGrid; - this._viewOutlets = resolved.viewOutlets; + // View outlets of the new layout. + const viewOutlets = new Map(); + + // Detect and resolve id clashes between views defined by this perspective and views contained in the main area, + // assigning views of this perspective a new identity. + if (currentLayout.hasPart(MAIN_AREA, {grid: 'workbench'}) && this._perspectiveLayout.hasPart(MAIN_AREA, {grid: 'workbench'})) { + this._perspectiveLayout = this._perspectiveViewConflictResolver.resolve(currentLayout, this._perspectiveLayout); + } + + // Add view outlets of views contained in the main area. + if (currentLayout.hasPart(MAIN_AREA, {grid: 'workbench'}) && this._perspectiveLayout.hasPart(MAIN_AREA, {grid: 'workbench'})) { + Object.entries(currentLayout.viewOutlets({grid: 'mainArea'})).forEach(([viewId, segments]) => { + viewOutlets.set(viewId, segments); + }); } - const newLayout = this._workbenchLayoutFactory.create({ - workbenchGrid: this._workbenchGrid, + // Add view outlets of views contained in this perspective. + Object.entries(this._perspectiveLayout.viewOutlets()).forEach(([viewId, segments]) => { + viewOutlets.set(viewId, segments); + }); + + // Create the layout for this perspective. + return this._workbenchLayoutFactory.create({ + workbenchGrid: this._perspectiveLayout.workbenchGrid, mainAreaGrid: currentLayout.mainAreaGrid, + viewOutlets: Object.fromEntries(viewOutlets), + viewStates: currentLayout.viewStates({grid: 'mainArea'}), // preserve view state of views in main area; view state of perspective cannot be restored since not persisted // Do not preserve maximized state when switching between perspectives. }); - - // Preserve view outlets defined in current layout's main area only if new layout contains a main area. - const outletsToRemove = newLayout.mainAreaGrid ? currentLayout.views({grid: 'workbench'}) : currentLayout.views(); - - return { - layout: newLayout, - viewOutlets: { - // Remove outlets of current perspective from the URL. - ...RouterUtils.outletsFromCurrentUrl(this._router, outletsToRemove.map(view => view.id), () => null), - // Add outlets of the perspective to activate to the URL. - ...this._viewOutlets, - }, - }; } public get active(): boolean { @@ -167,47 +154,85 @@ export class ɵWorkbenchPerspective implements WorkbenchPerspective { } /** - * Creates the initial workbench grid of this perspective as defined in the perspective definition. + * Creates the initial layout of this perspective as defined in the perspective definition. */ - private async createInitialWorkbenchGrid(): Promise { - const layout = await runInInjectionContext(this._environmentInjector, () => this._initialLayoutFn(this._workbenchLayoutFactory)); - return (layout as ɵWorkbenchLayout).workbenchGrid; + private async createInitialPerspectiveLayout(): Promise<ɵWorkbenchLayout> { + return await runInInjectionContext(this._environmentInjector, () => this._initialLayoutFn(this._workbenchLayoutFactory)) as ɵWorkbenchLayout; } /** - * Invokes the callback when the layout of this perspective changes. + * Subscribes to workbench layout changes, invoking the given callback on layout change, but only if this perspective is active. */ - private onLayoutChange(callback: (layout: ɵWorkbenchLayout) => Promise): void { + private onPerspectiveLayoutChange(callback: (layout: ɵWorkbenchLayout) => Promise): void { this._workbenchLayoutService.layout$ .pipe( filter(() => this.active), serializeExecution(callback), - takeUntil(this._destroy$), + takeUntilDestroyed(this._destroyRef), ) .subscribe(); } + /** + * Loads the layout of this perspective from storage, applying necessary migrations if the layout is outdated. + * Returns `null` if not stored or could not be deserialized. + */ + private async loadPerspectiveLayout(): Promise<ɵWorkbenchLayout | null> { + if (this.transient) { + return this._perspectiveLayout ?? null; + } + + const perspectiveLayout = await this._workbenchPerspectiveStorageService.loadPerspectiveLayout(this.id); + if (!perspectiveLayout) { + return null; + } + + return this._workbenchGridMerger.merge({ + local: this._workbenchLayoutFactory.create({ + workbenchGrid: perspectiveLayout.userLayout.workbenchGrid, + viewOutlets: perspectiveLayout.userLayout.viewOutlets, + }), + base: this._workbenchLayoutFactory.create({ + workbenchGrid: perspectiveLayout.referenceLayout.workbenchGrid, + viewOutlets: perspectiveLayout.referenceLayout.viewOutlets, + }), + remote: this._initialPerspectiveLayout!, + }); + } + /** * Stores the layout of this perspective. * * If an anonymous perspective, only memoizes the layout, but does not write it to storage. */ - private async storePerspectiveLayout(layout: ɵWorkbenchLayout): Promise { - // Memoize layout and outlets. - this._workbenchGrid = layout.workbenchGrid; - this._viewOutlets = RouterUtils.outletsFromCurrentUrl(this._router, layout.views({grid: 'workbench'}).map(view => view.id)); - - // Store the layout if not a transient perspective. - if (!this.transient) { - await this._workbenchPerspectiveStorageService.storePerspectiveData(this.id, { - initialWorkbenchGrid: this._workbenchLayoutSerializer.serialize(this._initialWorkbenchGrid), - workbenchGrid: this._workbenchLayoutSerializer.serialize(this._workbenchGrid), - viewOutlets: this._viewOutlets, - }); + private async storePerspectiveLayout(currentLayout: ɵWorkbenchLayout): Promise { + // Memoize the layout of this perspective. + this._perspectiveLayout = this._workbenchLayoutFactory.create({ + workbenchGrid: currentLayout.workbenchGrid, + viewOutlets: currentLayout.viewOutlets({grid: 'workbench'}), + }); + + // Do not store the layout if a transient perspective. + if (this.transient) { + return; } + + const serializedReferenceLayout = this._initialPerspectiveLayout!.serialize(); + const serializedUserLayout = this._perspectiveLayout.serialize(); + + await this._workbenchPerspectiveStorageService.storePerspectiveLayout(this.id, { + referenceLayout: { + workbenchGrid: serializedReferenceLayout.workbenchGrid, + viewOutlets: serializedReferenceLayout.workbenchViewOutlets, + }, + userLayout: { + workbenchGrid: serializedUserLayout.workbenchGrid, + viewOutlets: serializedUserLayout.workbenchViewOutlets, + }, + }); } public destroy(): void { - this._destroy$.next(); + this._destroyRef.destroy(); } } diff --git a/projects/scion/workbench/src/lib/popup/popup.config.ts b/projects/scion/workbench/src/lib/popup/popup.config.ts index cb0804dec..fe2f6a1f2 100644 --- a/projects/scion/workbench/src/lib/popup/popup.config.ts +++ b/projects/scion/workbench/src/lib/popup/popup.config.ts @@ -18,6 +18,7 @@ import {map} from 'rxjs/operators'; import {ɵDestroyRef} from '../common/ɵdestroy-ref'; import {ɵWorkbenchDialog} from '../dialog/ɵworkbench-dialog'; import {Blockable} from '../glass-pane/blockable'; +import {ViewId} from '../view/workbench-view.model'; /** * Configures the content to be displayed in a popup. @@ -105,7 +106,7 @@ export abstract class PopupConfig { */ public readonly size?: PopupSize; /** - * Specifies CSS class(es) to be added to the popup, useful in end-to-end tests for locating the popup. + * Specifies CSS class(es) to add to the popup, e.g., to locate the popup in tests. */ public readonly cssClass?: string | string[]; /** @@ -120,7 +121,7 @@ export abstract class PopupConfig { * By default, if opening the popup in the context of a view, that view is used as the popup's contextual view. * If you set the view id to `null`, the popup will open without referring to the contextual view. */ - viewId?: string | null; + viewId?: ViewId | null; }; } @@ -296,5 +297,5 @@ export interface PopupReferrer { /** * Identity of the view if opened in the context of a view. */ - viewId?: string; + viewId?: ViewId; } diff --git a/projects/scion/workbench/src/lib/portal/workbench-portal-outlet.directive.ts b/projects/scion/workbench/src/lib/portal/workbench-portal-outlet.directive.ts index 563ae1600..d66a84867 100644 --- a/projects/scion/workbench/src/lib/portal/workbench-portal-outlet.directive.ts +++ b/projects/scion/workbench/src/lib/portal/workbench-portal-outlet.directive.ts @@ -20,9 +20,9 @@ import {WbComponentPortal} from './wb-component-portal'; * Usage: * * ```html - * + * * - * + * * ```` * * @see WbComponentPortal diff --git a/projects/scion/workbench/src/lib/registry/workbench-object-registry.ts b/projects/scion/workbench/src/lib/registry/workbench-object-registry.ts index 9d085e804..3d5e70cb8 100644 --- a/projects/scion/workbench/src/lib/registry/workbench-object-registry.ts +++ b/projects/scion/workbench/src/lib/registry/workbench-object-registry.ts @@ -29,8 +29,8 @@ export class WorkbenchObjectRegistry { * Creates an instance of the registry. * * @param config - Controls the creation of the registry. - * @property keyFn - Function to extract the key of an object. - * @property nullObjectErrorFn - Function to provide an error when looking up an object not contained in the registry. + * @param config.keyFn - Function to extract the key of an object. + * @param config.nullObjectErrorFn - Function to provide an error when looking up an object not contained in the registry. */ constructor(config: {keyFn: (object: T) => KEY; nullObjectErrorFn: (key: KEY) => Error}) { this._keyFn = config.keyFn; diff --git a/projects/scion/workbench/src/lib/routing/route-resolve.spec.ts b/projects/scion/workbench/src/lib/routing/route-resolve.spec.ts deleted file mode 100644 index 3e5604ce5..000000000 --- a/projects/scion/workbench/src/lib/routing/route-resolve.spec.ts +++ /dev/null @@ -1,156 +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 {ComponentFixture, discardPeriodicTasks, fakeAsync, TestBed, waitForAsync} from '@angular/core/testing'; -import {Component} from '@angular/core'; -import {WorkbenchRouter} from './workbench-router.service'; -import {WorkbenchView} from '../view/workbench-view.model'; -import {advance, styleFixture} from '../testing/testing.util'; -import {WorkbenchComponent} from '../workbench.component'; -import {WorkbenchTestingModule} from '../testing/workbench-testing.module'; -import {RouterTestingModule} from '@angular/router/testing'; - -describe('WorkbenchRouter', () => { - - let fixture: ComponentFixture; - let workbenchRouter: WorkbenchRouter; - - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - imports: [ - WorkbenchTestingModule.forTest(), - RouterTestingModule.withRoutes([ - {path: 'path/to/view', component: ViewComponent}, - {path: 'path/to/view-1', component: ViewComponent}, - {path: 'path/to/view-2', component: ViewComponent}, - {path: 'path/to/view-3', component: ViewComponent}, - {path: 'path', component: ViewComponent}, - {path: 'path/:segment1', component: ViewComponent}, - {path: 'path/:segment1/:segment2', component: ViewComponent}, - ]), - ], - }); - fixture = styleFixture(TestBed.createComponent(WorkbenchComponent)); - workbenchRouter = TestBed.inject(WorkbenchRouter); - })); - - it('resolves present views by path', fakeAsync(() => { - // Add View 1 - workbenchRouter.navigate(['path', 'to', 'view-1']).then(); - advance(fixture); - - // Add View 1 again - workbenchRouter.navigate(['path', 'to', 'view-1'], {target: 'blank'}).then(); - advance(fixture); - - // Add View 2 - workbenchRouter.navigate(['path', 'to', 'view-2']).then(); - advance(fixture); - - // Add View 2 again (activate) - workbenchRouter.navigate(['path', 'to', 'view-2']).then(); - advance(fixture); - - // Add View 3 - workbenchRouter.navigate(['path', 'to', 'view-3']).then(); - advance(fixture); - - expect(workbenchRouter.resolvePresentViewIds(['path', 'to', 'view-1'])).toEqual(jasmine.arrayWithExactContents(['view.1', 'view.2'])); - expect(workbenchRouter.resolvePresentViewIds(['path', 'to', 'view-2'])).toEqual(['view.3']); - expect(workbenchRouter.resolvePresentViewIds(['path', 'to', 'view-3'])).toEqual(['view.4']); - - discardPeriodicTasks(); - })); - - it('resolves present views by path and ignores matrix params', fakeAsync(() => { - // Add View 1 - workbenchRouter.navigate(['path', 'to', 'view', {'matrixParam': 'A'}], {target: 'blank'}).then(); - advance(fixture); - - // Add View 1 again (existing view is activated) - workbenchRouter.navigate(['path', 'to', 'view', {'matrixParam': 'A'}]).then(); - advance(fixture); - - // Add View 2 (new view is created, because target is 'blank') - workbenchRouter.navigate(['path', 'to', 'view', {'matrixParam': 'B'}], {target: 'blank'}).then(); - advance(fixture); - - // Update matrix param (both views are updated) - workbenchRouter.navigate(['path', 'to', 'view', {'matrixParam': 'B'}]).then(); - advance(fixture); - - // Update matrix param (both views are updated) - workbenchRouter.navigate(['path', 'to', 'view', {'matrixParam': 'C'}]).then(); - advance(fixture); - - expect(workbenchRouter.resolvePresentViewIds(['path', 'to', 'view'])).toEqual(jasmine.arrayWithExactContents(['view.1', 'view.2'])); - expect(workbenchRouter.resolvePresentViewIds(['path', 'to', 'view', {'matrixParam': 'A'}])).toEqual(jasmine.arrayWithExactContents(['view.1', 'view.2'])); - expect(workbenchRouter.resolvePresentViewIds(['path', 'to', 'view', {'matrixParam': 'B'}])).toEqual(jasmine.arrayWithExactContents(['view.1', 'view.2'])); - expect(workbenchRouter.resolvePresentViewIds(['path', 'to', 'view', {'matrixParam': 'C'}])).toEqual(jasmine.arrayWithExactContents(['view.1', 'view.2'])); - - discardPeriodicTasks(); - })); - - it('resolves present views by path containing wildcards', fakeAsync(() => { - // Add View 1 - workbenchRouter.navigate(['path'], {target: 'blank'}).then(); - advance(fixture); - - // Add View 2 - workbenchRouter.navigate(['path', 1], {target: 'blank'}).then(); - advance(fixture); - - // Add View 3 - workbenchRouter.navigate(['path', 2], {target: 'blank'}).then(); - advance(fixture); - - // Add View 4 - workbenchRouter.navigate(['path', 1, 1], {target: 'blank'}).then(); - advance(fixture); - - // Add View 5 - workbenchRouter.navigate(['path', 1, 2], {target: 'blank'}).then(); - advance(fixture); - - // Add View 6 - workbenchRouter.navigate(['path', 2, 1], {target: 'blank'}).then(); - advance(fixture); - - // Add View 7 - workbenchRouter.navigate(['path', 2, 2], {target: 'blank'}).then(); - advance(fixture); - - // Wildcard match is disabled by default - expect(workbenchRouter.resolvePresentViewIds(['path'])).toEqual(['view.1']); - expect(workbenchRouter.resolvePresentViewIds(['path', '*'])).toEqual([]); - expect(workbenchRouter.resolvePresentViewIds(['path', 1, '*'])).toEqual([]); - expect(workbenchRouter.resolvePresentViewIds(['path', '*', 1])).toEqual([]); - expect(workbenchRouter.resolvePresentViewIds(['path', '*', '*'])).toEqual([]); - - // Set `matchWildcardSegments` option to `true` - expect(workbenchRouter.resolvePresentViewIds(['path'], {matchWildcardSegments: true})).toEqual(['view.1']); - expect(workbenchRouter.resolvePresentViewIds(['path', '*'], {matchWildcardSegments: true})).toEqual(jasmine.arrayWithExactContents(['view.2', 'view.3'])); - expect(workbenchRouter.resolvePresentViewIds(['path', 1, '*'], {matchWildcardSegments: true})).toEqual(jasmine.arrayWithExactContents(['view.4', 'view.5'])); - expect(workbenchRouter.resolvePresentViewIds(['path', '*', 1], {matchWildcardSegments: true})).toEqual(jasmine.arrayWithExactContents(['view.4', 'view.6'])); - expect(workbenchRouter.resolvePresentViewIds(['path', '*', '*'], {matchWildcardSegments: true})).toEqual(jasmine.arrayWithExactContents(['view.4', 'view.5', 'view.6', 'view.7'])); - - discardPeriodicTasks(); - })); -}); - -/**************************************************************************************************** - * Definition of App Test Module * - ****************************************************************************************************/ -@Component({selector: 'spec-view', template: '{{view.id}}', standalone: true}) -class ViewComponent { - - constructor(public view: WorkbenchView) { - } -} diff --git a/projects/scion/workbench/src/lib/routing/router-navigate.spec.ts b/projects/scion/workbench/src/lib/routing/router-navigate.spec.ts index 8ddc6869c..3f6804ff7 100644 --- a/projects/scion/workbench/src/lib/routing/router-navigate.spec.ts +++ b/projects/scion/workbench/src/lib/routing/router-navigate.spec.ts @@ -301,16 +301,16 @@ describe('Router', () => {
  • view-1
  • ./view-2
  • -
  • view-1
  • -
  • view-2
  • +
  • /feature-a/view-1
  • +
  • /feature-a/view-2
  • feature-b
  • feature-b/view-1
  • -
  • feature-b/view-1
  • +
  • ./feature-b/view-2
  • -
  • feature-b
  • -
  • feature-b/view-1
  • -
  • feature-b/view-2
  • +
  • /feature-a/feature-b
  • +
  • /feature-a/feature-b/view-1
  • +
  • /feature-a/feature-b/view-2
  • `, standalone: true, @@ -323,12 +323,12 @@ class FeatureA_EntryComponent { template: `

    Feature Module A - View 1

    @@ -343,8 +343,8 @@ class FeatureA_View1Component { template: `

    Feature Module A - View 2

    `, standalone: true, @@ -376,8 +376,8 @@ export class FeatureAModule { template: `

    Feature Module B - Entry