Skip to content

Commit

Permalink
fix(workbench/view): fix issues preventing views from closing
Browse files Browse the repository at this point in the history
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<boolean> {
    // return `true` to close the view, otherwise `false`.
  }
}
```

**After migration:**

```ts
class YourComponent implements CanClose {
  public async canClose(): Promise<boolean> {
    // return `true` to close the view, otherwise `false`.
  }
}
  • Loading branch information
danielwiehl committed May 3, 2024
1 parent 1afe970 commit 86a23d0
Show file tree
Hide file tree
Showing 47 changed files with 1,396 additions and 352 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(''),
Expand Down Expand Up @@ -92,25 +92,22 @@ export default class ViewPageComponent implements ViewClosingListener, OnDestroy
});
}

public async onClosing(event: ViewClosingEvent): Promise<void> {
public async canClose(): Promise<boolean> {
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 {
Expand Down Expand Up @@ -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);
}
});
}
Expand Down Expand Up @@ -198,6 +195,6 @@ export default class ViewPageComponent implements ViewClosingListener, OnDestroy
}

public ngOnDestroy(): void {
this.view.removeClosingListener(this);
this.view.removeCanClose(this);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -51,19 +51,23 @@ 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<Routes>;
public microfrontendViewCapabilities$: Observable<WorkbenchViewCapability[]> | undefined;
public testCapabilities$: Observable<Capability[]> | undefined;

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,
Expand All @@ -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$<WorkbenchViewCapability>({type: WorkbenchCapabilities.View})
this.microfrontendViewCapabilities$ = this._manifestService!.lookupCapabilities$<WorkbenchViewCapability>({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)),
Expand All @@ -96,6 +100,7 @@ export default class StartPageComponent {

this.markForCheckOnUrlChange(router, cd);
this.installFilterFieldDisplayTextSynchronizer();
this.memoizeContextualPart();
}

public onViewOpen(path: string, event: MouseEvent): void {
Expand All @@ -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();
Expand All @@ -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;
}
}
Expand Down Expand Up @@ -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;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,15 @@
</sci-form-field>

<sci-form-field label="Dirty">
<sci-checkbox [(ngModel)]="view.dirty" class="e2e-dirty"></sci-checkbox>
<sci-checkbox [(ngModel)]="view.dirty" class="e2e-dirty"/>
</sci-form-field>

<sci-form-field label="Closable">
<sci-checkbox [(ngModel)]="view.closable" class="e2e-closable"></sci-checkbox>
<sci-checkbox [(ngModel)]="view.closable" class="e2e-closable"/>
</sci-form-field>

<sci-form-field label="Confirm Closing">
<sci-checkbox [formControl]="formControls.confirmClosing" class="e2e-confirm-closing"/>
</sci-form-field>

<sci-form-field label="CSS Class(es)">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -51,14 +51,15 @@ 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<WorkbenchPartActionDescriptor[]>;

public formControls = {
partActions: this._formBuilder.control(''),
cssClass: this._formBuilder.control(''),
confirmClosing: this._formBuilder.control(false),
};

public WorkbenchRouteData = WorkbenchRouteData;
Expand All @@ -81,6 +82,23 @@ export default class ViewPageComponent {
this.installCssClassUpdater();
}

public async canClose(): Promise<boolean> {
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 [];
Expand Down
26 changes: 10 additions & 16 deletions docs/site/howto/how-to-prevent-view-closing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
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<boolean> {
const action = await inject(WorkbenchMessageBoxService).open('Do you want to save changes?', {
actions: {
yes: 'Yes',
no: 'No',
Expand Down
2 changes: 1 addition & 1 deletion docs/site/howto/how-to.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
7 changes: 7 additions & 0 deletions projects/scion/e2e-testing/src/app.po.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,13 @@ export class AppPO {
await waitUntilStable(() => this.getCurrentNavigationId());
}

/**
* Opens a new browser window.
*/
public async openNewWindow(): Promise<AppPO> {
return new AppPO(await this.page.context().newPage());
}

/**
* Instructs the browser to move back one page in the session history.
*/
Expand Down
7 changes: 7 additions & 0 deletions projects/scion/e2e-testing/src/start-page.po.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ export class StartPagePO implements WorkbenchViewPagePO {
}
}

/**
* Returns the part in which this page is displayed.
*/
public getPartId(): Promise<string | null> {
return this.locator.getAttribute('data-partid');
}

/**
* Clicks the workbench view tile with specified CSS class set.
*/
Expand Down
Loading

0 comments on commit 86a23d0

Please sign in to comment.