From 1217a8f8eb4310ee04340aa68c5b200b7d92a9da Mon Sep 17 00:00:00 2001 From: danielwiehl Date: Wed, 1 May 2024 18:20:20 +0200 Subject: [PATCH] =?UTF-8?q?fix(workbench/view):=C2=A0fix=C2=A0issues=20pre?= =?UTF-8?q?venting=20views=20from=20closing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard now runs before the Angular navigation. Previously, it was implemented as an Angular `CanDeactivate` guard. The following issues have been fixed: - guard now also runs on child routes; - multiple views can be closed simultaneously, even if some views prevent closing; previously, Angular navigation was canceled, causing no views to be closed; - guard can now perform an Angular navigation, e.g., to display a dialog with routed content; closes #27, closes #344 BREAKING CHANGE: Interface and method for preventing closing of a view have changed. To migrate, implement the `CanClose` instead of the `WorkbenchViewPreDestroy` interface. The following snippet illustrates what a migration could look like: **Before migration:** ``ts class YourComponent implements WorkbenchViewPreDestroy { public async onWorkbenchViewPreDestroy(): Promise { // return `true` to close the view, otherwise `false`. } } ``` **After migration:** ```ts class YourComponent implements CanClose { public async canClose(): Promise { // return `true` to close the view, otherwise `false`. } } --- .../src/app/view-page/view-page.component.ts | 27 +- .../app/start-page/start-page.component.ts | 46 ++- .../app/view-page/view-page.component.html | 8 +- .../src/app/view-page/view-page.component.ts | 24 +- .../site/howto/how-to-prevent-view-closing.md | 26 +- docs/site/howto/how-to.md | 2 +- projects/scion/e2e-testing/src/app.po.ts | 7 + .../scion/e2e-testing/src/start-page.po.ts | 7 + .../src/workbench-client/view.e2e-spec.ts | 71 ++-- .../workbench/page-object/router-page.po.ts | 5 + .../src/workbench/page-object/view-page.po.ts | 4 + .../src/workbench/view.e2e-spec.ts | 369 +++++++++++++++++- .../src/lib/view/workbench-view.ts | 54 +-- .../src/lib/view/\311\265workbench-view.ts" | 77 ++-- .../src/lib/\311\265workbench-commands.ts" | 14 +- .../scion/workbench-client/src/public-api.ts | 2 +- .../workbench/src/lib/common/asserts.util.ts | 2 +- .../workbench/src/lib/common/uuid.util.ts | 23 ++ .../lib/dialog/\311\265workbench-dialog.ts" | 4 +- .../filter-field/filter-field.component.ts | 4 +- .../workbench-layout-migration-v4.model.ts | 46 +++ .../workbench-layout-migration-v4.service.ts | 62 +++ .../layout/workbench-layout.component.spec.ts | 3 +- .../src/lib/layout/workbench-layout.model.ts | 9 +- .../src/lib/layout/workbench-layout.spec.ts | 173 ++++++-- .../workench-layout-serializer.service.ts | 20 +- .../\311\265workbench-layout.factory.ts" | 2 +- .../lib/layout/\311\265workbench-layout.ts" | 58 ++- .../microfrontend-view.component.ts | 31 +- .../workbench-grid-merger.service.spec.ts | 13 +- .../workbench-perspective-storage.spec.ts | 3 +- ...perspective-view-conflict-resolver.spec.ts | 3 +- .../perspective/workbench-perspective.spec.ts | 3 +- .../scion/workbench/src/lib/public_api.ts | 2 +- ...ch-auxiliary-routes-registrator.service.ts | 7 +- .../routing/workbench-url-observer.service.ts | 3 +- .../\311\265workbench-router.service.ts" | 47 ++- .../to-equal-workbench-layout.matcher.ts | 46 ++- .../workbench/src/lib/testing/testing.util.ts | 7 + .../src/lib/view/view-move-handler.service.ts | 10 +- .../scion/workbench/src/lib/view/view.spec.ts | 302 +++++++++++++- .../lib/view/workbench-view-route-guards.ts | 30 +- .../lib/view/\311\265workbench-view.model.ts" | 3 + .../scion/workbench/src/lib/workbench-id.ts | 4 +- .../workbench/src/lib/workbench.model.ts | 26 +- .../workbench/src/lib/workbench.service.ts | 34 +- .../src/lib/\311\265workbench.service.ts" | 25 +- 47 files changed, 1396 insertions(+), 352 deletions(-) create mode 100644 projects/scion/workbench/src/lib/common/uuid.util.ts create mode 100644 projects/scion/workbench/src/lib/layout/migration/model/workbench-layout-migration-v4.model.ts create mode 100644 projects/scion/workbench/src/lib/layout/migration/workbench-layout-migration-v4.service.ts diff --git a/apps/workbench-client-testing-app/src/app/view-page/view-page.component.ts b/apps/workbench-client-testing-app/src/app/view-page/view-page.component.ts index a9fd574ff..b254e2d09 100644 --- a/apps/workbench-client-testing-app/src/app/view-page/view-page.component.ts +++ b/apps/workbench-client-testing-app/src/app/view-page/view-page.component.ts @@ -10,7 +10,7 @@ import {Component, Inject, OnDestroy} from '@angular/core'; import {FormGroup, NonNullableFormBuilder, ReactiveFormsModule} from '@angular/forms'; -import {ViewClosingEvent, ViewClosingListener, WorkbenchMessageBoxService, WorkbenchRouter, WorkbenchView} from '@scion/workbench-client'; +import {CanClose, WorkbenchMessageBoxService, WorkbenchRouter, WorkbenchView} from '@scion/workbench-client'; import {ActivatedRoute} from '@angular/router'; import {UUID} from '@scion/toolkit/uuid'; import {MonoTypeOperatorFunction, NEVER} from 'rxjs'; @@ -49,7 +49,7 @@ import {SciCheckboxComponent} from '@scion/components.internal/checkbox'; SciViewportComponent, ], }) -export default class ViewPageComponent implements ViewClosingListener, OnDestroy { +export default class ViewPageComponent implements CanClose, OnDestroy { public form = this._formBuilder.group({ title: this._formBuilder.control(''), @@ -92,25 +92,22 @@ export default class ViewPageComponent implements ViewClosingListener, OnDestroy }); } - public async onClosing(event: ViewClosingEvent): Promise { + public async canClose(): Promise { if (!this.form.controls.confirmClosing.value) { - return; + return true; } const action = await this._messageBoxService.open({ content: 'Do you want to close this view?', - severity: 'info', - actions: { - yes: 'Yes', - no: 'No', - }, + actions: {yes: 'Yes', no: 'No', error: 'Throw Error'}, cssClass: ['e2e-close-view', this.view.id], - modality: 'application', // message box is displayed even if closing view is not active + modality: 'application', }); - if (action === 'no') { - event.preventDefault(); + if (action === 'error') { + throw Error(`[CanCloseSpecError] Error in CanLoad of view '${this.view.id}'.`); } + return action === 'yes'; } public onMarkDirty(dirty?: boolean): void { @@ -145,10 +142,10 @@ export default class ViewPageComponent implements ViewClosingListener, OnDestroy ) .subscribe(confirmClosing => { if (confirmClosing) { - this.view.addClosingListener(this); + this.view.addCanClose(this); } else { - this.view.removeClosingListener(this); + this.view.removeCanClose(this); } }); } @@ -198,6 +195,6 @@ export default class ViewPageComponent implements ViewClosingListener, OnDestroy } public ngOnDestroy(): void { - this.view.removeClosingListener(this); + this.view.removeCanClose(this); } } 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 ccb1537d7..3b0b0b6d3 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 @@ -8,11 +8,11 @@ * SPDX-License-Identifier: EPL-2.0 */ -import {ChangeDetectionStrategy, ChangeDetectorRef, Component, HostListener, Optional, ViewChild} from '@angular/core'; -import {WorkbenchConfig, WorkbenchRouteData, WorkbenchRouter, WorkbenchRouterLinkDirective, WorkbenchView} from '@scion/workbench'; +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, HostBinding, HostListener, Optional, ViewChild} from '@angular/core'; +import {WorkbenchConfig, WorkbenchRouteData, WorkbenchRouter, WorkbenchRouterLinkDirective, WorkbenchService, WorkbenchView} from '@scion/workbench'; import {Capability, IntentClient, ManifestService} from '@scion/microfrontend-platform'; import {Observable, of} from 'rxjs'; -import {WorkbenchCapabilities, WorkbenchPopupService, WorkbenchRouter as WorkbenchClientRouter, WorkbenchViewCapability} from '@scion/workbench-client'; +import {WorkbenchCapabilities, WorkbenchPopupService as WorkbenchClientPopupService, 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'; @@ -51,6 +51,9 @@ export default class StartPageComponent { @ViewChild(SciFilterFieldComponent) private _filterField!: SciFilterFieldComponent; + @HostBinding('attr.data-partid') + public partId: string | undefined; + public filterControl = this._formBuilder.control(''); public workbenchViewRoutes$: Observable; public microfrontendViewCapabilities$: Observable | undefined; @@ -58,12 +61,13 @@ export default class StartPageComponent { public WorkbenchRouteData = WorkbenchRouteData; - constructor(@Optional() private _view: WorkbenchView, // not available on entry point page - @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 + constructor(@Optional() private _view: WorkbenchView | null, // not available on entry point page + @Optional() private _workbenchClientRouter: WorkbenchClientRouter | null, // not available when starting the workbench standalone + @Optional() private _intentClient: IntentClient | null, // not available when starting the workbench standalone + @Optional() private _manifestService: ManifestService | null, // not available when starting the workbench standalone + @Optional() private _workbenchClientPopupService: WorkbenchClientPopupService | null, // not available when starting the workbench standalone + private _workbenchService: WorkbenchService, + private _workbenchRouter: WorkbenchRouter, private _formBuilder: NonNullableFormBuilder, router: Router, cd: ChangeDetectorRef, @@ -79,14 +83,14 @@ export default class StartPageComponent { if (workbenchConfig.microfrontendPlatform) { // Read microfrontend views to be pinned to the start page. - this.microfrontendViewCapabilities$ = this._manifestService.lookupCapabilities$({type: WorkbenchCapabilities.View}) + this.microfrontendViewCapabilities$ = this._manifestService!.lookupCapabilities$({type: WorkbenchCapabilities.View}) .pipe( filterArray(viewCapability => 'pinToStartPage' in viewCapability.properties && !!viewCapability.properties['pinToStartPage']), filterArray(viewCapability => !isTestCapability(viewCapability)), sortArray((a, b) => a.metadata!.appSymbolicName.localeCompare(b.metadata!.appSymbolicName)), ); // Read test capabilities to be pinned to the start page. - this.testCapabilities$ = this._manifestService.lookupCapabilities$() + this.testCapabilities$ = this._manifestService!.lookupCapabilities$() .pipe( filterArray(capability => !!capability.properties && 'pinToStartPage' in capability.properties && !!capability.properties['pinToStartPage']), filterArray(viewCapability => isTestCapability(viewCapability)), @@ -96,6 +100,7 @@ export default class StartPageComponent { this.markForCheckOnUrlChange(router, cd); this.installFilterFieldDisplayTextSynchronizer(); + this.memoizeContextualPart(); } public onViewOpen(path: string, event: MouseEvent): void { @@ -108,7 +113,7 @@ export default class StartPageComponent { public onMicrofrontendViewOpen(viewCapability: WorkbenchViewCapability, event: MouseEvent): void { event.preventDefault(); // Prevent href navigation imposed by accessibility rules - this._workbenchClientRouter.navigate(viewCapability.qualifier, { + this._workbenchClientRouter!.navigate(viewCapability.qualifier, { target: event.ctrlKey ? 'blank' : this._view?.id ?? 'blank', activate: !event.ctrlKey, }).then(); @@ -119,15 +124,15 @@ export default class StartPageComponent { // TODO [#343] Remove switch-case after fixed issue https://github.com/SchweizerischeBundesbahnen/scion-workbench/issues/343 switch (testCapability.type) { case WorkbenchCapabilities.View: { - await this._workbenchClientRouter.navigate(testCapability.qualifier!, {target: this._view?.id}); + await this._workbenchClientRouter!.navigate(testCapability.qualifier!, {target: this._view?.id}); break; } case WorkbenchCapabilities.Popup: { - await this._workbenchPopupService.open(testCapability.qualifier!, {anchor: event}); + await this._workbenchClientPopupService!.open(testCapability.qualifier!, {anchor: event}); break; } default: { - await this._intentClient.publish({type: testCapability.type, qualifier: testCapability.qualifier}); + await this._intentClient!.publish({type: testCapability.type, qualifier: testCapability.qualifier}); break; } } @@ -167,6 +172,17 @@ export default class StartPageComponent { }); } + /** + * Memoizes the part in which this component is displayed. + */ + private memoizeContextualPart(): void { + this._workbenchService.layout$ + .pipe(takeUntilDestroyed()) + .subscribe(() => { + this.partId = this._view?.part.id ?? this._workbenchService.parts.filter(part => part.active).sort(a => a.isInMainArea ? -1 : +1).at(0)?.id; + }); + } + public selectViewCapabilityText = (viewCapability: WorkbenchViewCapability): string | undefined => { return viewCapability.properties!.title; }; 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 7d09c225e..ea7e98681 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 @@ -54,11 +54,15 @@ - + - + + + + + 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 f3258ef18..642929c8a 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 @@ -8,8 +8,8 @@ * SPDX-License-Identifier: EPL-2.0 */ -import {Component} from '@angular/core'; -import {WorkbenchPartActionDirective, WorkbenchRouteData, WorkbenchStartup, WorkbenchView} from '@scion/workbench'; +import {Component, inject} from '@angular/core'; +import {CanClose, WorkbenchMessageBoxService, WorkbenchPartActionDirective, WorkbenchRouteData, WorkbenchStartup, WorkbenchView} from '@scion/workbench'; import {Observable} from 'rxjs'; import {map, startWith} from 'rxjs/operators'; import {ActivatedRoute} from '@angular/router'; @@ -51,7 +51,7 @@ import {CssClassComponent} from '../css-class/css-class.component'; WorkbenchPartActionDirective, ], }) -export default class ViewPageComponent { +export default class ViewPageComponent implements CanClose { public uuid = UUID.randomUUID(); public partActions$: Observable; @@ -59,6 +59,7 @@ export default class ViewPageComponent { public formControls = { partActions: this._formBuilder.control(''), cssClass: this._formBuilder.control(''), + confirmClosing: this._formBuilder.control(false), }; public WorkbenchRouteData = WorkbenchRouteData; @@ -81,6 +82,23 @@ export default class ViewPageComponent { this.installCssClassUpdater(); } + public async canClose(): Promise { + if (!this.formControls.confirmClosing.value) { + return true; + } + + const action = await inject(WorkbenchMessageBoxService).open('Do you want to close this view?', { + actions: {yes: 'Yes', no: 'No', error: 'Throw Error'}, + cssClass: ['e2e-close-view', this.view.id], + modality: 'application', + }); + + if (action === 'error') { + throw Error(`[CanCloseSpecError] Error in CanLoad of view '${this.view.id}'.`); + } + return action === 'yes'; + } + private parsePartActions(): WorkbenchPartActionDescriptor[] { if (!this.formControls.partActions.value) { return []; diff --git a/docs/site/howto/how-to-prevent-view-closing.md b/docs/site/howto/how-to-prevent-view-closing.md index f0f7370a7..25f8b0369 100644 --- a/docs/site/howto/how-to-prevent-view-closing.md +++ b/docs/site/howto/how-to-prevent-view-closing.md @@ -5,29 +5,23 @@ ## [SCION Workbench][menu-home] > [How To Guides][menu-how-to] > View -### How to prevent a view from being closed +### How to prevent a view from closing -The closing of a view can be intercepted by implementing the `WorkbenchViewPreDestroy` lifecycle hook in the view component. The `onWorkbenchViewPreDestroy` method is called when the view is about to be closed. Return `true` to continue closing or `false` otherwise. Alternatively, you can return a Promise or Observable to perform an asynchronous operation such as displaying a message box. +A view can implement the `CanClose` interface to intercept or prevent the closing. + +The `canClose` method is called when the view is about to close. Return `true` to close the view or `false` to prevent closing. Instead of a `boolean`, the method can return a `Promise` or an `Observable` to perform an asynchronous operation, such as displaying a message box. 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 { +import {Component, inject} from '@angular/core'; +import {CanClose, WorkbenchMessageBoxService} from '@scion/workbench'; - constructor(private view: WorkbenchView, private messageBoxService: WorkbenchMessageBoxService) { - } - - public async onWorkbenchViewPreDestroy(): Promise { - if (!this.view.dirty) { - return true; - } +@Component({...}) +class ViewComponent implements CanClose { - const action = await this.messageBoxService.open('Do you want to save changes?', { - severity: 'info', + public async canClose(): Promise { + const action = await inject(WorkbenchMessageBoxService).open('Do you want to save changes?', { actions: { yes: 'Yes', no: 'No', diff --git a/docs/site/howto/how-to.md b/docs/site/howto/how-to.md index 5268a0fcf..5e04d89be 100644 --- a/docs/site/howto/how-to.md +++ b/docs/site/howto/how-to.md @@ -26,7 +26,7 @@ We are working on a comprehensive guide that explains the features and concepts - [How to open a view](how-to-open-view.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 prevent a view from closing](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) diff --git a/projects/scion/e2e-testing/src/app.po.ts b/projects/scion/e2e-testing/src/app.po.ts index 9f281041a..17a304100 100644 --- a/projects/scion/e2e-testing/src/app.po.ts +++ b/projects/scion/e2e-testing/src/app.po.ts @@ -108,6 +108,13 @@ export class AppPO { await waitUntilStable(() => this.getCurrentNavigationId()); } + /** + * Opens a new browser window. + */ + public async openNewWindow(): Promise { + return new AppPO(await this.page.context().newPage()); + } + /** * Instructs the browser to move back one page in the session history. */ diff --git a/projects/scion/e2e-testing/src/start-page.po.ts b/projects/scion/e2e-testing/src/start-page.po.ts index facca7e78..33059d12a 100644 --- a/projects/scion/e2e-testing/src/start-page.po.ts +++ b/projects/scion/e2e-testing/src/start-page.po.ts @@ -49,6 +49,13 @@ export class StartPagePO implements WorkbenchViewPagePO { } } + /** + * Returns the part in which this page is displayed. + */ + public getPartId(): Promise { + return this.locator.getAttribute('data-partid'); + } + /** * Clicks the workbench view tile with specified CSS class set. */ diff --git a/projects/scion/e2e-testing/src/workbench-client/view.e2e-spec.ts b/projects/scion/e2e-testing/src/workbench-client/view.e2e-spec.ts index 53384e848..e9dbf43c8 100644 --- a/projects/scion/e2e-testing/src/workbench-client/view.e2e-spec.ts +++ b/projects/scion/e2e-testing/src/workbench-client/view.e2e-spec.ts @@ -191,7 +191,7 @@ test.describe('Workbench View', () => { await expect(viewPage.view.tab.closeButton).not.toBeVisible(); }); - test('should allow closing the view', async ({appPO, microfrontendNavigator}) => { + test('should close a view', async ({appPO, microfrontendNavigator}) => { await appPO.navigateTo({microfrontendSupport: true}); const viewPage = await microfrontendNavigator.openInNewTab(ViewPagePO, 'app1'); @@ -205,7 +205,7 @@ test.describe('Workbench View', () => { await expectView(viewPage).not.toBeAttached(); }); - test('should allow preventing the view from closing', async ({appPO, microfrontendNavigator}) => { + test('should prevent closing a view', async ({appPO, microfrontendNavigator}) => { await appPO.navigateTo({microfrontendSupport: true}); await microfrontendNavigator.registerIntention('app1', {type: 'messagebox'}); @@ -215,7 +215,7 @@ test.describe('Workbench View', () => { // prevent the view from closing await testeeViewPage.checkConfirmClosing(true); - // try closing the view + // try closing the view via view tab. await testeeViewPage.view.tab.close(); const messageBox = appPO.messagebox({cssClass: ['e2e-close-view', await testeeViewPage.view.getViewId()]}); await messageBox.clickActionButton('no'); @@ -223,14 +223,14 @@ test.describe('Workbench View', () => { // expect the view not to be closed await expectView(testeeViewPage).toBeActive(); - // try closing the view + // try closing the view via handle. await testeeViewPage.clickClose(); await messageBox.clickActionButton('no'); // expect the view not to be closed await expectView(testeeViewPage).toBeActive(); - // try closing the view + // close the view await testeeViewPage.view.tab.close(); await messageBox.clickActionButton('yes'); @@ -238,7 +238,7 @@ test.describe('Workbench View', () => { await expectView(testeeViewPage).not.toBeAttached(); }); - test('should only close confirmed views, leaving other views open', async ({appPO, microfrontendNavigator}) => { + test('should close confirmed views, leaving other views open', async ({appPO, microfrontendNavigator}) => { await appPO.navigateTo({microfrontendSupport: true}); await microfrontendNavigator.registerIntention('app1', {type: 'messagebox'}); @@ -253,7 +253,6 @@ test.describe('Workbench View', () => { // open test view 3 const testee3ViewPage = await microfrontendNavigator.openInNewTab(ViewPagePO, 'app1'); - await testee3ViewPage.checkConfirmClosing(true); // prevent the view from closing // open context menu of viewtab 3 const contextMenu = await testee3ViewPage.view.tab.openContextMenu(); @@ -261,36 +260,62 @@ test.describe('Workbench View', () => { // click to close all tabs await contextMenu.menuItems.closeAll.click(); - // expect all views being still open + // expect all views to be opened await expect(appPO.views()).toHaveCount(3); // confirm closing view 1 const messageBox1 = appPO.messagebox({cssClass: ['e2e-close-view', await testee1ViewPage.view.getViewId()]}); await messageBox1.clickActionButton('yes'); - // expect view 1 being closed - await expect(appPO.views()).toHaveCount(2); - // prevent closing view 2 const messageBox2 = appPO.messagebox({cssClass: ['e2e-close-view', await testee2ViewPage.view.getViewId()]}); await messageBox2.clickActionButton('no'); - // expect view 2 being still open - await expect(appPO.views()).toHaveCount(2); + // expect view 1 and view 3 to be closed. + await expectView(testee1ViewPage).not.toBeAttached(); + await expectView(testee2ViewPage).toBeActive(); + await expectView(testee3ViewPage).not.toBeAttached(); + }); - // confirm closing view 3 - const messageBox3 = appPO.messagebox({cssClass: ['e2e-close-view', await testee3ViewPage.view.getViewId()]}); - await messageBox3.clickActionButton('yes'); + test('should close view and log error if `CanClose` guard throws an error', async ({appPO, microfrontendNavigator, consoleLogs}) => { + await appPO.navigateTo({microfrontendSupport: true}); - // expect view 3 to be closed - await expect(appPO.views()).toHaveCount(1); - await expectView(testee3ViewPage).not.toBeAttached(); + await microfrontendNavigator.registerIntention('app1', {type: 'messagebox'}); - // expect view 2 not to be closed and active - await expectView(testee2ViewPage).toBeActive(); + // open test view 1 + const testee1ViewPage = await microfrontendNavigator.openInNewTab(ViewPagePO, 'app1'); + await testee1ViewPage.checkConfirmClosing(true); // prevent the view from closing - // expect view 1 to be closed + // open test view 2 + const testee2ViewPage = await microfrontendNavigator.openInNewTab(ViewPagePO, 'app1'); + await testee2ViewPage.checkConfirmClosing(true); // prevent the view from closing + + // open test view 3 + const testee3ViewPage = await microfrontendNavigator.openInNewTab(ViewPagePO, 'app1'); + + // open context menu of viewtab 3 + const contextMenu = await testee3ViewPage.view.tab.openContextMenu(); + + // click to close all tabs + await contextMenu.menuItems.closeAll.click(); + + // expect all views to be opened + await expect(appPO.views()).toHaveCount(3); + + // simulate view 1 to throw error + const messageBox1 = appPO.messagebox({cssClass: ['e2e-close-view', await testee1ViewPage.view.getViewId()]}); + await messageBox1.clickActionButton('error'); + + // prevent closing view 2 + const messageBox2 = appPO.messagebox({cssClass: ['e2e-close-view', await testee2ViewPage.view.getViewId()]}); + await messageBox2.clickActionButton('no'); + + // expect view 1 and view 3 to be closed. await expectView(testee1ViewPage).not.toBeAttached(); + await expectView(testee2ViewPage).toBeActive(); + await expectView(testee3ViewPage).not.toBeAttached(); + + await expect.poll(() => consoleLogs.contains({severity: 'error', message: /\[CanCloseSpecError] Error in CanLoad of view 'view\.1'\./})).toBe(true); }); test('should activate viewtab when switching between tabs', async ({appPO, microfrontendNavigator}) => { @@ -329,7 +354,7 @@ test.describe('Workbench View', () => { expect(activeViewSize).toEqual(inactiveViewSize); }); - test('should not confirm closing when switching between viewtabs', async ({appPO, microfrontendNavigator}) => { + test('should not invoke `CanClose` guard when switching between viewtabs', async ({appPO, microfrontendNavigator}) => { await appPO.navigateTo({microfrontendSupport: true}); await microfrontendNavigator.registerIntention('app1', {type: 'messagebox'}); 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 1ad943743..3c097d2af 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 @@ -40,6 +40,10 @@ export class RouterPagePO implements WorkbenchViewPagePO { const navigationId = await this._appPO.getCurrentNavigationId(); await this.locator.locator('button.e2e-router-navigate').click(); + if (!(extras?.waitForNavigation ?? true)) { + return; + } + // Evaluate the response: resolve the promise on success, or reject it on error. await Promise.race([ waitForCondition(async () => (await this._appPO.getCurrentNavigationId()) !== navigationId), @@ -124,6 +128,7 @@ export class RouterPagePO implements WorkbenchViewPagePO { export interface RouterPageOptions { viewContextActive?: boolean; + waitForNavigation?: false; } export interface RouterLinkPageOptions { 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 a674a6a35..81ebae5e2 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 @@ -86,6 +86,10 @@ export class ViewPagePO implements WorkbenchViewPagePO { await new SciCheckboxPO(this.locator.locator('sci-checkbox.e2e-closable')).toggle(check); } + public async checkConfirmClosing(check: boolean): Promise { + await new SciCheckboxPO(this.locator.locator('sci-checkbox.e2e-confirm-closing')).toggle(check); + } + public async clickClose(): Promise { const accordion = new SciAccordionPO(this.locator.locator('sci-accordion.e2e-view-actions')); await accordion.expand(); 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 4fdf5eef9..0bf432979 100644 --- a/projects/scion/e2e-testing/src/workbench/view.e2e-spec.ts +++ b/projects/scion/e2e-testing/src/workbench/view.e2e-spec.ts @@ -14,6 +14,10 @@ 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'; +import {MPart, MTreeNode} from '../matcher/to-equal-workbench-layout.matcher'; +import {MAIN_AREA} from '../workbench.model'; +import {WorkbenchNavigator} from './workbench-navigator'; +import {StartPagePO} from '../start-page.po'; test.describe('Workbench View', () => { @@ -296,7 +300,7 @@ test.describe('Workbench View', () => { consoleLogs.clear(); }); - test('should allow to close the view', async ({appPO, workbenchNavigator}) => { + test('should close a view', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); const viewPage = await workbenchNavigator.openInNewTab(ViewPagePO); await viewPage.clickClose(); @@ -305,6 +309,134 @@ test.describe('Workbench View', () => { await expectView(viewPage).not.toBeAttached(); }); + test('should prevent closing a view', async ({appPO, page, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + const testeeViewPage = await workbenchNavigator.openInNewTab(ViewPagePO); + + // Prevent the view from closing. + await testeeViewPage.checkConfirmClosing(true); + + // Close view via view tab (prevent). + await testeeViewPage.view.tab.close(); + const canCloseMessageBox = appPO.messagebox({cssClass: ['e2e-close-view', await testeeViewPage.view.getViewId()]}); + await canCloseMessageBox.clickActionButton('no'); + await expectView(testeeViewPage).toBeActive(); + + // Close view via WorkbenchView handle (prevent). + await testeeViewPage.clickClose(); + await canCloseMessageBox.clickActionButton('no'); + await expectView(testeeViewPage).toBeActive(); + + // Close view via close keystroke (prevent). + await testeeViewPage.view.tab.click(); + await page.keyboard.press('Control+K'); + await canCloseMessageBox.clickActionButton('no'); + await expectView(testeeViewPage).toBeActive(); + + // Close view via close keystroke (prevent). + await testeeViewPage.view.tab.click(); + await page.keyboard.press('Control+K'); + await canCloseMessageBox.clickActionButton('no'); + await expectView(testeeViewPage).toBeActive(); + + // Close all views via close keystroke (prevent). + await testeeViewPage.view.tab.click(); + await page.keyboard.press('Control+Shift+Alt+K'); + await canCloseMessageBox.clickActionButton('no'); + await expectView(testeeViewPage).toBeActive(); + + // Close view via router (prevent). + // Do not wait for the navigation to complete because the message box blocks navigation. + const routerPage = await workbenchNavigator.openInNewTab(RouterPagePO); + await routerPage.navigate([], {target: await testeeViewPage.view.getViewId(), close: true, waitForNavigation: false}); + await canCloseMessageBox.clickActionButton('no'); + await expectView(testeeViewPage).toBeInactive(); + + // Close all views via router (prevent). + // Do not wait for the navigation to complete because the message box blocks navigation. + await routerPage.navigate(['test-view'], {close: true, waitForNavigation: false}); + await canCloseMessageBox.clickActionButton('no'); + await expectView(testeeViewPage).toBeInactive(); + + // Close view. + await testeeViewPage.view.tab.close(); + await canCloseMessageBox.clickActionButton('yes'); + await expectView(testeeViewPage).not.toBeAttached(); + }); + + test('should close confirmed views, leaving other views open', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + // Open test view 1. + const testee1ViewPage = await workbenchNavigator.openInNewTab(ViewPagePO); + await testee1ViewPage.checkConfirmClosing(true); // prevent the view from closing + + // Open test view 2. + const testee2ViewPage = await workbenchNavigator.openInNewTab(ViewPagePO); + await testee2ViewPage.checkConfirmClosing(true); // prevent the view from closing + + // Open test view 3. + const testee3ViewPage = await workbenchNavigator.openInNewTab(ViewPagePO); + + // Close all views. + const contextMenu = await testee3ViewPage.view.tab.openContextMenu(); + await contextMenu.menuItems.closeAll.click(); + + // Expect all views to still be opened. + await expect(appPO.views()).toHaveCount(3); + + // Confirm closing view 1. + const canCloseMessageBox1 = appPO.messagebox({cssClass: ['e2e-close-view', await testee1ViewPage.view.getViewId()]}); + await canCloseMessageBox1.clickActionButton('yes'); + + // Prevent closing view 2. + const canCloseMessageBox2 = appPO.messagebox({cssClass: ['e2e-close-view', await testee2ViewPage.view.getViewId()]}); + await canCloseMessageBox2.clickActionButton('no'); + + // Expect view 1 and view 3 to be closed. + await expectView(testee1ViewPage).not.toBeAttached(); + await expectView(testee2ViewPage).toBeActive(); + await expectView(testee3ViewPage).not.toBeAttached(); + }); + + test('should close view and log error if `CanClose` guard errors', async ({appPO, workbenchNavigator, consoleLogs}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + // Open test view 1. + const testee1ViewPage = await workbenchNavigator.openInNewTab(ViewPagePO); + await testee1ViewPage.checkConfirmClosing(true); // prevent the view from closing + + // Open test view 2. + const testee2ViewPage = await workbenchNavigator.openInNewTab(ViewPagePO); + await testee2ViewPage.checkConfirmClosing(true); // prevent the view from closing + + // Open test view 3. + const testee3ViewPage = await workbenchNavigator.openInNewTab(ViewPagePO); + + // Close all views. + const contextMenu = await testee3ViewPage.view.tab.openContextMenu(); + await contextMenu.menuItems.closeAll.click(); + + // Expect all views to still be opened. + await expect(appPO.views()).toHaveCount(3); + + // Simulate `CanClose` guard of view 1 to error. + const canCloseMessageBox1 = appPO.messagebox({cssClass: ['e2e-close-view', await testee1ViewPage.view.getViewId()]}); + await canCloseMessageBox1.clickActionButton('error'); + + // Prevent closing view 2. + const canCloseMessageBox2 = appPO.messagebox({cssClass: ['e2e-close-view', await testee2ViewPage.view.getViewId()]}); + await canCloseMessageBox2.clickActionButton('no'); + + // Expect view 1 and view 3 to be closed. + await expectView(testee1ViewPage).not.toBeAttached(); + await expectView(testee2ViewPage).toBeActive(); + await expectView(testee3ViewPage).not.toBeAttached(); + + await expect.poll(() => consoleLogs.contains({severity: 'error', message: /\[CanCloseSpecError] Error in CanLoad of view 'view\.1'\./})).toBe(true); + }); + test(`should disable context menu 'Close tab' for 'non-closable' view`, async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); @@ -345,6 +477,241 @@ test.describe('Workbench View', () => { await expectView(viewPage2).toBeActive(); }); + /** + * Tests to unset the "markedForRemoval" flag after navigation, i.e., that a subsequent layout operation does not invoke the `CanClose` guard again. + */ + test('should unset `markedForRemoval` flag after navigation', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + // Open test view. + const testeeViewPage = await workbenchNavigator.openInNewTab(ViewPagePO); + + // Prevent the view from closing. + await testeeViewPage.checkConfirmClosing(true); + + // Try closing the view. + await testeeViewPage.view.tab.close(); + + // Prevent closing the view. + const canCloseMessageBox = appPO.messagebox({cssClass: ['e2e-close-view', await testeeViewPage.view.getViewId()]}); + await canCloseMessageBox.clickActionButton('no'); + + // Expect view not to be closed. + await expectView(testeeViewPage).toBeActive(); + + // Perform navigation after prevented closing. + await workbenchNavigator.openInNewTab(ViewPagePO); + + // Expect `CanClose` guard not to be invoked. + await expect(appPO.messagebox({cssClass: ['e2e-close-view', await testeeViewPage.view.getViewId()]}).locator).not.toBeAttached(); + }); + + test('should not invoke `CanClose` guard when dragging view in the same layout in the main area', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + // Open test view 1. + const testee1ViewPage = await workbenchNavigator.openInNewTab(ViewPagePO); + const testee1ViewId = await testee1ViewPage.view.getViewId(); + + // Open test view 2. + const testee2ViewPage = await workbenchNavigator.openInNewTab(ViewPagePO); + const testee2ViewId = await testee2ViewPage.view.getViewId(); + + // Prevent the view from closing. + await testee2ViewPage.checkConfirmClosing(true); + + // Test `CanClose` guard to be installed. + await testee2ViewPage.view.tab.close(); + const canCloseMessageBox = appPO.messagebox({cssClass: ['e2e-close-view', testee2ViewId]}); + await canCloseMessageBox.clickActionButton('no'); + await expectView(testee2ViewPage).toBeActive(); + + // Drag view in the layout. + await testee2ViewPage.view.tab.dragTo({partId: await testee2ViewPage.view.part.getPartId(), region: 'east'}); + + // Expect `CanClose` guard not to be invoked. + await expect(appPO.messagebox({cssClass: ['e2e-close-view', testee2ViewId]}).locator).not.toBeAttached(); + + // Expect view to be dragged. + await expectView(testee2ViewPage).toBeActive(); + await expect(appPO.workbench).toEqualWorkbenchLayout({ + mainAreaGrid: { + root: new MTreeNode({ + direction: 'row', + ratio: .5, + child1: new MPart({ + views: [{id: testee1ViewId}], + activeViewId: testee1ViewId, + }), + child2: new MPart({ + views: [{id: testee2ViewId}], + activeViewId: testee2ViewId, + }), + }), + }, + }); + }); + + test('should not invoke `CanClose` guard when dragging view in the same layout into the peripheral area', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + // Open test view 1. + const testee1ViewPage = await workbenchNavigator.openInNewTab(ViewPagePO); + const testee1ViewId = await testee1ViewPage.view.getViewId(); + + // Open test view 2. + const testee2ViewPage = await workbenchNavigator.openInNewTab(ViewPagePO); + const testee2ViewId = await testee2ViewPage.view.getViewId(); + + // Prevent the view from closing. + await testee2ViewPage.checkConfirmClosing(true); + + // Test `CanClose` guard to be installed. + await testee2ViewPage.view.tab.close(); + const canCloseMessageBox = appPO.messagebox({cssClass: ['e2e-close-view', testee2ViewId]}); + await canCloseMessageBox.clickActionButton('no'); + await expectView(testee2ViewPage).toBeActive(); + + // Drag view in the layout. + await testee2ViewPage.view.tab.dragTo({grid: 'workbench', region: 'east'}); + + // Expect `CanClose` guard not to be invoked. + await expect(appPO.messagebox({cssClass: ['e2e-close-view', testee2ViewId]}).locator).not.toBeAttached(); + + // Expect view to be dragged. + await expectView(testee2ViewPage).toBeActive(); + await expect(appPO.workbench).toEqualWorkbenchLayout({ + workbenchGrid: { + root: new MTreeNode({ + direction: 'row', + ratio: .8, + child1: new MPart({id: MAIN_AREA}), + child2: new MPart({ + views: [{id: testee2ViewId}], + activeViewId: testee2ViewId, + }), + }), + }, + mainAreaGrid: { + root: new MPart({ + views: [{id: testee1ViewId}], + activeViewId: testee1ViewId, + }), + }, + }); + }); + + test('should not invoke `CanClose` guard when dragging view to a new window', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + // Open test view 1. + const testee1ViewPage = await workbenchNavigator.openInNewTab(ViewPagePO); + const testee1ViewId = await testee1ViewPage.view.getViewId(); + + // Open test view 2. + const testee2ViewPage = await workbenchNavigator.openInNewTab(ViewPagePO); + const testee2ViewId = await testee2ViewPage.view.getViewId(); + + // Prevent the view from closing. + await testee2ViewPage.checkConfirmClosing(true); + + // Test `CanClose` guard to be installed. + await testee2ViewPage.view.tab.close(); + const canCloseMessageBox = appPO.messagebox({cssClass: ['e2e-close-view', testee2ViewId]}); + await canCloseMessageBox.clickActionButton('no'); + await expectView(testee2ViewPage).toBeActive(); + + // Move view to new window + const newAppPO = await testee2ViewPage.view.tab.moveToNewWindow(); + const newWindow = { + appPO: newAppPO, + workbenchNavigator: new WorkbenchNavigator(newAppPO), + }; + + // Expect `CanClose` guard not to be invoked. + await expect(appPO.messagebox({cssClass: ['e2e-close-view', testee2ViewId]}).locator).not.toBeAttached(); + + // Expect view to be moved to the new window. + await expect(newAppPO.workbench).toEqualWorkbenchLayout({ + mainAreaGrid: { + root: new MPart({ + views: [{id: 'view.1'}], + activeViewId: 'view.1', + }), + }, + }); + await expectView(new ViewPagePO(newWindow.appPO, {viewId: 'view.1'})).toBeActive(); + + // Expect view to be removed from the origin window. + await expect(appPO.workbench).toEqualWorkbenchLayout({ + mainAreaGrid: { + root: new MPart({ + views: [{id: testee1ViewId}], + activeViewId: testee1ViewId, + }), + }, + }); + await expectView(testee1ViewPage).toBeActive(); + await expectView(testee2ViewPage).not.toBeAttached(); + }); + + test('should not invoke `CanClose` guard when dragging view to another window', async ({appPO, workbenchNavigator}) => { + await appPO.navigateTo({microfrontendSupport: false}); + + // Open test view 1. + const testee1ViewPage = await workbenchNavigator.openInNewTab(ViewPagePO); + const testee1ViewId = await testee1ViewPage.view.getViewId(); + + // Open test view 2. + const testee2ViewPage = await workbenchNavigator.openInNewTab(ViewPagePO); + const testee2ViewId = await testee2ViewPage.view.getViewId(); + + // Prevent the view from closing. + await testee2ViewPage.checkConfirmClosing(true); + + // Test `CanClose` guard to be installed. + await testee2ViewPage.view.tab.close(); + const canCloseMessageBox = appPO.messagebox({cssClass: ['e2e-close-view', testee2ViewId]}); + await canCloseMessageBox.clickActionButton('no'); + await expectView(testee2ViewPage).toBeActive(); + + // Open new browser window. + const newAppPO = await appPO.openNewWindow(); + await newAppPO.navigateTo({microfrontendSupport: false}); + + // Move view to new browser window. + const startPagePartId = (await new StartPagePO(newAppPO).getPartId())!; + await testee2ViewPage.view.tab.moveTo(startPagePartId, { + workbenchId: await newAppPO.getWorkbenchId(), + }); + + // Expect `CanClose` guard not to be invoked. + await expect(appPO.messagebox({cssClass: ['e2e-close-view', testee2ViewId]}).locator).not.toBeAttached(); + + // Expect view to be moved to the new window. + await expect(newAppPO.workbench).toEqualWorkbenchLayout({ + mainAreaGrid: { + root: new MPart({ + views: [{id: 'view.1'}], + activeViewId: 'view.1', + }), + }, + }); + await expectView(new ViewPagePO(newAppPO, {viewId: 'view.1'})).toBeActive(); + + // Expect view to be removed from the origin window. + await expect(appPO.workbench).toEqualWorkbenchLayout({ + mainAreaGrid: { + root: new MPart({ + views: [{id: testee1ViewId}], + activeViewId: testee1ViewId, + }), + }, + }); + await expectView(testee1ViewPage).toBeActive(); + await expectView(testee2ViewPage).not.toBeAttached(); + }); + test('should detach view if not active', async ({appPO, workbenchNavigator}) => { await appPO.navigateTo({microfrontendSupport: false}); 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 bfeb643b5..3f2b231c6 100644 --- a/projects/scion/workbench-client/src/lib/view/workbench-view.ts +++ b/projects/scion/workbench-client/src/lib/view/workbench-view.ts @@ -103,44 +103,46 @@ export abstract class WorkbenchView { public abstract close(): void; /** - * Adds a listener to be notified just before closing this view. The closing event is cancelable, - * i.e., you can invoke {@link ViewClosingEvent.preventDefault} to prevent closing. + * Registers a guard to decide whether this view can be closed or not. + * The guard will be removed when navigating to another microfrontend. * - * The listener is removed when navigating to another microfrontend, whether from the same app or a different one. + * @see CanClose */ - public abstract addClosingListener(listener: ViewClosingListener): void; + public abstract addCanClose(canClose: CanClose): void; /** - * Removes the given listener. + * Unregisters the given guard. */ - public abstract removeClosingListener(listener: ViewClosingListener): void; + public abstract removeCanClose(canClose: CanClose): void; } /** - * Listener to be notified just before closing the workbench view. + * Guard that can be registered in {@link WorkbenchView} to decide whether the view can be closed. * - * @category View + * The following example registers a `CanClose` guard that asks the user whether the view can be closed. + * + * ```ts + * class MicrofrontendComponent implements CanClose { + * + * constructor() { + * Beans.get(WorkbenchView).addCanClose(this); + * } + * + * public async canClose(): Promise { + * const action = await Beans.get(WorkbenchMessageBoxService).open('Do you want to close this view?', { + * actions: {yes: 'Yes', no: 'No'}, + * }); + * return action === 'yes'; + * } + * } + * ``` */ -export interface ViewClosingListener { +export interface CanClose { /** - * Method invoked just before closing the workbench view. - * - * The closing event is cancelable, i.e., you can invoke {@link ViewClosingEvent.preventDefault} to prevent closing. - * - * Note that you can cancel the event only until the returned Promise resolves. For example, to ask the user - * for confirmation, you can use an async block and await user confirmation, as following: - * - * ```ts - * public async onClosing(event: ViewClosingEvent): Promise { - * const shouldClose = await askUserToConfirmClosing(); - * if (!shouldClose) { - * event.preventDefault(); - * } - * } - * ``` - */ - onClosing(event: ViewClosingEvent): void | Promise; + * Decides whether this view can be closed. + */ + canClose(): Observable | Promise | boolean; } /** 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 2d03c90c6..e31e3200c 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" @@ -9,14 +9,14 @@ */ import {Beans, PreDestroy} from '@scion/toolkit/bean-manager'; -import {merge, Observable, OperatorFunction, pipe, Subject, Subscription, take} from 'rxjs'; +import {firstValueFrom, merge, Observable, OperatorFunction, pipe, Subject, Subscription, take} from 'rxjs'; import {WorkbenchViewCapability} from './workbench-view-capability'; -import {ManifestService, mapToBody, Message, MessageClient, MessageHeaders, MicrofrontendPlatformClient, ResponseStatusCodes} from '@scion/microfrontend-platform'; +import {ManifestService, mapToBody, MessageClient, MicrofrontendPlatformClient} from '@scion/microfrontend-platform'; 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, ViewId, ViewSnapshot, WorkbenchView} from './workbench-view'; +import {CanClose, ViewId, ViewSnapshot, WorkbenchView} from './workbench-view'; import {decorateObservable} from '../observable-decorator'; export class ɵWorkbenchView implements WorkbenchView, PreDestroy { @@ -32,8 +32,8 @@ export class ɵWorkbenchView implements WorkbenchView, PreDestroy { * Observable that emits before navigating to a different microfrontend of the same app. */ private _beforeInAppNavigation$ = new Subject(); - private _closingListeners = new Set(); - private _closingSubscription: Subscription | undefined; + private _canCloseGuards = new Set(); + private _canCloseSubscription: Subscription | undefined; public active$: Observable; public params$: Observable>; @@ -45,7 +45,7 @@ export class ɵWorkbenchView implements WorkbenchView, PreDestroy { constructor(public id: ViewId) { this._beforeUnload$ = Beans.get(MessageClient).observe$(ɵWorkbenchCommands.viewUnloadingTopic(this.id)) - .pipe(map(() => undefined)); + .pipe(map(() => undefined), shareReplay({refCount: false, bufferSize: 1})); this.params$ = Beans.get(MessageClient).observe$>(ɵWorkbenchCommands.viewParamsTopic(this.id)) .pipe( @@ -84,8 +84,15 @@ export class ɵWorkbenchView implements WorkbenchView, PreDestroy { ) .subscribe(() => { this._beforeInAppNavigation$.next(); - this._closingListeners.clear(); - this._closingSubscription?.unsubscribe(); + this._canCloseGuards.clear(); + this._canCloseSubscription?.unsubscribe(); + }); + + // Detect navigation to a different view capability of another app. + this._beforeUnload$ + .pipe(takeUntil(this._destroy$)) + .subscribe(() => { + this._canCloseSubscription?.unsubscribe(); }); } @@ -162,60 +169,38 @@ export class ɵWorkbenchView implements WorkbenchView, PreDestroy { /** * @inheritDoc */ - public addClosingListener(listener: ViewClosingListener): void { - if (!this._closingListeners.has(listener) && this._closingListeners.add(listener).size === 1) { - // Subscribe to the closing event lazily when registering the first listener, so that the workbench only has to ask for a - // closing confirmation if a listener is actually installed. - this._closingSubscription = this.installClosingHandler(); + public addCanClose(canClose: CanClose): void { + // Subscribe to `CanClose` requests lazily when registering the first listener. + // The workbench will only invoke this guard if a listener is installed. + if (!this._canCloseGuards.has(canClose) && this._canCloseGuards.add(canClose).size === 1) { + this._canCloseSubscription = Beans.get(MessageClient).onMessage(ɵWorkbenchCommands.canCloseTopic(this.id), () => this.canClose()); } } /** * @inheritDoc */ - public removeClosingListener(listener: ViewClosingListener): void { - if (this._closingListeners.delete(listener) && this._closingListeners.size === 0) { - this._closingSubscription?.unsubscribe(); - this._closingSubscription = undefined; + public removeCanClose(canClose: CanClose): void { + if (this._canCloseGuards.delete(canClose) && this._canCloseGuards.size === 0) { + this._canCloseSubscription?.unsubscribe(); + this._canCloseSubscription = undefined; } } /** - * Installs a handler to be invoked by the workbench before closing this view. - */ - private installClosingHandler(): Subscription { - return Beans.get(MessageClient).observe$(ɵWorkbenchCommands.viewClosingTopic(this.id)) - .pipe( - switchMap(async (closeRequest: Message) => { - // Do not move the publishing of the response to a subsequent handler, because the subscription gets canceled when the last closing listener unsubscribes. - // See {@link removeClosingListener}. For example, if the listener unsubscribes immediately after handled prevention, subsequent handlers of this Observable - // chain would not be called, and neither would the subscribe handler. - const preventViewClosing = await this.isViewClosingPrevented(); - const replyTo = closeRequest.headers.get(MessageHeaders.ReplyTo); - await Beans.get(MessageClient).publish(replyTo, !preventViewClosing, {headers: new Map().set(MessageHeaders.Status, ResponseStatusCodes.TERMINAL)}); - }), - takeUntil(merge(this._beforeUnload$, this._destroy$)), - ) - .subscribe(); - } - - /** - * Lets registered listeners prevent this view from closing. - * - * @return Promise that resolves to `true` if at least one listener prevents closing, or that resolves to `false` otherwise. + * Decides whether this view can be closed. */ - private async isViewClosingPrevented(): Promise { - for (const listener of this._closingListeners) { - const event = new ViewClosingEvent(); - await listener.onClosing(event); - if (event.isDefaultPrevented()) { - return true; + private async canClose(): Promise { + for (const guard of this._canCloseGuards) { + if (!await firstValueFrom(Observables.coerce(guard.canClose()), {defaultValue: true})) { + return false; } } - return false; + return true; } public preDestroy(): void { + this._canCloseSubscription?.unsubscribe(); this._destroy$.next(); } } 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 1826610b7..c4dc32989 100644 --- "a/projects/scion/workbench-client/src/lib/\311\265workbench-commands.ts" +++ "b/projects/scion/workbench-client/src/lib/\311\265workbench-commands.ts" @@ -50,11 +50,17 @@ export const ɵWorkbenchCommands = { viewActiveTopic: (viewId: ViewId) => `ɵworkbench/views/${viewId}/active`, /** - * Computes the topic for signaling that a view is about to be closed. + * Computes the topic to request closing confirmation of a view. * - * Just before closing the view and if the microfrontend has subscribed to this topic, the workbench requests - * 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. + * When closing a view and if the microfrontend has subscribed to this topic, the workbench requests 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. + */ + canCloseTopic: (viewId: ViewId) => `ɵworkbench/views/${viewId}/canClose`, + /** + * TODO [Angular 20] Remove legacy topic. + * + * @deprecated since version 17.0.0-beta.8; Use `canCloseTopic` instead. */ viewClosingTopic: (viewId: ViewId) => `ɵworkbench/views/${viewId}/closing`, diff --git a/projects/scion/workbench-client/src/public-api.ts b/projects/scion/workbench-client/src/public-api.ts index 60467b54c..9ecacd055 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, ViewId} from './lib/view/workbench-view'; +export {WorkbenchView, CanClose, 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/src/lib/common/asserts.util.ts b/projects/scion/workbench/src/lib/common/asserts.util.ts index 70224bae5..0ff40d986 100644 --- a/projects/scion/workbench/src/lib/common/asserts.util.ts +++ b/projects/scion/workbench/src/lib/common/asserts.util.ts @@ -13,7 +13,7 @@ export function assertType(object: any, assert: {toBeOneOf: Type[] | Type object instanceof expectedType)) { const expectedType = Arrays.coerce(assert.toBeOneOf).map(it => it.name).join(' or '); const actualType = object.constructor.name; throw Error(`[AssertError] Object not of the expected type [expected=${expectedType}, actual=${actualType}].`); diff --git a/projects/scion/workbench/src/lib/common/uuid.util.ts b/projects/scion/workbench/src/lib/common/uuid.util.ts new file mode 100644 index 000000000..def2596b4 --- /dev/null +++ b/projects/scion/workbench/src/lib/common/uuid.util.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 {UUID} from '@scion/toolkit/uuid'; + +/** + * Format of a UUID (universally unique identifier) compliant with the RFC 4122 version 4. + */ +export type UUID = `${string}-${string}-${string}-${string}-${string}`; + +/** + * Generates a UUID (universally unique identifier) compliant with the RFC 4122 version 4. + */ +export function randomUUID(): UUID { + return UUID.randomUUID() as UUID; +} diff --git "a/projects/scion/workbench/src/lib/dialog/\311\265workbench-dialog.ts" "b/projects/scion/workbench/src/lib/dialog/\311\265workbench-dialog.ts" index 441f09a92..63e56f02b 100644 --- "a/projects/scion/workbench/src/lib/dialog/\311\265workbench-dialog.ts" +++ "b/projects/scion/workbench/src/lib/dialog/\311\265workbench-dialog.ts" @@ -33,7 +33,7 @@ import {WorkbenchDialogHeaderDirective} from './dialog-header/workbench-dialog-h import {Disposable} from '../common/disposable'; import {Blockable} from '../glass-pane/blockable'; import {Blocking} from '../glass-pane/blocking'; -import {UUID} from '@scion/toolkit/uuid'; +import {randomUUID} from '../common/uuid.util'; /** @inheritDoc */ export class ɵWorkbenchDialog implements WorkbenchDialog, Blockable, Blocking { @@ -57,7 +57,7 @@ export class ɵWorkbenchDialog implements WorkbenchDialog, Block /** * Unique identity of this dialog. */ - public readonly id = UUID.randomUUID(); + public readonly id = randomUUID(); /** * Indicates whether this dialog is blocked by other dialog(s) that overlay this dialog. */ diff --git a/projects/scion/workbench/src/lib/filter-field/filter-field.component.ts b/projects/scion/workbench/src/lib/filter-field/filter-field.component.ts index 71d4d2983..7dcc2d2f9 100644 --- a/projects/scion/workbench/src/lib/filter-field/filter-field.component.ts +++ b/projects/scion/workbench/src/lib/filter-field/filter-field.component.ts @@ -13,8 +13,8 @@ import {ControlValueAccessor, FormControl, NG_VALUE_ACCESSOR, ReactiveFormsModul import {noop} from 'rxjs'; import {coerceBooleanProperty} from '@angular/cdk/coercion'; import {FocusMonitor, FocusOrigin} from '@angular/cdk/a11y'; -import {UUID} from '@scion/toolkit/uuid'; import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; +import {randomUUID} from '../common/uuid.util'; /** * Provides a filter control. @@ -35,7 +35,7 @@ export class FilterFieldComponent implements ControlValueAccessor, OnDestroy { private _cvaChangeFn: (value: any) => void = noop; private _cvaTouchedFn: () => void = noop; - public readonly id = UUID.randomUUID(); + public readonly id = randomUUID; /** * Sets focus order in sequential keyboard navigation. diff --git a/projects/scion/workbench/src/lib/layout/migration/model/workbench-layout-migration-v4.model.ts b/projects/scion/workbench/src/lib/layout/migration/model/workbench-layout-migration-v4.model.ts new file mode 100644 index 000000000..a265eaa19 --- /dev/null +++ b/projects/scion/workbench/src/lib/layout/migration/model/workbench-layout-migration-v4.model.ts @@ -0,0 +1,46 @@ +/* + * 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 MPartV4 { + type: 'MPart'; + id: string; + views: MViewV4[]; + activeViewId?: ViewIdV4; + structural: boolean; +} + +export interface MTreeNodeV4 { + type: 'MTreeNode'; + nodeId: string; + child1: MTreeNodeV4 | MPartV4; + child2: MTreeNodeV4 | MPartV4; + ratio: number; + direction: 'column' | 'row'; +} + +export interface MPartGridV4 { + root: MTreeNodeV4 | MPartV4; + activePartId: string; +} + +export interface MViewV4 { + id: ViewIdV4; + alternativeId?: string; + uid: string; + cssClass?: string[]; + markedForRemoval?: true; + navigation?: { + hint?: string; + cssClass?: string[]; + }; +} + +export type ViewIdV4 = `view.${number}`; + diff --git a/projects/scion/workbench/src/lib/layout/migration/workbench-layout-migration-v4.service.ts b/projects/scion/workbench/src/lib/layout/migration/workbench-layout-migration-v4.service.ts new file mode 100644 index 000000000..d26403e2f --- /dev/null +++ b/projects/scion/workbench/src/lib/layout/migration/workbench-layout-migration-v4.service.ts @@ -0,0 +1,62 @@ +/* + * 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 {MPartGridV3, MPartV3, MTreeNodeV3, MViewV3} from './model/workbench-layout-migration-v3.model'; +import {WorkbenchMigration} from '../../migration/workbench-migration'; +import {MPartGridV4, MPartV4, MTreeNodeV4, MViewV4} from './model/workbench-layout-migration-v4.model'; +import {randomUUID} from '../../common/uuid.util'; + +/** + * Migrates the workbench layout from version 3 to version 4. + * + * TODO [Angular 20] Remove migrator. + */ +@Injectable({providedIn: 'root'}) +export class WorkbenchLayoutMigrationV4 implements WorkbenchMigration { + + public migrate(json: string): string { + const partGridV3: MPartGridV3 = JSON.parse(json); + + // Migrate the grid. + const partGridV4: MPartGridV4 = { + ...partGridV3, + root: migrateGridElement(partGridV3.root), + }; + return JSON.stringify(partGridV4); + + function migrateGridElement(elementV3: MTreeNodeV3 | MPartV3): MTreeNodeV4 | MPartV4 { + switch (elementV3.type) { + case 'MTreeNode': + return migrateNode(elementV3); + case 'MPart': + return migratePart(elementV3); + default: + throw Error(`[WorkbenchLayoutError] Unable to migrate to the latest version. Expected element to be of type 'MPart' or 'MTreeNode'. [version=3, element=${JSON.stringify(elementV3)}]`); + } + } + + function migrateNode(nodeV3: MTreeNodeV3): MTreeNodeV4 { + return { + ...nodeV3, + child1: migrateGridElement(nodeV3.child1), + child2: migrateGridElement(nodeV3.child2), + }; + } + + function migratePart(partV3: MPartV3): MPartV4 { + return {...partV3, views: partV3.views.map(migrateView)}; + } + + function migrateView(viewV3: MViewV3): MViewV4 { + return {...viewV3, uid: randomUUID()}; + } + } +} 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 b8c342acb..2ea47982f 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 @@ -11,7 +11,7 @@ import {TestBed} from '@angular/core/testing'; import {provideRouter, Router, RouterOutlet} from '@angular/router'; import {WorkbenchRouter} from '../routing/workbench-router.service'; -import {toEqualWorkbenchLayoutCustomMatcher} from '../testing/jasmine/matcher/to-equal-workbench-layout.matcher'; +import {MPart, MTreeNode, toEqualWorkbenchLayoutCustomMatcher} from '../testing/jasmine/matcher/to-equal-workbench-layout.matcher'; import {toBeRegisteredCustomMatcher} from '../testing/jasmine/matcher/to-be-registered.matcher'; import {WorkbenchLayoutComponent} from './workbench-layout.component'; import {Component} from '@angular/core'; @@ -22,7 +22,6 @@ 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 {segments, styleFixture, waitForInitialWorkbenchLayout, waitUntilStable} from '../testing/testing.util'; -import {MPart, MTreeNode} from './workbench-layout.model'; import {WorkbenchPartRegistry} from '../part/workbench-part.registry'; import {WORKBENCH_ID} from '../workbench-id'; import {provideWorkbenchForTest} from '../testing/workbench.provider'; 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 f8f5b65bd..0a716c1ed 100644 --- a/projects/scion/workbench/src/lib/layout/workbench-layout.model.ts +++ b/projects/scion/workbench/src/lib/layout/workbench-layout.model.ts @@ -11,6 +11,7 @@ import {assertType} from '../common/asserts.util'; import {Defined} from '@scion/toolkit/util'; import {ViewId} from '../view/workbench-view.model'; +import {UUID} from '../common/uuid.util'; /** * Represents the arrangement of parts as grid. @@ -53,7 +54,7 @@ export class MTreeNode { public direction!: 'column' | 'row'; public parent?: MTreeNode; - constructor(treeNode: Partial>) { + constructor(treeNode: Omit) { treeNode.parent && assertType(treeNode.parent, {toBeOneOf: MTreeNode}); // assert not to be an object literal assertType(treeNode.child1, {toBeOneOf: [MTreeNode, MPart]}); // assert not to be an object literal assertType(treeNode.child2, {toBeOneOf: [MTreeNode, MPart]}); // assert not to be an object literal @@ -83,11 +84,11 @@ export class MPart { public readonly type = 'MPart'; public readonly id!: string; public parent?: MTreeNode; - public views: MView[] = []; + public views!: MView[]; public activeViewId?: ViewId; public structural!: boolean; - constructor(part: Partial>) { + constructor(part: Omit) { Defined.orElseThrow(part.id, () => Error('MPart requires an id')); part.parent && assertType(part.parent, {toBeOneOf: MTreeNode}); // assert not to be an object literal Object.assign(this, part); @@ -109,7 +110,9 @@ export class MPart { export interface MView { id: ViewId; alternativeId?: string; + uid: UUID; cssClass?: string[]; + markedForRemoval?: true; 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 0c492e034..9fb960559 100644 --- a/projects/scion/workbench/src/lib/layout/workbench-layout.spec.ts +++ b/projects/scion/workbench/src/lib/layout/workbench-layout.spec.ts @@ -8,16 +8,16 @@ * SPDX-License-Identifier: EPL-2.0 */ -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'; +import {MPart, MTreeNode, toEqualWorkbenchLayoutCustomMatcher} from '../testing/jasmine/matcher/to-equal-workbench-layout.matcher'; 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'; +import {anything, segments} from '../testing/testing.util'; +import {MPart as _MPart, MTreeNode as _MTreeNode, MView} from './workbench-layout.model'; describe('WorkbenchLayout', () => { @@ -685,30 +685,30 @@ describe('WorkbenchLayout', () => { const workbenchLayout = TestBed.inject(ɵWorkbenchLayoutFactory).create({workbenchGrid: serializedLayout.workbenchGrid, mainAreaGrid: serializedLayout.mainAreaGrid}); // verify the main area root node. - const rootNode = workbenchLayout.mainAreaGrid!.root as MTreeNode; - expect(rootNode.constructor).toEqual(MTreeNode); + const rootNode = workbenchLayout.mainAreaGrid!.root as _MTreeNode; + expect(rootNode).toBeInstanceOf(_MTreeNode); expect(rootNode.parent).toBeUndefined(); // verify the left sashbox - const bcNode = rootNode.child1 as MTreeNode; - expect(bcNode.constructor).toEqual(MTreeNode); + const bcNode = rootNode.child1 as _MTreeNode; + expect(bcNode).toBeInstanceOf(_MTreeNode); expect(bcNode.parent).toBe(rootNode); // verify the 'B' part - const topLeftPart = bcNode.child1 as MPart; - expect(topLeftPart.constructor).toEqual(MPart); + const topLeftPart = bcNode.child1 as _MPart; + expect(topLeftPart).toBeInstanceOf(_MPart); expect(topLeftPart.parent).toBe(bcNode); expect(topLeftPart.id).toEqual('B'); // verify the 'C' part - const bottomLeftPart = bcNode.child2 as MPart; - expect(bottomLeftPart.constructor).toEqual(MPart); + const bottomLeftPart = bcNode.child2 as _MPart; + expect(bottomLeftPart).toBeInstanceOf(_MPart); expect(bottomLeftPart.parent).toBe(bcNode); expect(bottomLeftPart.id).toEqual('C'); // verify the initial part - const initialPart = rootNode.child2 as MPart; - expect(initialPart.constructor).toEqual(MPart); + const initialPart = rootNode.child2 as _MPart; + expect(initialPart).toBeInstanceOf(_MPart); expect(initialPart.parent).toBe(rootNode); expect(initialPart.id).toEqual('A'); }); @@ -763,12 +763,12 @@ describe('WorkbenchLayout', () => { .addView('view.3', {partId: 'A'}) .addView('view.4', {partId: 'C'}); - 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'}]); + expect(workbenchLayout.part({partId: 'B'}).views.map(view => view.id)).toEqual(['view.1', 'view.2']); + expect(workbenchLayout.part({partId: 'A'}).views.map(view => view.id)).toEqual(['view.3']); + expect(workbenchLayout.part({partId: 'C'}).views.map(view => view.id)).toEqual(['view.4']); }); - it('should remove non-structural part when removing its last view', () => { + it('should remove non-structural part when removing its last view', async () => { TestBed.overrideProvider(MAIN_AREA_INITIAL_PART_ID, {useValue: 'main'}); const workbenchLayout = TestBed.inject(ɵWorkbenchLayoutFactory) @@ -776,8 +776,8 @@ describe('WorkbenchLayout', () => { .addPart('left', {relativeTo: 'main', align: 'left'}, {structural: false}) .addView('view.1', {partId: 'left'}) .addView('view.2', {partId: 'left'}) - .removeView('view.1') - .removeView('view.2'); + .removeView('view.1', {force: true}) + .removeView('view.2', {force: true}); expect(() => workbenchLayout.part({partId: 'left'})).toThrowError(/NullPartError/); expect(workbenchLayout.hasPart('left')).toBeFalse(); @@ -980,9 +980,9 @@ describe('WorkbenchLayout', () => { .moveView('view.2', 'C') .moveView('view.3', 'C'); - 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'}]); + expect(workbenchLayout.part({partId: 'B'}).views.map(view => view.id)).toEqual(['view.1']); + expect(workbenchLayout.part({partId: 'C'}).views.map(view => view.id)).toEqual(['view.2', 'view.3']); + expect(workbenchLayout.part({partId: 'A'}).views.map(view => view.id)).toEqual(['view.4']); }); it('should retain navigation when moving view to another part', () => { @@ -996,9 +996,9 @@ describe('WorkbenchLayout', () => { .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.part({partId: 'right'}).views.map(view => ({...view, uid: 23}))).toEqual(jasmine.arrayWithExactContents([ + {id: 'view.1', navigation: {cssClass: ['class-navigation']}, cssClass: ['class-view'], uid: anything()} satisfies MView, + {id: 'view.2', navigation: {hint: 'some-hint'}, uid: anything()} satisfies MView, ])); expect(workbenchLayout.urlSegments({viewId: 'view.1'})).toEqual(segments(['path/to/view'])); expect(workbenchLayout.urlSegments({viewId: 'view.2'})).toEqual([]); @@ -1012,7 +1012,7 @@ describe('WorkbenchLayout', () => { .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.part({partId: 'right'}).views).toEqual([{id: 'view.1', navigation: {}, uid: anything()} satisfies MView]); expect(workbenchLayout.viewState({viewId: 'view.1'})).toEqual({some: 'state'}); }); @@ -1023,7 +1023,7 @@ describe('WorkbenchLayout', () => { .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.view({viewId: 'view.1'})).toEqual({id: 'view.1', navigation: {}, uid: anything()} satisfies MView); expect(workbenchLayout.urlSegments({viewId: 'view.1'})).toEqual(segments(['path/to/view'])); }); @@ -1034,7 +1034,7 @@ describe('WorkbenchLayout', () => { .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.view({viewId: 'view.1'})).toEqual({id: 'view.1', navigation: {hint: 'some-hint'}, uid: anything()} satisfies MView); expect(workbenchLayout.urlSegments({viewId: 'view.1'})).toEqual([]); }); @@ -1045,7 +1045,7 @@ describe('WorkbenchLayout', () => { .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.view({viewId: 'view.1'})).toEqual({id: 'view.1', navigation: {}, uid: anything()} satisfies MView); expect(workbenchLayout.viewState({viewId: 'view.1'})).toEqual({}); expect(workbenchLayout.urlSegments({viewId: 'view.1'})).toEqual(segments(['path/to/view'])); }); @@ -1068,7 +1068,7 @@ describe('WorkbenchLayout', () => { .addPart('part') .addView('view.1', {partId: 'part'}) .navigateView('view.1', ['path/to/view'], {state: {some: 'state'}}) - .removeView('view.1'); + .removeView('view.1', {force: true}); expect(workbenchLayout.view({viewId: 'view.1'}, {orElse: null})).toBeNull(); expect(workbenchLayout.viewState({viewId: 'view.1'})).toEqual({}); @@ -1217,8 +1217,8 @@ describe('WorkbenchLayout', () => { .moveView('view.3', 'C'); expect(workbenchLayout.hasPart('B')).toBeFalse(); - expect(workbenchLayout.part({partId: 'A'}).views).toEqual([{id: 'view.1'}, {id: 'view.2'}]); - expect(workbenchLayout.part({partId: 'C'}).views).toEqual([{id: 'view.3'}]); + expect(workbenchLayout.part({partId: 'A'}).views.map(view => view.id)).toEqual(['view.1', 'view.2']); + expect(workbenchLayout.part({partId: 'C'}).views.map(view => view.id)).toEqual(['view.3']); }); /** @@ -1244,12 +1244,12 @@ describe('WorkbenchLayout', () => { .moveView('view.2', 'A') .moveView('view.3', 'C'); - 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'}]); + expect(workbenchLayout.part({partId: 'B'}).id).toEqual('B'); + expect(workbenchLayout.part({partId: 'A'}).views.map(view => view.id)).toEqual(['view.1', 'view.2']); + expect(workbenchLayout.part({partId: 'C'}).views.map(view => view.id)).toEqual(['view.3']); }); - it('should activate the most recently activated view when removing a view', () => { + it('should activate the most recently activated view when removing a view', async () => { TestBed.overrideProvider(MAIN_AREA_INITIAL_PART_ID, {useValue: 'main'}); const viewActivationInstantProviderSpyObj = installViewActivationInstantProviderSpyObj(); @@ -1271,16 +1271,16 @@ describe('WorkbenchLayout', () => { workbenchLayout = workbenchLayout .activateView('view.1') - .removeView('view.1'); + .removeView('view.1', {force: true}); expect(workbenchLayout.part({partId: 'main'}).activeViewId).toEqual('view.4'); - workbenchLayout = workbenchLayout.removeView('view.4'); + workbenchLayout = workbenchLayout.removeView('view.4', {force: true}); expect(workbenchLayout.part({partId: 'main'}).activeViewId).toEqual('view.2'); - workbenchLayout = workbenchLayout.removeView('view.2'); + workbenchLayout = workbenchLayout.removeView('view.2', {force: true}); expect(workbenchLayout.part({partId: 'main'}).activeViewId).toEqual('view.5'); - workbenchLayout = workbenchLayout.removeView('view.5'); + workbenchLayout = workbenchLayout.removeView('view.5', {force: true}); expect(workbenchLayout.part({partId: 'main'}).activeViewId).toEqual('view.3'); }); @@ -1568,10 +1568,14 @@ describe('WorkbenchLayout', () => { workbenchLayout = workbenchLayout.addView('view.6', {partId: 'main'}); expect(workbenchLayout.computeNextViewId()).toEqual('view.7'); - workbenchLayout = workbenchLayout.removeView('view.3'); + workbenchLayout = workbenchLayout.removeView('view.3'); // marked for removal + expect(workbenchLayout.computeNextViewId()).toEqual('view.7'); + + workbenchLayout = workbenchLayout.removeView('view.3'); // marked for removal + workbenchLayout = await workbenchLayout.removeViewsMarkedForRemoval(); expect(workbenchLayout.computeNextViewId()).toEqual('view.3'); - workbenchLayout = workbenchLayout.removeView('view.1'); + workbenchLayout = workbenchLayout.removeView('view.1', {force: true}); expect(workbenchLayout.computeNextViewId()).toEqual('view.1'); workbenchLayout = workbenchLayout.addView('view.1', {partId: 'main'}); @@ -1581,6 +1585,91 @@ describe('WorkbenchLayout', () => { expect(workbenchLayout.computeNextViewId()).toEqual('view.7'); }); + it('should remove view', async () => { + TestBed.overrideProvider(MAIN_AREA_INITIAL_PART_ID, {useValue: 'main'}); + + const workbenchLayout = TestBed.inject(ɵWorkbenchLayoutFactory) + .addPart(MAIN_AREA) + .addView('view.1', {partId: 'main'}) + .addView('view.2', {partId: 'main'}) + .addView('view.3', {partId: 'main'}) + .removeView('view.2', {force: true}); + + expect(workbenchLayout.view({viewId: 'view.1'}, {orElse: null})).toBeDefined(); + expect(workbenchLayout.view({viewId: 'view.2'}, {orElse: null})).toBeNull(); + expect(workbenchLayout.view({viewId: 'view.3'}, {orElse: null})).toBeDefined(); + }); + + it('should mark view for removal', async () => { + TestBed.overrideProvider(MAIN_AREA_INITIAL_PART_ID, {useValue: 'main'}); + + let workbenchLayout = TestBed.inject(ɵWorkbenchLayoutFactory) + .addPart(MAIN_AREA) + .addView('view.1', {partId: 'main'}) + .addView('view.2', {partId: 'main'}) + .addView('view.3', {partId: 'main'}); + + // Mark views for removal. + workbenchLayout = workbenchLayout.removeView('view.1'); + workbenchLayout = workbenchLayout.removeView('view.2'); + + // Expect views not to be removed, but marked for removal. + const view1 = workbenchLayout.view({viewId: 'view.1'}); + const view2 = workbenchLayout.view({viewId: 'view.2'}); + const view3 = workbenchLayout.view({viewId: 'view.3'}); + + expect(view1.markedForRemoval).toBeTrue(); + expect(view2.markedForRemoval).toBeTrue(); + expect(view3.markedForRemoval).toBeUndefined(); + + // Remove views marked for removal if guard returns true. + workbenchLayout = await workbenchLayout.removeViewsMarkedForRemoval(viewUid => view2.uid === viewUid); + + // Expect views to be removed. + expect(workbenchLayout.view({viewId: 'view.1'}, {orElse: null})).toEqual(view1); + expect(workbenchLayout.view({viewId: 'view.2'}, {orElse: null})).toBeNull(); + expect(workbenchLayout.view({viewId: 'view.3'}, {orElse: null})).toEqual(view3); + + // Remove views marked for removal. + workbenchLayout = await workbenchLayout.removeViewsMarkedForRemoval(); + + // Expect views to be removed. + expect(workbenchLayout.view({viewId: 'view.1'}, {orElse: null})).toBeNull(); + expect(workbenchLayout.view({viewId: 'view.2'}, {orElse: null})).toBeNull(); + expect(workbenchLayout.view({viewId: 'view.3'}, {orElse: null})).toEqual(view3); + }); + + it('should not serialize `markedForRemoval` flag', async () => { + TestBed.overrideProvider(MAIN_AREA_INITIAL_PART_ID, {useValue: 'main'}); + + const workbenchLayout = TestBed.inject(ɵWorkbenchLayoutFactory) + .addPart(MAIN_AREA) + .addView('view.1', {partId: 'main'}) + .addView('view.2', {partId: 'main'}) + .addView('view.3', {partId: 'main'}); + + // Remove view and serialize the layout. + const serializedLayout = workbenchLayout + .removeView('view.2') + .serialize(); + + const deserializedLayout = TestBed.inject(ɵWorkbenchLayoutFactory).create({workbenchGrid: serializedLayout.workbenchGrid, mainAreaGrid: serializedLayout.mainAreaGrid}); + + const view1 = deserializedLayout.view({viewId: 'view.1'}); + const view2 = deserializedLayout.view({viewId: 'view.2'}); + const view3 = deserializedLayout.view({viewId: 'view.3'}); + + // Expect views not to be removed. + expect(view1).toBeDefined(); + expect(view2).toBeDefined(); + expect(view3).toBeDefined(); + + // Expect views not to be marked for removal. + expect(view1.markedForRemoval).toBeUndefined(); + expect(view2.markedForRemoval).toBeUndefined(); + expect(view3.markedForRemoval).toBeUndefined(); + }); + it('should find parts by criteria', () => { TestBed.overrideProvider(MAIN_AREA_INITIAL_PART_ID, {useValue: 'main'}); 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 6da926f60..603ac3f6a 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 @@ -10,13 +10,14 @@ import {inject, Injectable} from '@angular/core'; import {MPart, MPartGrid, MTreeNode, ɵMPartGrid} from './workbench-layout.model'; -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'; +import {WorkbenchLayoutMigrationV4} from './migration/workbench-layout-migration-v4.service'; +import {randomUUID} from '../common/uuid.util'; /** * Serializes and deserializes a base64-encoded JSON into a {@link MPartGrid}. @@ -26,7 +27,8 @@ export class WorkbenchLayoutSerializer { private _workbenchLayoutMigrator = new WorkbenchMigrator() .registerMigration(1, inject(WorkbenchLayoutMigrationV2)) - .registerMigration(2, inject(WorkbenchLayoutMigrationV3)); + .registerMigration(2, inject(WorkbenchLayoutMigrationV3)) + .registerMigration(3, inject(WorkbenchLayoutMigrationV4)); /** * Serializes the given grid into a URL-safe base64 string. @@ -34,10 +36,11 @@ export class WorkbenchLayoutSerializer { * @param grid - Specifies the grid to be serialized. * @param options - Controls the serialization. * @param options.includeNodeId - Controls if to include the `nodeId`. By default, if not set, the `nodeId` is excluded from serialization. + * @param options.includeMarkRemovedFlag - Controls if to include the `markedForRemoval` flag. By default, if not set, the `markedForRemoval` is excluded from serialization. */ - 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 { + public serializeGrid(grid: MPartGrid, options?: {includeNodeId?: boolean; includeMarkedForRemovalFlag?: boolean}): string; + public serializeGrid(grid: MPartGrid | undefined | null, options?: {includeNodeId?: boolean; includeMarkedForRemovalFlag?: boolean}): null | string; + public serializeGrid(grid: MPartGrid | undefined | null, options?: {includeNodeId?: boolean; includeMarkedForRemovalFlag?: boolean}): string | null { if (grid === null || grid === undefined) { return null; } @@ -46,6 +49,9 @@ export class WorkbenchLayoutSerializer { if (!options?.includeNodeId) { transientFields.add('nodeId'); } + if (!options?.includeMarkedForRemovalFlag) { + transientFields.add('markedForRemoval'); + } const json = JSON.stringify(grid, (key, value) => { return transientFields.has(key) ? undefined : value; @@ -67,7 +73,7 @@ export class WorkbenchLayoutSerializer { return new MPart(value); // create a class object from the object literal } if (MTreeNode.isMTreeNode(value)) { - return new MTreeNode({...value, nodeId: value.nodeId ?? UUID.randomUUID()}); // create a class object from the object literal + return new MTreeNode({...value, nodeId: value.nodeId ?? randomUUID()}); // create a class object from the object literal } return value; }); @@ -115,7 +121,7 @@ export class WorkbenchLayoutSerializer { * * @see WorkbenchMigrator */ -export const WORKBENCH_LAYOUT_VERSION = 3; +export const WORKBENCH_LAYOUT_VERSION = 4; /** * Fields not serialized into JSON representation. 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 d3338561d..20da3184d 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" @@ -29,7 +29,7 @@ export class ɵWorkbenchLayoutFactory implements WorkbenchLayoutFactory { */ public addPart(id: string | MAIN_AREA): ɵWorkbenchLayout { return this.create({ - workbenchGrid: {root: new MPart({id, structural: true}), activePartId: id}, + workbenchGrid: {root: new MPart({id, structural: true, views: []}), activePartId: id}, }); } 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 ff46a2109..a2a2e73fa 100644 --- "a/projects/scion/workbench/src/lib/layout/\311\265workbench-layout.ts" +++ "b/projects/scion/workbench/src/lib/layout/\311\265workbench-layout.ts" @@ -9,7 +9,7 @@ */ import {MPart, MPartGrid, MTreeNode, MView, ɵMPartGrid} from './workbench-layout.model'; import {assertType} from '../common/asserts.util'; -import {UUID} from '@scion/toolkit/uuid'; +import {randomUUID, UUID} from '../common/uuid.util'; import {MAIN_AREA, ReferencePart, WorkbenchLayout} from './workbench-layout'; import {WorkbenchLayoutSerializer} from './workench-layout-serializer.service'; import {WorkbenchViewRegistry} from '../view/workbench-view.registry'; @@ -272,12 +272,13 @@ export class ɵWorkbenchLayout implements WorkbenchLayout { * @param findBy.partId - Searches for views contained in the specified part. * @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.markedForRemoval - Searches for views marked (or not marked) for removal. * @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; partId?: string; segments?: UrlSegmentMatcher; navigationHint?: string | null; grid?: keyof Grids}, options?: {orElse: 'throwError'}): readonly MView[] { + public views(findBy?: {id?: string; partId?: string; segments?: UrlSegmentMatcher; navigationHint?: string | null; markedForRemoval?: boolean; grid?: keyof Grids}, options?: {orElse: 'throwError'}): readonly MView[] { const views = this.parts({grid: findBy?.grid}) .filter(part => { if (findBy?.partId !== undefined && part.id !== findBy.partId) { @@ -296,6 +297,9 @@ export class ɵWorkbenchLayout implements WorkbenchLayout { if (findBy?.navigationHint !== undefined && findBy.navigationHint !== (view.navigation?.hint ?? null)) { return false; } + if (findBy?.markedForRemoval !== undefined && findBy.markedForRemoval !== (view.markedForRemoval ?? false)) { + return false; + } return true; }); @@ -311,10 +315,10 @@ export class ɵWorkbenchLayout implements WorkbenchLayout { 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); + workingCopy.__addView({id, uid: randomUUID()}, options); } else { - workingCopy.__addView({id: this.computeNextViewId(), alternativeId: id}, options); + workingCopy.__addView({id: this.computeNextViewId(), alternativeId: id, uid: randomUUID()}, options); } return workingCopy; } @@ -330,10 +334,27 @@ export class ɵWorkbenchLayout implements WorkbenchLayout { /** * @inheritDoc + * + * @param id - @inheritDoc + * @param options - Controls removal of the view. + * @param options.force - Specifies whether to force remove the view, bypassing `CanClose` guard. + */ + public removeView(id: string, options?: {force?: boolean}): ɵWorkbenchLayout { + const workingCopy = this.workingCopy(); + workingCopy.views({id}, {orElse: 'throwError'}).forEach(view => workingCopy.__removeView(view, {force: options?.force})); + return workingCopy; + } + + /** + * Removes views marked for removal, optionally invoking the passed `CanClose` function to decide whether to remove a view. */ - public removeView(id: string, options?: {grid?: keyof Grids}): ɵWorkbenchLayout { + public async removeViewsMarkedForRemoval(canCloseFn?: (viewUid: UUID) => Promise | boolean): Promise<ɵWorkbenchLayout> { const workingCopy = this.workingCopy(); - workingCopy.views({id}, {orElse: 'throwError'}).forEach(view => workingCopy.__removeView(view, {grid: options?.grid})); + for (const view of workingCopy.views({markedForRemoval: true})) { + if (!canCloseFn || await canCloseFn(view.uid)) { + workingCopy.__removeView(view, {force: true}); + } + } return workingCopy; } @@ -424,7 +445,7 @@ export class ɵWorkbenchLayout implements WorkbenchLayout { 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}); + const newPart = new MPart({id, structural: options?.structural ?? true, views: []}); // Find the reference element, if specified, or use the layout root as reference otherwise. const referenceElement = relativeTo.relativeTo ? this.findTreeElement({id: relativeTo.relativeTo}) : this.workbenchGrid.root; @@ -433,7 +454,7 @@ export class ɵWorkbenchLayout implements WorkbenchLayout { // Create a new tree node. const newTreeNode: MTreeNode = new MTreeNode({ - nodeId: UUID.randomUUID(), + nodeId: randomUUID(), child1: addBefore ? newPart : referenceElement, child2: addBefore ? referenceElement : newPart, direction: relativeTo.align === 'left' || relativeTo.align === 'right' ? 'row' : 'column', @@ -567,7 +588,7 @@ export class ɵWorkbenchLayout implements WorkbenchLayout { // Move the view. if (sourcePart !== targetPart) { - this.__removeView(view, {removeOutlet: false, removeState: false}); + this.__removeView(view, {removeOutlet: false, removeState: false, force: true}); this.__addView(view, {partId: targetPartId, position: options?.position}); } else if (options?.position !== undefined) { @@ -589,8 +610,13 @@ export class ɵWorkbenchLayout implements WorkbenchLayout { /** * Note: This method name begins with underscores, indicating that it does not operate on a working copy, but modifies this layout instead. */ - private __removeView(view: MView, options?: {grid?: keyof Grids; removeOutlet?: false; removeState?: false}): void { - const part = this.part({viewId: view.id, grid: options?.grid}); + private __removeView(view: MView, options?: {removeOutlet?: false; removeState?: false; force?: boolean}): void { + if (!options?.force) { + view.markedForRemoval = true; + return; + } + + const part = this.part({viewId: view.id}); // Remove view. part.views.splice(part.views.indexOf(view), 1); @@ -792,8 +818,8 @@ export class ɵWorkbenchLayout implements WorkbenchLayout { */ private workingCopy(): ɵWorkbenchLayout { return runInInjectionContext(this._injector, () => new ɵWorkbenchLayout({ - workbenchGrid: this._serializer.serializeGrid(this.workbenchGrid, {includeNodeId: true}), - mainAreaGrid: this._serializer.serializeGrid(this._grids.mainArea, {includeNodeId: true}), + workbenchGrid: this._serializer.serializeGrid(this.workbenchGrid, {includeMarkedForRemovalFlag: true, includeNodeId: true}), + mainAreaGrid: this._serializer.serializeGrid(this._grids.mainArea, {includeMarkedForRemovalFlag: true, includeNodeId: true}), viewOutlets: Object.fromEntries(this._viewOutlets), viewStates: Object.fromEntries(this._viewStates), maximized: this._maximized, @@ -806,7 +832,7 @@ export class ɵWorkbenchLayout implements WorkbenchLayout { */ function createDefaultWorkbenchGrid(): MPartGrid { return { - root: new MPart({id: MAIN_AREA, structural: true}), + root: new MPart({id: MAIN_AREA, structural: true, views: []}), activePartId: MAIN_AREA, }; } @@ -818,7 +844,7 @@ function createDefaultWorkbenchGrid(): MPartGrid { */ function createInitialMainAreaGrid(): MPartGrid { return { - root: new MPart({id: inject(MAIN_AREA_INITIAL_PART_ID)}), + root: new MPart({id: inject(MAIN_AREA_INITIAL_PART_ID), structural: false, views: []}), activePartId: inject(MAIN_AREA_INITIAL_PART_ID), }; } @@ -928,7 +954,7 @@ export interface ReferenceElement extends ReferencePart { */ export const MAIN_AREA_INITIAL_PART_ID = new InjectionToken('MAIN_AREA_INITIAL_PART_ID', { providedIn: 'root', - factory: () => UUID.randomUUID(), + factory: () => randomUUID(), }); /** 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 6050ebbfd..5d2305830 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 @@ -10,13 +10,13 @@ import {ChangeDetectorRef, Component, CUSTOM_ELEMENTS_SCHEMA, DestroyRef, ElementRef, inject, Inject, Injector, OnDestroy, OnInit, Provider, runInInjectionContext, ViewChild} from '@angular/core'; import {ActivatedRoute, Params} from '@angular/router'; -import {combineLatest, EMPTY, firstValueFrom, Observable, of, Subject, switchMap} from 'rxjs'; -import {catchError, first, map, takeUntil} from 'rxjs/operators'; +import {combineLatest, firstValueFrom, Observable, of, Subject, switchMap} from 'rxjs'; +import {first, map, takeUntil} from 'rxjs/operators'; import {ManifestService, mapToBody, MessageClient, MessageHeaders, MicrofrontendPlatformConfig, OutletRouter, ResponseStatusCodes, SciRouterOutletElement, TopicMessage} from '@scion/microfrontend-platform'; import {WorkbenchViewCapability, ɵMicrofrontendRouteParams, ɵVIEW_ID_CONTEXT_KEY, ɵViewParamsUpdateCommand, ɵWorkbenchCommands} from '@scion/workbench-client'; import {Dictionaries, Maps} from '@scion/toolkit/util'; import {Logger, LoggerNames} from '../../logging'; -import {WorkbenchViewPreDestroy} from '../../workbench.model'; +import {CanClose} from '../../workbench.model'; import {IFRAME_HOST, ViewContainerReference} from '../../content-projection/view-container.reference'; import {serializeExecution} from '../../common/operators'; import {ɵWorkbenchView} from '../../view/ɵworkbench-view.model'; @@ -56,7 +56,7 @@ import {Objects} from '../../common/objects.util'; ], schemas: [CUSTOM_ELEMENTS_SCHEMA], // required because is a custom element }) -export class MicrofrontendViewComponent implements OnInit, OnDestroy, WorkbenchViewPreDestroy { +export class MicrofrontendViewComponent implements OnInit, OnDestroy, CanClose { private _unsubscribeParamsUpdater$ = new Subject(); private _universalKeystrokes = [ @@ -248,23 +248,18 @@ export class MicrofrontendViewComponent implements OnInit, OnDestroy, WorkbenchV await firstValueFrom(viewParams$); } - /** - * Method invoked just before closing this view. - * - * If the embedded microfrontend has a listener installed to be notified when closing this view, - * initiates a request-reply communication, allowing the microfrontend to prevent this view from closing. - */ - public async onWorkbenchViewPreDestroy(): Promise { - const closingTopic = ɵWorkbenchCommands.viewClosingTopic(this.view.id); + /** @inheritDoc */ + public async canClose(): Promise { + const canCloseTopic = ɵWorkbenchCommands.canCloseTopic(this.view.id); + const legacyCanCloseTopic = ɵWorkbenchCommands.viewClosingTopic(this.view.id); - // Allow the microfrontend to prevent this view from closing. - const count = await firstValueFrom(this._messageClient.subscriberCount$(closingTopic)); - if (count === 0) { + // Initiate a request-response communication only if the embedded microfrontend implements `CanClose` guard. + const hasCanCloseGuard = await firstValueFrom(this._messageClient.subscriberCount$(canCloseTopic)) > 0; + const hasLegacyCanCloseGuard = await firstValueFrom(this._messageClient.subscriberCount$(legacyCanCloseTopic)) > 0; + if (!hasCanCloseGuard && !hasLegacyCanCloseGuard) { return true; } - - const doit = this._messageClient.request$(closingTopic).pipe(mapToBody(), catchError(() => EMPTY)); - return firstValueFrom(doit, {defaultValue: true}); + return firstValueFrom(this._messageClient.request$(hasCanCloseGuard ? canCloseTopic : legacyCanCloseTopic).pipe(mapToBody()), {defaultValue: true}); } public onFocusWithin(event: Event): void { 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 e4830dd60..3b5f35131 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 @@ -9,10 +9,9 @@ */ import {TestBed} from '@angular/core/testing'; -import {toEqualWorkbenchLayoutCustomMatcher} from '../testing/jasmine/matcher/to-equal-workbench-layout.matcher'; +import {MPart, MTreeNode, toEqualWorkbenchLayoutCustomMatcher} from '../testing/jasmine/matcher/to-equal-workbench-layout.matcher'; 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'; @@ -31,7 +30,7 @@ describe('WorkbenchGridMerger', () => { }); }); - it('should preserve local changes when no diff between base and remote', () => { + it('should preserve local changes when no diff between base and remote', async () => { const base = TestBed.inject(ɵWorkbenchLayoutFactory) .addPart(MAIN_AREA) .addPart('topLeft', {relativeTo: MAIN_AREA, align: 'left', ratio: .25}) @@ -45,7 +44,7 @@ describe('WorkbenchGridMerger', () => { const mergedLayout = TestBed.inject(WorkbenchGridMerger).merge({ local: base - .removeView('view.2') + .removeView('view.2', {force: true}) .addView('view.100', {partId: 'topLeft'}) .navigateView('view.100', ['path/to/view/100']) .navigateView('view.1', ['PATH/TO/VIEW/1']), @@ -96,7 +95,7 @@ describe('WorkbenchGridMerger', () => { /** * 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', () => { + it('should discard local changes when diff between base and remote grids', async () => { const base = TestBed.inject(ɵWorkbenchLayoutFactory) .addPart(MAIN_AREA) .addPart('topLeft', {relativeTo: MAIN_AREA, align: 'left', ratio: .25}) @@ -110,13 +109,13 @@ describe('WorkbenchGridMerger', () => { const mergedLayout = TestBed.inject(WorkbenchGridMerger).merge({ local: base - .removeView('view.2') + .removeView('view.2', {force: true}) .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') + .removeView('view.1', {force: true}) .addView('view.100', {partId: 'bottomLeft'}) .navigateView('view.100', ['PATH/TO/VIEW/100']), }); 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 baf694c86..5a9f30b56 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 @@ -10,13 +10,12 @@ import {TestBed} from '@angular/core/testing'; import {expect} from '../testing/jasmine/matcher/custom-matchers.definition'; -import {toEqualWorkbenchLayoutCustomMatcher} from '../testing/jasmine/matcher/to-equal-workbench-layout.matcher'; +import {MPart, MTreeNode, toEqualWorkbenchLayoutCustomMatcher} from '../testing/jasmine/matcher/to-equal-workbench-layout.matcher'; import {TestComponent} from '../testing/test.component'; import {WorkbenchRouter} from '../routing/workbench-router.service'; import {MAIN_AREA, WorkbenchLayout} from '../layout/workbench-layout'; import {styleFixture, waitForInitialWorkbenchLayout, waitUntilStable} from '../testing/testing.util'; import {WorkbenchLayoutComponent} from '../layout/workbench-layout.component'; -import {MPart, MTreeNode} from '../layout/workbench-layout.model'; import {WorkbenchService} from '../workbench.service'; import {ɵWorkbenchLayoutFactory} from '../layout/ɵworkbench-layout.factory'; import {WorkbenchPerspectiveStorageService} from './workbench-perspective-storage.service'; diff --git a/projects/scion/workbench/src/lib/perspective/workbench-perspective-view-conflict-resolver.spec.ts b/projects/scion/workbench/src/lib/perspective/workbench-perspective-view-conflict-resolver.spec.ts index 482b25b1b..1b6068d47 100644 --- a/projects/scion/workbench/src/lib/perspective/workbench-perspective-view-conflict-resolver.spec.ts +++ b/projects/scion/workbench/src/lib/perspective/workbench-perspective-view-conflict-resolver.spec.ts @@ -10,7 +10,7 @@ import {TestBed} from '@angular/core/testing'; import {expect} from '../testing/jasmine/matcher/custom-matchers.definition'; -import {toEqualWorkbenchLayoutCustomMatcher} from '../testing/jasmine/matcher/to-equal-workbench-layout.matcher'; +import {MPart, MTreeNode, toEqualWorkbenchLayoutCustomMatcher} from '../testing/jasmine/matcher/to-equal-workbench-layout.matcher'; import {By} from '@angular/platform-browser'; import {TestComponent, withComponentContent} from '../testing/test.component'; import {WorkbenchRouter} from '../routing/workbench-router.service'; @@ -18,7 +18,6 @@ import {WorkbenchService} from '../workbench.service'; import {MAIN_AREA} from '../layout/workbench-layout'; import {styleFixture, waitForInitialWorkbenchLayout, waitUntilStable} from '../testing/testing.util'; import {WorkbenchLayoutComponent} from '../layout/workbench-layout.component'; -import {MPart, MTreeNode} from '../layout/workbench-layout.model'; import {provideRouter} from '@angular/router'; import {provideWorkbenchForTest} from '../testing/workbench.provider'; 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 5ea62723f..4f2f466e2 100644 --- a/projects/scion/workbench/src/lib/perspective/workbench-perspective.spec.ts +++ b/projects/scion/workbench/src/lib/perspective/workbench-perspective.spec.ts @@ -17,9 +17,8 @@ import {By} from '@angular/platform-browser'; import {inject} from '@angular/core'; import {expect} from '../testing/jasmine/matcher/custom-matchers.definition'; import {WorkbenchLayoutComponent} from '../layout/workbench-layout.component'; -import {MPart, MTreeNode} from '../layout/workbench-layout.model'; import {delay, of} from 'rxjs'; -import {toEqualWorkbenchLayoutCustomMatcher} from '../testing/jasmine/matcher/to-equal-workbench-layout.matcher'; +import {MPart, MTreeNode, toEqualWorkbenchLayoutCustomMatcher} from '../testing/jasmine/matcher/to-equal-workbench-layout.matcher'; import {MAIN_AREA} from '../layout/workbench-layout'; import {WorkbenchLayoutFactory} from '../layout/workbench-layout.factory'; import {WorkbenchRouter} from '../routing/workbench-router.service'; diff --git a/projects/scion/workbench/src/lib/public_api.ts b/projects/scion/workbench/src/lib/public_api.ts index a876df901..db73a0989 100644 --- a/projects/scion/workbench/src/lib/public_api.ts +++ b/projects/scion/workbench/src/lib/public_api.ts @@ -13,7 +13,7 @@ export {WorkbenchModule, WorkbenchModuleConfig} from './workbench.module'; export {provideWorkbench} from './workbench.provider'; export {WorkbenchService} from './workbench.service'; export {WORKBENCH_ID} from './workbench-id'; -export {WorkbenchViewPreDestroy, WorkbenchPartAction, WorkbenchTheme, CanMatchPartFn, WorkbenchMenuItem, WorkbenchMenuItemFactoryFn} from './workbench.model'; +export {WorkbenchPartAction, WorkbenchTheme, CanMatchPartFn, WorkbenchMenuItem, WorkbenchMenuItemFactoryFn, CanClose} from './workbench.model'; export {WorkbenchComponent} from './workbench.component'; export {VIEW_TAB_RENDERING_CONTEXT, ViewTabRenderingContext} from './workbench.constants'; diff --git a/projects/scion/workbench/src/lib/routing/workbench-auxiliary-routes-registrator.service.ts b/projects/scion/workbench/src/lib/routing/workbench-auxiliary-routes-registrator.service.ts index 64f8d37fe..79f55f252 100644 --- a/projects/scion/workbench/src/lib/routing/workbench-auxiliary-routes-registrator.service.ts +++ b/projects/scion/workbench/src/lib/routing/workbench-auxiliary-routes-registrator.service.ts @@ -9,7 +9,7 @@ */ import {Injectable, InjectionToken} from '@angular/core'; -import {CanDeactivateFn, CanMatchFn, PRIMARY_OUTLET, Route, Router, Routes, ɵEmptyOutletComponent} from '@angular/router'; +import {CanMatchFn, PRIMARY_OUTLET, Route, Router, Routes, ɵEmptyOutletComponent} from '@angular/router'; import {WorkbenchConfig} from '../workbench-config'; import PageNotFoundComponent from '../page-not-found/page-not-found.component'; import {WorkbenchRouteData} from './workbench-route-data'; @@ -43,7 +43,6 @@ export class WorkbenchAuxiliaryRoutesRegistrator { ...route, outlet, providers: [{provide: WORKBENCH_AUXILIARY_ROUTE_OUTLET, useValue: outlet}, ...(route.providers ?? [])], - canDeactivate: [...(config.canDeactivate || []), ...(route.canDeactivate || [])], })); }); @@ -96,10 +95,6 @@ export class WorkbenchAuxiliaryRoutesRegistrator { * Configures auxiliary routes. */ export interface AuxiliaryRouteConfig { - /** - * Specifies "CanDeactivate" guard(s) to install on the auxiliary routes. - */ - canDeactivate?: Array>; /** * Specifies "CanMatch" guard(s) to install on the wildcard route (`**`), * selected by the router if no route matches the requested URL. diff --git a/projects/scion/workbench/src/lib/routing/workbench-url-observer.service.ts b/projects/scion/workbench/src/lib/routing/workbench-url-observer.service.ts index e3e310fd4..9810bd39c 100644 --- a/projects/scion/workbench/src/lib/routing/workbench-url-observer.service.ts +++ b/projects/scion/workbench/src/lib/routing/workbench-url-observer.service.ts @@ -33,7 +33,7 @@ import {RouterUtils} from './router.util'; import {ViewId} from '../view/workbench-view.model'; import {ɵWorkbenchRouter} from './ɵworkbench-router.service'; import {WorkbenchNavigationContext} from './routing.model'; -import {canDeactivateView, canMatchNotFoundPage} from '../view/workbench-view-route-guards'; +import {canMatchNotFoundPage} from '../view/workbench-view-route-guards'; /** * Tracks the browser URL for workbench layout changes. @@ -160,7 +160,6 @@ export class WorkbenchUrlObserver { const registeredRoutes = this._auxiliaryRoutesRegistrator.registerAuxiliaryRoutes(addedViews, { canMatchNotFoundPage: [canMatchNotFoundPage], - canDeactivate: [canDeactivateView], }); if (registeredRoutes.length) { this._logger.debug(() => `Registered auxiliary routes for views: ${addedViews}`, LoggerNames.ROUTING, registeredRoutes); diff --git "a/projects/scion/workbench/src/lib/routing/\311\265workbench-router.service.ts" "b/projects/scion/workbench/src/lib/routing/\311\265workbench-router.service.ts" index 324704fd0..2a02a367d 100644 --- "a/projects/scion/workbench/src/lib/routing/\311\265workbench-router.service.ts" +++ "b/projects/scion/workbench/src/lib/routing/\311\265workbench-router.service.ts" @@ -10,7 +10,7 @@ import {NavigationExtras, Router, UrlSegment, UrlTree} from '@angular/router'; import {WorkbenchRouter} from './workbench-router.service'; -import {Defined} from '@scion/toolkit/util'; +import {Defined, Observables} from '@scion/toolkit/util'; import {Injectable, Injector, NgZone, OnDestroy, runInInjectionContext} from '@angular/core'; import {WorkbenchLayoutService} from '../layout/workbench-layout.service'; import {MAIN_AREA_LAYOUT_QUERY_PARAM} from '../workbench.constants'; @@ -23,6 +23,10 @@ import {Commands, ViewOutlets, WorkbenchNavigationContext, WorkbenchNavigationEx import {ViewId} from '../view/workbench-view.model'; import {UrlSegmentMatcher} from './url-segment-matcher'; import {Objects} from '../common/objects.util'; +import {WorkbenchViewRegistry} from '../view/workbench-view.registry'; +import {Logger} from '../logging'; +import {CanClose} from '../workbench.model'; +import {UUID} from '../common/uuid.util'; /** @inheritDoc */ @Injectable({providedIn: 'root'}) @@ -37,7 +41,9 @@ export class ɵWorkbenchRouter implements WorkbenchRouter, OnDestroy { constructor(private _router: Router, private _workbenchLayoutService: WorkbenchLayoutService, + private _workbenchViewRegistry: WorkbenchViewRegistry, private _injector: Injector, + private _logger: Logger, private _zone: NgZone) { // Instruct the Angular router to process navigations that do not change the current URL, i.e., when only updating navigation state. // For example, the workbench grid is passed to the navigation as state, not as a query parameter. Without this flag set, changes to @@ -70,11 +76,14 @@ export class ɵWorkbenchRouter implements WorkbenchRouter, OnDestroy { // Let the navigator compute the new workbench layout. const currentLayout = this._workbenchLayoutService.layout!; - const newLayout: ɵWorkbenchLayout | null = await runInInjectionContext(this._injector, () => navigateFn(currentLayout)) as ɵWorkbenchLayout; + let newLayout: ɵWorkbenchLayout | null = await runInInjectionContext(this._injector, () => navigateFn(currentLayout)) as ɵWorkbenchLayout; if (!newLayout) { return true; } + // Remove views marked for removal, invoking `CanClose` guard if implemented. + newLayout = await newLayout.removeViewsMarkedForRemoval(viewUid => this.canCloseView(viewUid)); + // Create extras with workbench navigation instructions. extras = createNavigationExtras(newLayout, extras); @@ -130,11 +139,14 @@ export class ɵWorkbenchRouter implements WorkbenchRouter, OnDestroy { // Let the navigator compute the new workbench layout. const currentLayout = this._workbenchLayoutService.layout!; - const newLayout: ɵWorkbenchLayout | null = await runInInjectionContext(this._injector, () => onNavigate(currentLayout)); + let newLayout: ɵWorkbenchLayout | null = await runInInjectionContext(this._injector, () => onNavigate(currentLayout)); if (!newLayout) { return null; } + // Remove views marked for removal. + newLayout = await newLayout.removeViewsMarkedForRemoval(); + // Create extras with workbench navigation instructions. extras = createNavigationExtras(newLayout, extras); @@ -144,6 +156,35 @@ export class ɵWorkbenchRouter implements WorkbenchRouter, OnDestroy { }); } + /** + * Decides if given view can be closed, invoking `CanClose` guard if implemented. + */ + private async canCloseView(viewUid: UUID): Promise { + const view = this._workbenchViewRegistry.views.find(view => view.uid === viewUid); + if (!view) { + return true; + } + if (!view.closable) { + return false; + } + + // Test if the view implements `CanClose` guard. + const component = view.getComponent() as CanClose | null; + if (typeof component?.canClose !== 'function') { + return true; + } + + // Invoke `CanClose` guard to decide if to close the view. + try { + const canClose = runInInjectionContext(this._injector, () => component.canClose()); + return await firstValueFrom(Observables.coerce(canClose), {defaultValue: true}); + } + catch (error) { + this._logger.error(`Unhandled error while invoking 'CanClose' guard of view '${view.id}'.`, error); + return true; + } + } + /** * Returns the context of the current workbench navigation, when being invoked during navigation, or throws an error otherwise. */ diff --git a/projects/scion/workbench/src/lib/testing/jasmine/matcher/to-equal-workbench-layout.matcher.ts b/projects/scion/workbench/src/lib/testing/jasmine/matcher/to-equal-workbench-layout.matcher.ts index c51dd971c..7bc7a28c7 100644 --- a/projects/scion/workbench/src/lib/testing/jasmine/matcher/to-equal-workbench-layout.matcher.ts +++ b/projects/scion/workbench/src/lib/testing/jasmine/matcher/to-equal-workbench-layout.matcher.ts @@ -14,7 +14,7 @@ import CustomMatcherResult = jasmine.CustomMatcherResult; import ObjectContaining = jasmine.ObjectContaining; import {DebugElement} from '@angular/core'; import {WorkbenchLayoutComponent} from '../../../layout/workbench-layout.component'; -import {MPart, MPartGrid, MTreeNode} from '../../../layout/workbench-layout.model'; +import {MPart as _MPart, MPartGrid as _MPartGrid, MTreeNode as _MTreeNode, MView as _MView} from '../../../layout/workbench-layout.model'; import {WorkbenchLayouts} from '../../../layout/workbench-layouts.util'; import {ɵWorkbenchLayout} from '../../../layout/ɵworkbench-layout'; import {MAIN_AREA} from '../../../layout/workbench-layout'; @@ -68,7 +68,7 @@ function assertWorkbenchLayout(expected: ExpectedWorkbenchLayout, actual: ɵWork /** * Asserts the actual model to equal the expected model. Only properties declared on the expected object are asserted. */ -function assertPartGridModel(expectedLayout: Partial, actualLayout: MPartGrid | null, util: MatchersUtil): void { +function assertPartGridModel(expectedLayout: MPartGrid, actualLayout: _MPartGrid | null, util: MatchersUtil): void { const result = toEqual(actualLayout, objectContainingRecursive(expectedLayout), util); if (!result.pass) { throw Error(result.message); @@ -97,7 +97,7 @@ function assertWorkbenchLayoutDOM(expected: ExpectedWorkbenchLayout, actualEleme * @see assertMTreeNodeDOM * @see assertMPartDOM */ -function assertGridElementDOM(expectedModelElement: Partial, actualElement: Element | null, expectedWorkbenchLayout: ExpectedWorkbenchLayout): void { +function assertGridElementDOM(expectedModelElement: MTreeNode | MPart, actualElement: Element | null, expectedWorkbenchLayout: ExpectedWorkbenchLayout): void { if (!actualElement) { throw Error(`[DOMAssertError] Expected element to be present in the DOM, but is not. [${expectedModelElement.type}=${JSON.stringify(expectedModelElement)}]`); } @@ -118,14 +118,14 @@ function assertGridElementDOM(expectedModelElement: Partial, /** * Performs a recursive assertion of the DOM structure starting with the expected tree node. */ -function assertMTreeNodeDOM(expectedTreeNode: Partial, actualElement: Element, expectedWorkbenchLayout: ExpectedWorkbenchLayout): void { +function assertMTreeNodeDOM(expectedTreeNode: MTreeNode, actualElement: Element, expectedWorkbenchLayout: ExpectedWorkbenchLayout): void { const nodeId = actualElement.getAttribute('data-nodeid'); if (!nodeId) { throw Error(`[DOMAssertError] Expected element 'wb-grid-element' to have attribute 'data-nodeid', but is missing. [MTreeNode=${JSON.stringify(expectedTreeNode)}]`); } - const child1Visible = WorkbenchLayouts.isGridElementVisible(expectedTreeNode.child1!); - const child2Visible = WorkbenchLayouts.isGridElementVisible(expectedTreeNode.child2!); + const child1Visible = WorkbenchLayouts.isGridElementVisible(expectedTreeNode.child1 as _MTreeNode | _MPart); + const child2Visible = WorkbenchLayouts.isGridElementVisible(expectedTreeNode.child2 as _MTreeNode | _MPart); // Assert sashbox. if (child1Visible && child2Visible) { @@ -162,7 +162,7 @@ function assertMTreeNodeDOM(expectedTreeNode: Partial, actualElement: /** * Performs a recursive assertion of the DOM structure starting with the expected part. */ -function assertMPartDOM(expectedPart: Partial, actualElement: Element, expectedWorkbenchLayout: ExpectedWorkbenchLayout): void { +function assertMPartDOM(expectedPart: MPart, actualElement: Element, expectedWorkbenchLayout: ExpectedWorkbenchLayout): void { const partId = actualElement.getAttribute('data-partid'); if (partId !== expectedPart.id) { throw Error(`[DOMAssertError] Expected element 'wb-grid-element' to have attribute '[data-partid="${expectedPart.id}"]', but is '[data-partid="${partId}"]'. [MPart=${JSON.stringify(expectedPart)}]`); @@ -246,9 +246,37 @@ export interface ExpectedWorkbenchLayout { /** * Specifies the expected workbench grid. If not set, does not assert the workbench grid. */ - workbenchGrid?: Partial & {root: MTreeNode | MPart}; + workbenchGrid?: MPartGrid; /** * Specifies the expected main area grid. If not set, does not assert the main area grid. */ - mainAreaGrid?: Partial & {root: MTreeNode | MPart}; + mainAreaGrid?: MPartGrid; +} + +/** + * `MPartGrid` that can be used as expectation in {@link CustomMatchers#toEqualWorkbenchLayout}. + */ +export type MPartGrid = Partial> & {root: MTreeNode | MPart}; + +/** + * `MView` that can be used as expectation in {@link CustomMatchers#toEqualWorkbenchLayout}. + */ +export type MView = Partial<_MView>; + +/** + * `MTreeNode` that can be used as expectation in {@link CustomMatchers#toEqualWorkbenchLayout}. + */ +export class MTreeNode extends _MTreeNode { + constructor(treeNode: Partial>) { + super(treeNode as _MTreeNode); + } +} + +/** + * `MPart` that can be used as expectation in {@link CustomMatchers#toEqualWorkbenchLayout}. + */ +export class MPart extends _MPart { + constructor(part: Partial & {views: Array>}>) { + super(part as _MPart); + } } diff --git a/projects/scion/workbench/src/lib/testing/testing.util.ts b/projects/scion/workbench/src/lib/testing/testing.util.ts index 457f47af2..5d1398cbd 100644 --- a/projects/scion/workbench/src/lib/testing/testing.util.ts +++ b/projects/scion/workbench/src/lib/testing/testing.util.ts @@ -81,3 +81,10 @@ export function clickElement(appFixture: ComponentFixture, viewType: Type RouterUtils.commandsToSegments(commands)); } + +/** + * Alias for {@link jasmine.anything}, but casts to the expected type. + */ +export function anything(): T { + return jasmine.anything() as unknown as T; +} diff --git a/projects/scion/workbench/src/lib/view/view-move-handler.service.ts b/projects/scion/workbench/src/lib/view/view-move-handler.service.ts index 1aaf38155..e32e0aa9a 100644 --- a/projects/scion/workbench/src/lib/view/view-move-handler.service.ts +++ b/projects/scion/workbench/src/lib/view/view-move-handler.service.ts @@ -1,6 +1,5 @@ import {Inject, Injectable} from '@angular/core'; import {ViewDragService, ViewMoveEvent} from '../view-dnd/view-drag.service'; -import {UUID} from '@scion/toolkit/uuid'; import {Router} from '@angular/router'; import {LocationStrategy} from '@angular/common'; import {ɵWorkbenchRouter} from '../routing/ɵworkbench-router.service'; @@ -11,6 +10,7 @@ import {Defined} from '@scion/toolkit/util'; import {generatePerspectiveWindowName} from '../perspective/workbench-perspective.service'; import {ANONYMOUS_PERSPECTIVE_ID_PREFIX} from '../workbench.constants'; import {WORKBENCH_ID} from '../workbench-id'; +import {randomUUID} from '../common/uuid.util'; /** * Updates the workbench layout when the user moves a view. @@ -67,7 +67,7 @@ export class ViewMoveHandler { await this._workbenchRouter.navigate(layout => { const newViewId = event.source.alternativeViewId ?? layout.computeNextViewId(); if (addToNewPart) { - const newPartId = event.target.newPart?.id ?? UUID.randomUUID(); + const newPartId = event.target.newPart?.id ?? randomUUID(); return layout .addPart(newPartId, {relativeTo: event.target.elementId, align: coerceAlignProperty(region!), ratio: event.target.newPart?.ratio}, {structural: false}) .addView(newViewId, {partId: newPartId, activateView: true, activatePart: true, cssClass: event.source.classList?.get('layout')}) @@ -101,20 +101,20 @@ export class ViewMoveHandler { }) .navigateView(newViewId, commands, {hint: event.source.navigationHint, cssClass: event.source.classList?.get('navigation')}); }); - const target = generatePerspectiveWindowName(`${ANONYMOUS_PERSPECTIVE_ID_PREFIX}${UUID.randomUUID()}`); + const target = generatePerspectiveWindowName(`${ANONYMOUS_PERSPECTIVE_ID_PREFIX}${randomUUID()}`); if (window.open(this._locationStrategy.prepareExternalUrl(this._router.serializeUrl(urlTree!)), target)) { await this.removeView(event); } } private async removeView(event: ViewMoveEvent): Promise { - await this._workbenchRouter.navigate(layout => layout.removeView(event.source.viewId)); + await this._workbenchRouter.navigate(layout => layout.removeView(event.source.viewId, {force: true})); } private async moveView(event: ViewMoveEvent): Promise { const addToNewPart = !!event.target.region; if (addToNewPart) { - const newPartId = event.target.newPart?.id ?? UUID.randomUUID(); + const newPartId = event.target.newPart?.id ?? randomUUID(); await this._workbenchRouter.navigate(layout => layout .addPart(newPartId, {relativeTo: event.target.elementId, align: coerceAlignProperty(event.target.region!), ratio: event.target.newPart?.ratio}, {structural: false}) .moveView(event.source.viewId, newPartId, {activatePart: true, activateView: true}), diff --git a/projects/scion/workbench/src/lib/view/view.spec.ts b/projects/scion/workbench/src/lib/view/view.spec.ts index 3eb7fa4c0..e589f2e75 100644 --- a/projects/scion/workbench/src/lib/view/view.spec.ts +++ b/projects/scion/workbench/src/lib/view/view.spec.ts @@ -14,7 +14,7 @@ import {ActivatedRoute, provideRouter} from '@angular/router'; import {WorkbenchViewRegistry} from './workbench-view.registry'; import {WorkbenchRouter} from '../routing/workbench-router.service'; import {ViewId, WorkbenchView} from './workbench-view.model'; -import {WorkbenchViewPreDestroy} from '../workbench.model'; +import {CanClose} from '../workbench.model'; import {Observable} from 'rxjs'; import {expect} from '../testing/jasmine/matcher/custom-matchers.definition'; import {styleFixture, waitForInitialWorkbenchLayout, waitUntilStable} from '../testing/testing.util'; @@ -27,6 +27,7 @@ import {coerceBooleanProperty} from '@angular/cdk/coercion'; import {canMatchWorkbenchView} from './workbench-view-route-guards'; import {WorkbenchRouteData} from '../routing/workbench-route-data'; import {ɵWorkbenchRouter} from '../routing/ɵworkbench-router.service'; +import {WorkbenchService} from '../workbench.service'; describe('View', () => { @@ -391,7 +392,113 @@ describe('View', () => { expect(view2.getComponent()!.activated).toBeFalse(); }); - it('should prevent the view from being closed', async () => { + it('should close views via WorkbenchRouter (unless prevent closing or non-closable)', async () => { + TestBed.configureTestingModule({ + providers: [ + provideWorkbenchForTest(), + provideRouter([ + {path: 'path/to/view/1', component: SpecViewComponent}, + {path: 'path/to/view/2', component: SpecViewComponent}, + {path: 'path/to/view/3', component: SpecViewComponent}, + {path: 'path/to/view/4', component: SpecViewComponent}, + ]), + ], + }); + styleFixture(TestBed.createComponent(WorkbenchComponent)); + await waitForInitialWorkbenchLayout(); + + const workbenchRouter = TestBed.inject(WorkbenchRouter); + const workbenchViewRegistry = TestBed.inject(WorkbenchViewRegistry); + const workbenchService = TestBed.inject(WorkbenchService); + + await workbenchRouter.navigate(['path/to/view/1'], {target: 'view.101'}); + await workbenchRouter.navigate(['path/to/view/2'], {target: 'view.102'}); + await workbenchRouter.navigate(['path/to/view/3'], {target: 'view.103'}); + await workbenchRouter.navigate(['path/to/view/4'], {target: 'view.104'}); + await waitUntilStable(); + + const componentView1 = workbenchViewRegistry.get('view.101').getComponent()!; + const componentView2 = workbenchViewRegistry.get('view.102').getComponent()!; + const componentView3 = workbenchViewRegistry.get('view.103').getComponent()!; + const componentView4 = workbenchViewRegistry.get('view.104').getComponent()!; + + // Prevent closing view 2. + componentView2.preventClosing = true; + + // Make view 4 not closable. + componentView4.view.closable = false; + + // Close all views via WorkbenchRouter. + await workbenchRouter.navigate(['path/to/view/*'], {close: true}); + await waitUntilStable(); + + // Expect view 2 und view 4 not to be closed. + expect(workbenchService.views.map(view => view.id)).toEqual(['view.102', 'view.104']); + + // Expect view 1 to be closed. + expect(componentView1.destroyed).toBeTrue(); + // Expect view 2 not to be closed. + expect(componentView2.destroyed).toBeFalse(); + // Expect view 3 to be closed. + expect(componentView3.destroyed).toBeTrue(); + // Expect view 4 not to be closed. + expect(componentView2.destroyed).toBeFalse(); + }); + + it('should close views via WorkbenchService (unless prevent closing or non-closable)', async () => { + TestBed.configureTestingModule({ + providers: [ + provideWorkbenchForTest(), + provideRouter([ + {path: 'path/to/view/1', component: SpecViewComponent}, + {path: 'path/to/view/2', component: SpecViewComponent}, + {path: 'path/to/view/3', component: SpecViewComponent}, + {path: 'path/to/view/4', component: SpecViewComponent}, + ]), + ], + }); + styleFixture(TestBed.createComponent(WorkbenchComponent)); + await waitForInitialWorkbenchLayout(); + + const workbenchRouter = TestBed.inject(WorkbenchRouter); + const workbenchViewRegistry = TestBed.inject(WorkbenchViewRegistry); + const workbenchService = TestBed.inject(WorkbenchService); + + await workbenchRouter.navigate(['path/to/view/1'], {target: 'view.101'}); + await workbenchRouter.navigate(['path/to/view/2'], {target: 'view.102'}); + await workbenchRouter.navigate(['path/to/view/3'], {target: 'view.103'}); + await workbenchRouter.navigate(['path/to/view/4'], {target: 'view.104'}); + await waitUntilStable(); + + const componentView1 = workbenchViewRegistry.get('view.101').getComponent()!; + const componentView2 = workbenchViewRegistry.get('view.102').getComponent()!; + const componentView3 = workbenchViewRegistry.get('view.103').getComponent()!; + const componentView4 = workbenchViewRegistry.get('view.104').getComponent()!; + + // Prevent closing view 2. + componentView2.preventClosing = true; + + // Make view 4 not closable. + componentView4.view.closable = false; + + // Close all views via WorkbenchService. + await workbenchService.closeViews('view.101', 'view.102', 'view.103', 'view.104'); + await waitUntilStable(); + + // Expect view 2 und view 4 not to be closed. + expect(workbenchService.views.map(view => view.id)).toEqual(['view.102', 'view.104']); + + // Expect view 1 to be closed. + expect(componentView1.destroyed).toBeTrue(); + // Expect view 2 not to be closed. + expect(componentView2.destroyed).toBeFalse(); + // Expect view 3 to be closed. + expect(componentView3.destroyed).toBeTrue(); + // Expect view 4 not to be closed. + expect(componentView2.destroyed).toBeFalse(); + }); + + it('should prevent closing view', async () => { TestBed.configureTestingModule({ providers: [ provideWorkbenchForTest(), @@ -404,7 +511,7 @@ describe('View', () => { const workbenchRouter = TestBed.inject(WorkbenchRouter); await waitForInitialWorkbenchLayout(); - // Navigate to "path/to/view/1". + // Navigate to "path/to/view". await workbenchRouter.navigate(['path/to/view'], {target: 'view.100'}); await waitUntilStable(); @@ -412,26 +519,198 @@ describe('View', () => { const component = view.getComponent()!; // Try to close View (prevent) - component.preventDestroy = true; + component.preventClosing = true; await view.close(); await waitUntilStable(); - // Expect view not to be destroyed. + // Expect view not to be closed. expect(view.destroyed).toBeFalse(); expect(component.destroyed).toBeFalse(); expect(fixture).toShow(SpecViewComponent); // Try to close to View 1 (accept) - component.preventDestroy = false; + component.preventClosing = false; await view.close(); await waitUntilStable(); - // Expect view to be destroyed. + // Expect view to be closed. expect(view.destroyed).toBeTrue(); expect(component.destroyed).toBeTrue(); expect(fixture).not.toShow(SpecViewComponent); }); + it('should prevent closing empty-path view', async () => { + TestBed.configureTestingModule({ + providers: [ + provideWorkbenchForTest(), + provideRouter([ + {path: '', canMatch: [canMatchWorkbenchView('view-1')], component: SpecViewComponent}, + ]), + ], + }); + const fixture = styleFixture(TestBed.createComponent(WorkbenchComponent)); + const workbenchRouter = TestBed.inject(WorkbenchRouter); + await waitForInitialWorkbenchLayout(); + + // Navigate to "" (hint: view-1) + await workbenchRouter.navigate([], {hint: 'view-1', target: 'view.100'}); + await waitUntilStable(); + + const view = TestBed.inject(WorkbenchViewRegistry).get('view.100'); + const component = view.getComponent()!; + + // Try to close View (prevent) + component.preventClosing = true; + await view.close(); + await waitUntilStable(); + + // Expect view not to be closed. + expect(view.destroyed).toBeFalse(); + expect(component.destroyed).toBeFalse(); + expect(fixture).toShow(SpecViewComponent); + + // Try to close to View 1 (accept) + component.preventClosing = false; + await view.close(); + await waitUntilStable(); + + // Expect view to be closed. + expect(view.destroyed).toBeTrue(); + expect(component.destroyed).toBeTrue(); + expect(fixture).not.toShow(SpecViewComponent); + }); + + it('should prevent closing view navigated to a child route', async () => { + TestBed.configureTestingModule({ + providers: [ + provideWorkbenchForTest(), + provideRouter([ + { + path: 'path', + loadChildren: () => [ + { + path: 'to', + loadChildren: () => [ + { + path: 'module', + loadChildren: () => [ + {path: 'view', component: SpecViewComponent}, + ], + }, + ], + }, + ], + }, + ]), + ], + }); + const fixture = styleFixture(TestBed.createComponent(WorkbenchComponent)); + const workbenchRouter = TestBed.inject(WorkbenchRouter); + await waitForInitialWorkbenchLayout(); + + // Navigate to "path/to/module/view". + await workbenchRouter.navigate(['path/to/module/view'], {target: 'view.100'}); + await waitUntilStable(); + + const view = TestBed.inject(WorkbenchViewRegistry).get('view.100'); + const component = view.getComponent()!; + + // Try to close View (prevent) + component.preventClosing = true; + await view.close(); + await waitUntilStable(); + + // Expect view not to be closed. + expect(view.destroyed).toBeFalse(); + expect(component.destroyed).toBeFalse(); + expect(fixture).toShow(SpecViewComponent); + + // Try to close to View 1 (accept) + component.preventClosing = false; + await view.close(); + await waitUntilStable(); + + // Expect view to be closed. + expect(view.destroyed).toBeTrue(); + expect(component.destroyed).toBeTrue(); + expect(fixture).not.toShow(SpecViewComponent); + }); + + it('should invoke `CanClose` guard on correct view component', async () => { + TestBed.configureTestingModule({ + providers: [ + provideWorkbenchForTest({ + layout: { + perspectives: [ + { + id: 'A', + layout: factory => factory + .addPart('left') + .addView('view.100', {partId: 'left'}) + .navigateView('view.100', ['path/to/view/1']), + }, + { + id: 'B', + layout: factory => factory + .addPart('left') + .addView('view.100', {partId: 'left'}) // Add view with same id as in perspective A. + .navigateView('view.100', ['path/to/view/2']) + .removeView('view.100'), // Remove view to test that `CanClose` of view.100 in perspective A is not invoked. + }, + ], + }, + }), + provideRouter([ + {path: 'path/to/view/1', component: SpecViewComponent}, + {path: 'path/to/view/2', component: SpecViewComponent}, + ]), + ], + }); + styleFixture(TestBed.createComponent(WorkbenchComponent)); + await waitForInitialWorkbenchLayout(); + + // Spy console. + spyOn(console, 'log').and.callThrough(); + + // Switch to perspective A. + await TestBed.inject(WorkbenchService).switchPerspective('A'); + + // Switch to perspective B. + await TestBed.inject(WorkbenchService).switchPerspective('B'); + + // Expect `CanClose` guard not to be invoked for view.100 of perspective A. + expect(console.log).not.toHaveBeenCalledWith(`[SpecViewComponent][CanClose] CanClose invoked for view 'view.100'. [path=path/to/view/1]`); + }); + + it('should not invoke `CanClose` guard when creating URL tree', async () => { + TestBed.configureTestingModule({ + providers: [ + provideWorkbenchForTest(), + provideRouter([ + {path: 'path/to/view', component: SpecViewComponent}, + ]), + ], + }); + const fixture = styleFixture(TestBed.createComponent(WorkbenchComponent)); + const workbenchRouter = TestBed.inject(WorkbenchRouter); + await waitForInitialWorkbenchLayout(); + + // Navigate to "path/to/view". + await workbenchRouter.navigate(['path/to/view'], {target: 'view.100'}); + await waitUntilStable(); + + // Spy console. + spyOn(console, 'log').and.callThrough(); + + await TestBed.inject(ɵWorkbenchRouter).createUrlTree(layout => layout.removeView('view.100')); + + // Expect `CanClose` guard not to be invoked. + expect(console.log).not.toHaveBeenCalledWith(`[SpecViewComponent][CanClose] CanClose invoked for view 'view.100'. [path=path/to/view]`); + + // Expect view not to be closed. + expect(fixture).toShow(SpecViewComponent); + }); + it('should provide navigated component', async () => { TestBed.configureTestingModule({ providers: [ @@ -870,12 +1149,12 @@ describe('View', () => { template: '{{onCheckForChanges()}}', standalone: true, }) -class SpecViewComponent implements OnDestroy, WorkbenchViewPreDestroy { +class SpecViewComponent implements OnDestroy, CanClose { public destroyed = false; public activated: boolean | undefined; public checkedForChanges = false; - public preventDestroy = false; + public preventClosing = false; public view = inject(WorkbenchView); constructor() { @@ -898,8 +1177,9 @@ class SpecViewComponent implements OnDestroy, WorkbenchViewPreDestroy { } } - public onWorkbenchViewPreDestroy(): Observable | Promise | boolean { - return !this.preventDestroy; + public canClose(): Observable | Promise | boolean { + console.log(`[SpecViewComponent][CanClose] CanClose invoked for view '${this.view.id}'. [path=${this.view.urlSegments.join('/')}]`); + return !this.preventClosing; } public ngOnDestroy(): void { diff --git a/projects/scion/workbench/src/lib/view/workbench-view-route-guards.ts b/projects/scion/workbench/src/lib/view/workbench-view-route-guards.ts index d39641b96..4f9ab0361 100644 --- a/projects/scion/workbench/src/lib/view/workbench-view-route-guards.ts +++ b/projects/scion/workbench/src/lib/view/workbench-view-route-guards.ts @@ -8,11 +8,10 @@ * SPDX-License-Identifier: EPL-2.0 */ -import {ActivatedRouteSnapshot, CanDeactivateFn, CanMatchFn} from '@angular/router'; +import {CanMatchFn} from '@angular/router'; import {inject} from '@angular/core'; import {ɵWorkbenchRouter} from '../routing/ɵworkbench-router.service'; import {WorkbenchLayouts} from '../layout/workbench-layouts.util'; -import {WorkbenchViewPreDestroy} from '../workbench.model'; import {WORKBENCH_AUXILIARY_ROUTE_OUTLET} from '../routing/workbench-auxiliary-routes-registrator.service'; /** @@ -82,30 +81,3 @@ export const canMatchNotFoundPage: CanMatchFn = (): boolean => { const view = layout.view({viewId: outlet}, {orElse: null}); return !view || !!view.navigation; }; - -/** - * Prevents deactivation of the view if prevented by the view component. - */ -export const canDeactivateView: CanDeactivateFn = ((component: unknown | null, route: ActivatedRouteSnapshot) => { - const outlet = inject(WORKBENCH_AUXILIARY_ROUTE_OUTLET, {optional: true}); - - if (!WorkbenchLayouts.isViewId(outlet)) { - throw Error(`[ViewError] CanDeactivateFn must be installed on a view auxiliary route. [outlet=${outlet}]`); - } - - // Test if the view component implements `onWorkbenchViewPreDestroy`. - const viewComponent = component as WorkbenchViewPreDestroy; - if (typeof viewComponent?.onWorkbenchViewPreDestroy !== 'function') { - return true; - } - - // Depending on the route configuration, this guard may be called even if the component is not to be closed. - // Therefore, we need to check if the view is actually being closed before invoking the `onWorkbenchViewPreDestroy` - // lifecycle hook. See {@link Route.runGuardsAndResolvers}. - const isToBeClosed = inject(ɵWorkbenchRouter).getCurrentNavigationContext().layoutDiff.removedViews.includes(outlet) ?? false; - if (!isToBeClosed) { - return true; - } - - return viewComponent.onWorkbenchViewPreDestroy(); -}); diff --git "a/projects/scion/workbench/src/lib/view/\311\265workbench-view.model.ts" "b/projects/scion/workbench/src/lib/view/\311\265workbench-view.model.ts" index 5195bb484..440923363 100644 --- "a/projects/scion/workbench/src/lib/view/\311\265workbench-view.model.ts" +++ "b/projects/scion/workbench/src/lib/view/\311\265workbench-view.model.ts" @@ -41,6 +41,7 @@ import {ClassList} from '../common/class-list'; import {ViewState} from '../routing/routing.model'; import {RouterUtils} from '../routing/router.util'; import {WorkbenchRouteData} from '../routing/workbench-route-data'; +import {UUID} from '../common/uuid.util'; export class ɵWorkbenchView implements WorkbenchView, Blockable { @@ -64,6 +65,7 @@ export class ɵWorkbenchView implements WorkbenchView, Blockable { private _activationInstant: number | undefined; private _closable = true; + public uid!: UUID; public alternativeId: string | undefined; public navigationHint: string | undefined; public urlSegments: UrlSegment[] = []; @@ -131,6 +133,7 @@ export class ɵWorkbenchView implements WorkbenchView, Blockable { public onLayoutChange(layout: ɵWorkbenchLayout): void { const mPart = layout.part({viewId: this.id}); const mView = layout.view({viewId: this.id}); + this.uid = mView.uid; this.alternativeId = mView.alternativeId; this.urlSegments = layout.urlSegments({viewId: this.id}); this.navigationHint = mView.navigation?.hint; diff --git a/projects/scion/workbench/src/lib/workbench-id.ts b/projects/scion/workbench/src/lib/workbench-id.ts index 7a9d356aa..a03b28920 100644 --- a/projects/scion/workbench/src/lib/workbench-id.ts +++ b/projects/scion/workbench/src/lib/workbench-id.ts @@ -9,7 +9,7 @@ */ import {InjectionToken} from '@angular/core'; -import {UUID} from '@scion/toolkit/uuid'; +import {randomUUID} from './common/uuid.util'; /** * DI token to get a unique id of the workbench. @@ -18,5 +18,5 @@ import {UUID} from '@scion/toolkit/uuid'; */ export const WORKBENCH_ID = new InjectionToken('WORKBENCH_ID', { providedIn: 'root', - factory: () => UUID.randomUUID(), + factory: () => randomUUID(), }); diff --git a/projects/scion/workbench/src/lib/workbench.model.ts b/projects/scion/workbench/src/lib/workbench.model.ts index 7c628461f..260a3ed42 100644 --- a/projects/scion/workbench/src/lib/workbench.model.ts +++ b/projects/scion/workbench/src/lib/workbench.model.ts @@ -14,18 +14,32 @@ import {WorkbenchView} from './view/workbench-view.model'; import {WorkbenchPart} from './part/workbench-part.model'; /** - * Lifecycle hook that is called when a view component is to be destroyed, and which is called before 'ngOnDestroy'. + * Guard that a view can implement to decide whether it can be closed. * - * The return value controls whether destruction should be continued. + * The following example asks the user whether the view can be closed. + * + * The following example implements a `CanClose` guard that asks the user whether the view can be closed. + * + * ```ts + * class ViewComponent implements CanClose { + * + * public async canClose(): Promise { + * const action = await inject(WorkbenchMessageBoxService).open('Do you want to close this view?', { + * actions: {yes: 'Yes', no: 'No'}, + * }); + * return action === 'yes'; + * } + * } + * ``` */ -export interface WorkbenchViewPreDestroy { +export interface CanClose { /** - * Lifecycle hook which is called upon view destruction. + * Decides whether this view can be closed. * - * Return a falsy value to prevent view destruction, either as a boolean value or as an observable which emits a boolean value. + * This function can call `inject` to get any required dependencies. */ - onWorkbenchViewPreDestroy(): Observable | Promise | boolean; + canClose(): Observable | Promise | boolean; } /** diff --git a/projects/scion/workbench/src/lib/workbench.service.ts b/projects/scion/workbench/src/lib/workbench.service.ts index 2acc99a00..5f5c389fa 100644 --- a/projects/scion/workbench/src/lib/workbench.service.ts +++ b/projects/scion/workbench/src/lib/workbench.service.ts @@ -16,6 +16,7 @@ import {WorkbenchPerspective, WorkbenchPerspectiveDefinition} from './perspectiv import {WorkbenchPart} from './part/workbench-part.model'; import {Injectable} from '@angular/core'; import {ɵWorkbenchService} from './ɵworkbench.service'; +import {WorkbenchLayout} from './layout/workbench-layout'; /** * The central class of the SCION Workbench. @@ -37,6 +38,23 @@ import {ɵWorkbenchService} from './ɵworkbench.service'; @Injectable({providedIn: 'root', useExisting: ɵWorkbenchService}) export abstract class WorkbenchService { + /** + * Returns the current {@link WorkbenchLayout}. + * + * The layout is an immutable object. Modifications have no side effects. The layout can be modified using {@link WorkbenchRouter.navigate}. + */ + public abstract readonly layout: WorkbenchLayout; + + /** + * Emits the current {@link WorkbenchLayout}. + * + * Upon subscription, emits the current workbench layout, and then emits continuously + * when the layout changes. It never completes. + * + * The layout is an immutable object. Modifications have no side effects. The layout can be modified using {@link WorkbenchRouter.navigate}. + */ + public abstract readonly layout$: Observable; + /** * Perspectives registered with the workbench. */ @@ -45,7 +63,7 @@ export abstract class WorkbenchService { /** * Emits the perspectives registered with the workbench. * - * Upon subscription, the currently registered perspectives are emitted, and then emits continuously + * Upon subscription, emits registered perspectives, and then emits continuously * when new perspectives are registered or existing perspectives unregistered. It never completes. */ public abstract readonly perspectives$: Observable; @@ -75,37 +93,45 @@ export abstract class WorkbenchService { /** * Parts in the workbench layout. + * + * Each {@link WorkbenchPart} object represents a part in the workbench layout that can be interacted with. */ public abstract readonly parts: readonly WorkbenchPart[]; /** * Emits the parts in the workbench layout. * - * Upon subscription, the current parts are emitted, and then emits continuously + * Upon subscription, emits parts contained in the layout, and then emits continuously * when new parts are added or existing parts removed. It never completes. */ public abstract readonly parts$: Observable; /** * Returns a reference to the specified {@link WorkbenchPart}, or `null` if not found. + * + * A {@link WorkbenchPart} object represents a part in the workbench layout that can be interacted with. */ public abstract getPart(partId: string): WorkbenchPart | null; /** - * Views opened in the workbench. + * Views in the workbench layout. + * + * Each {@link WorkbenchView} object represents a view in the workbench layout that can be interacted with. */ public abstract readonly views: readonly WorkbenchView[]; /** * Emits the views opened in the workbench. * - * Upon subscription, the currently opened views are emitted, and then emits continuously + * Upon subscription, emits views contained in the layout, and then emits continuously * when new views are opened or existing views closed. It never completes. */ public abstract readonly views$: Observable; /** * Returns a reference to the specified {@link WorkbenchView}, or `null` if not found. + * + * A {@link WorkbenchView} object represents a view in the workbench layout that can be interacted with. */ public abstract getView(viewId: ViewId): WorkbenchView | null; diff --git "a/projects/scion/workbench/src/lib/\311\265workbench.service.ts" "b/projects/scion/workbench/src/lib/\311\265workbench.service.ts" index 614165cbf..e48198f11 100644 --- "a/projects/scion/workbench/src/lib/\311\265workbench.service.ts" +++ "b/projects/scion/workbench/src/lib/\311\265workbench.service.ts" @@ -25,10 +25,14 @@ import {WorkbenchPerspectiveRegistry} from './perspective/workbench-perspective. import {WorkbenchPartActionRegistry} from './part/workbench-part-action.registry'; import {WorkbenchThemeSwitcher} from './theme/workbench-theme-switcher.service'; import {ViewId} from './view/workbench-view.model'; +import {ɵWorkbenchLayout} from './layout/ɵworkbench-layout'; +import {WorkbenchLayoutService} from './layout/workbench-layout.service'; +import {throwError} from './common/throw-error.util'; @Injectable({providedIn: 'root'}) export class ɵWorkbenchService implements WorkbenchService { + public readonly layout$: Observable<ɵWorkbenchLayout>; public readonly perspectives$: Observable; public readonly parts$: Observable; public readonly views$: Observable; @@ -42,13 +46,19 @@ export class ɵWorkbenchService implements WorkbenchService { private _partActionRegistry: WorkbenchPartActionRegistry, private _viewRegistry: WorkbenchViewRegistry, private _perspectiveService: WorkbenchPerspectiveService, + private _layoutService: WorkbenchLayoutService, private _workbenchThemeSwitcher: WorkbenchThemeSwitcher) { + this.layout$ = this._layoutService.layout$; this.perspectives$ = this._perspectiveRegistry.perspectives$; this.parts$ = this._partRegistry.parts$; this.views$ = this._viewRegistry.views$; this.theme$ = this._workbenchThemeSwitcher.theme$; } + public get layout(): ɵWorkbenchLayout { + return this._layoutService.layout ?? throwError('[NullLayoutError] Workbench layout not created yet.'); + } + /** @inheritDoc */ public get perspectives(): readonly ɵWorkbenchPerspective[] { return this._perspectiveRegistry.perspectives; @@ -96,20 +106,7 @@ export class ɵWorkbenchService implements WorkbenchService { /** @inheritDoc */ public async closeViews(...viewIds: ViewId[]): Promise { - // TODO [#27]: Use single navigation to close multiple views. - // For example: - // return this._workbenchRouter.navigate(layout => { - // viewIds.forEach(viewId => layout = layout.removeView(viewId)); - // return layout - // }); - - // To avoid canceling the entire navigation if some view(s) prevent(s) closing, close each view through a separate navigation. - const navigations = await Promise.all(viewIds - .map(viewId => this._viewRegistry.get(viewId)) - .filter(view => view.closable) - .map(view => this._workbenchRouter.navigate([], {target: view.id, close: true})), - ); - return navigations.every(Boolean); + return this._workbenchRouter.navigate(layout => viewIds.reduce((layout, viewId) => layout.removeView(viewId), layout)); } /** @inheritDoc */