diff --git a/projects/e2e/workbench/src/page-object/app.po.ts b/projects/e2e/workbench/src/page-object/app.po.ts index 46ee063ed..8182a4773 100644 --- a/projects/e2e/workbench/src/page-object/app.po.ts +++ b/projects/e2e/workbench/src/page-object/app.po.ts @@ -8,7 +8,7 @@ * SPDX-License-Identifier: EPL-2.0 */ -import { $, $$, browser, ElementFinder, protractor } from 'protractor'; +import { $, $$, browser, ElementFinder, Key, protractor } from 'protractor'; import { getCssClasses } from '../util/testing.util'; import { Duration, Severity } from '@scion/workbench-application-platform.api'; import { ISize } from 'selenium-webdriver'; @@ -65,8 +65,13 @@ export class AppPO { /** * Returns the number of view tabs. + * + * Optionally, provide a CSS class to only count view tabs of given view. */ - public async getViewTabCount(): Promise { + public async getViewTabCount(viewCssClass?: string): Promise { + if (viewCssClass) { + return $$(`wb-view-tab.${viewCssClass}`).count(); + } return $$('wb-view-tab').count(); } @@ -248,6 +253,13 @@ export class AppPO { return new WelcomePagePO(); } + /** + * Closes all views of all viewparts. + */ + public async closeAllViewTabs(): Promise { + return $$('wb-view-part').sendKeys(Key.chord(Key.CONTROL, Key.SHIFT, 'k')); + } + /** * Returns a handle representing the viewpart action which has given CSS class set. * This call does not send a command to the browser. Use 'isPresent()' to test its presence. diff --git a/projects/e2e/workbench/src/router.e2e-spec.ts b/projects/e2e/workbench/src/router.e2e-spec.ts index 201609bc5..5d7bd6f14 100644 --- a/projects/e2e/workbench/src/router.e2e-spec.ts +++ b/projects/e2e/workbench/src/router.e2e-spec.ts @@ -10,6 +10,7 @@ import { AppPO } from './page-object/app.po'; import { ViewNavigationPO } from './page-object/view-navigation.po'; +import { browser } from 'protractor'; describe('Workbench Router', () => { @@ -112,4 +113,77 @@ describe('Workbench Router', () => { await expect(viewNavigationPO.isActiveViewTab).toBeTruthy(); await expect(appPO.getViewTabCount()).toEqual(1); }); + + it('should show title of inactive views when reloading the application', async () => { + await viewNavigationPO.navigateTo(); + + // open view-1 + await viewNavigationPO.enterPath('view'); + await viewNavigationPO.enterMatrixParams({viewCssClass: 'e2e-view-1', viewTitle: 'view-1-title'}); + await viewNavigationPO.navigate(); + + // open view-2 + await viewNavigationPO.activateViewTab(); + await viewNavigationPO.enterPath('view'); + await viewNavigationPO.enterMatrixParams({viewCssClass: 'e2e-view-2', viewTitle: 'view-2-title'}); + await viewNavigationPO.navigate(); + + // reload the application + await browser.refresh(); + + await expect(appPO.findViewTab('e2e-view-1').isActive()).toBeFalsy(); + await expect(appPO.findViewTab('e2e-view-1').getTitle()).toEqual('view-1-title'); + + await expect(appPO.findViewTab('e2e-view-2').isActive()).toBeTruthy(); + await expect(appPO.findViewTab('e2e-view-2').getTitle()).toEqual('view-2-title'); + + await expect((await browser.manage().logs().get('browser')).length).toEqual(0); + }); + + it('should not throw outlet activation error when opening a new view tab once a view tab was closed', async () => { + await browser.get('/'); + + // open view tab + await appPO.openNewViewTab(); + await expect(appPO.getViewTabCount('e2e-welcome-page')).toEqual(1); + + // close view tab + await appPO.findViewTab('e2e-welcome-page').close(); + await expect(appPO.getViewTabCount('e2e-welcome-page')).toEqual(0); + + // open view tab + await appPO.openNewViewTab(); + await expect(appPO.getViewTabCount('e2e-welcome-page')).toEqual(1); + // expect no error to be thrown + await expect(browser.manage().logs().get('browser')).toEqual([]); + + // open view tab + await appPO.openNewViewTab(); + await expect(appPO.getViewTabCount('e2e-welcome-page')).toEqual(2); + // expect no error to be thrown + await expect(browser.manage().logs().get('browser')).toEqual([]); + }); + + it('should close all views in a row', async () => { + await browser.get('/'); + + // open multiple view tabs + await appPO.openNewViewTab(); + await appPO.openNewViewTab(); + await appPO.openNewViewTab(); + await appPO.openNewViewTab(); + await appPO.openNewViewTab(); + await appPO.openNewViewTab(); + await appPO.openNewViewTab(); + await appPO.openNewViewTab(); + await appPO.openNewViewTab(); + + // close all view tabs + await appPO.closeAllViewTabs(); + + await expect(appPO.getViewTabCount()).toEqual(0); + + // expect no error to be thrown + await expect(browser.manage().logs().get('browser')).toEqual([]); + }); }); diff --git a/projects/scion/workbench/src/lib/workbench-url-observer.service.ts b/projects/scion/workbench/src/lib/workbench-url-observer.service.ts index e94295749..1f0e58332 100644 --- a/projects/scion/workbench/src/lib/workbench-url-observer.service.ts +++ b/projects/scion/workbench/src/lib/workbench-url-observer.service.ts @@ -8,9 +8,9 @@ * SPDX-License-Identifier: EPL-2.0 */ -import { GuardsCheckEnd, NavigationEnd, NavigationStart, Route, Router } from '@angular/router'; -import { filter, takeUntil } from 'rxjs/operators'; -import { Injectable, IterableDiffers, OnDestroy } from '@angular/core'; +import { GuardsCheckEnd, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, Route, Router } from '@angular/router'; +import { filter, take, takeUntil } from 'rxjs/operators'; +import { Injectable, IterableChanges, IterableDiffers, OnDestroy } from '@angular/core'; import { Subject } from 'rxjs'; import { ViewOutletDiffer } from './routing/view-outlet-differ'; import { WorkbenchAuxiliaryRoutesRegistrator } from './routing/workbench-auxiliary-routes-registrator.service'; @@ -41,7 +41,6 @@ export class WorkbenchUrlObserver implements OnDestroy { differs: IterableDiffers) { this.installNavigationStartRoutingListener(differs); this.installGuardsCheckEndRoutingListener(differs); - this.installNavigationEndRoutingListener(differs); } /** @@ -78,38 +77,56 @@ export class WorkbenchUrlObserver implements OnDestroy { return; } - // Discard routes of closed views. const viewOutletChanges = differ.diff(routerEvent.url); - if (viewOutletChanges) { - const routes = this._router.config; - const discardedRoutes: Route[] = []; - viewOutletChanges.forEachRemovedItem(({item}) => { - discardedRoutes.push(...routes.filter(route => route.outlet === item)); - }); + this.discardRoutesOfClosedViews(viewOutletChanges); - if (discardedRoutes.length > 0) { - this._auxRoutesRegistrator.replaceRouterConfig(routes.filter(route => !discardedRoutes.includes(route))); - } - } - } - - private onNavigationEnd(routerEvent: NavigationEnd, differ: ViewOutletDiffer): void { - // Update viewpart registry. + // Parse the ViewPartGrid from the URL. const serializedViewPartGrid = this._router.parseUrl(routerEvent.url).queryParamMap.get(VIEW_GRID_QUERY_PARAM); const viewPartGrid = new ViewPartGrid(serializedViewPartGrid, this._viewPartGridSerializer); - // Update view registry. - const viewOutletChanges = differ.diff(routerEvent.url); + // Update the view registry with added or removed view outlets. + // + // Note: + // Must be done after 'GuardsCheckEnd' and not after 'NavigationEnd' for the following reason: + // When registering new view outlets, respective view router outlets are instantiated. Later in the routing process, + // while Angular router activates routes, already instantiated router outlets are activated and their components mounted. + // If the outlets would not be instantiated yet, they would get activated only once attached to the DOM. + // + // Early activation is important for inactive views to set their view title, e.g. when reloading the application. + this.updateViewRegistry(viewOutletChanges, viewPartGrid); + + // Update the grid after routing completed to not run into following error: 'Cannot activate an already activated outlet'. + this.whenNavigatedThen(() => this._viewPartRegistry.setGrid(viewPartGrid)); + } + + private discardRoutesOfClosedViews(viewOutletChanges: IterableChanges): void { + if (!viewOutletChanges) { + return; + } + + const routes = this._router.config; + const discardedRoutes: Route[] = []; + + viewOutletChanges.forEachRemovedItem(({item}) => { + discardedRoutes.push(...routes.filter(route => route.outlet === item)); + }); + + if (discardedRoutes.length > 0) { + this._auxRoutesRegistrator.replaceRouterConfig(routes.filter(route => !discardedRoutes.includes(route))); + } + } + + private updateViewRegistry(viewOutletChanges: IterableChanges, viewPartGrid: ViewPartGrid): void { if (viewOutletChanges) { viewOutletChanges.forEachAddedItem(({item}) => { + // Note: registering a view outlet instantiates a new 'ViewComponent' with the view's named router outlet this._viewRegistry.addViewOutlet(item, viewPartGrid.isViewActive(item)); }); viewOutletChanges.forEachRemovedItem(({item}) => { this._viewRegistry.removeViewOutlet(item); }); } - this._viewPartRegistry.setGrid(viewPartGrid); } /** @@ -143,17 +160,19 @@ export class WorkbenchUrlObserver implements OnDestroy { } /** - * Delegates `NavigationEnd` events to `onNavigationEnd` method. + * Runs given function once navigation completed successfully. */ - private installNavigationEndRoutingListener(differs: IterableDiffers): void { - const outletDiffer = new ViewOutletDiffer(differs, this._router); + private whenNavigatedThen(thenFn: () => void): void { this._router.events .pipe( - filter(event => event instanceof NavigationEnd), + filter(event => event instanceof NavigationEnd || event instanceof NavigationCancel || event instanceof NavigationError), + take(1), takeUntil(this._destroy$), ) - .subscribe((routerEvent: NavigationEnd) => { - this.onNavigationEnd(routerEvent, outletDiffer); + .subscribe(event => { + if (event instanceof NavigationEnd) { + thenFn(); + } }); }