From 8d3dee3994e2dcb1177777cf3c9a9dfb45011440 Mon Sep 17 00:00:00 2001 From: Christopher Cortes Date: Wed, 25 Mar 2020 15:25:33 +0100 Subject: [PATCH] fix(stark-ui): remove all overlays by destroying the Angular CDK OverlayContainer when navigating to an 'exit' state ISSUES CLOSED: #1570 --- .../stark-ui/src/modules/session-ui/routes.ts | 27 +++- .../session-ui/session-ui.module.spec.ts | 149 ++++++++++++++++++ 2 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 packages/stark-ui/src/modules/session-ui/session-ui.module.spec.ts diff --git a/packages/stark-ui/src/modules/session-ui/routes.ts b/packages/stark-ui/src/modules/session-ui/routes.ts index 619c6b170f..01ddb066e9 100644 --- a/packages/stark-ui/src/modules/session-ui/routes.ts +++ b/packages/stark-ui/src/modules/session-ui/routes.ts @@ -1,4 +1,6 @@ +import { HookResult, Transition } from "@uirouter/core"; import { Ng2StateDeclaration } from "@uirouter/angular"; +import { OverlayContainer } from "@angular/cdk/overlay"; import { starkLoginStateName, starkLoginStateUrl, @@ -16,6 +18,25 @@ import { StarkSessionLogoutPageComponent } from "./pages"; +/** + * Hook to destroy the OverlayContainer inside which all overlays are rendered (i.e. stark-dropdown options) + * Fixes https://github.com/NationalBankBelgium/stark/issues/1570 + */ +export function destroyOverlaysOnEnterFn(transition: Transition): HookResult { + try { + // inject the OverlayContainer + const overlayContainer = transition.injector().getNative(OverlayContainer); + // destroy the container by calling its own "ngOnDestroy" method + // see https://github.com/angular/components/pull/5378/files + /* tslint:disable-next-line:no-lifecycle-call*/ + overlayContainer.ngOnDestroy(); + } catch (err) { + // the OverlayContainer could not be injected, do nothing + } + + return true; +} + /** * States defined by Session-UI Module */ @@ -46,7 +67,8 @@ export const SESSION_UI_STATES: Ng2StateDeclaration[] = [ "initOrExit@": { component: StarkSessionExpiredPageComponent } - } + }, + onEnter: destroyOverlaysOnEnterFn }, { name: starkSessionLogoutStateName, // the parent is defined in the state's name (contains a dot) @@ -55,6 +77,7 @@ export const SESSION_UI_STATES: Ng2StateDeclaration[] = [ "initOrExit@": { component: StarkSessionLogoutPageComponent } - } + }, + onEnter: destroyOverlaysOnEnterFn } ]; diff --git a/packages/stark-ui/src/modules/session-ui/session-ui.module.spec.ts b/packages/stark-ui/src/modules/session-ui/session-ui.module.spec.ts new file mode 100644 index 0000000000..4ea752c748 --- /dev/null +++ b/packages/stark-ui/src/modules/session-ui/session-ui.module.spec.ts @@ -0,0 +1,149 @@ +/* tslint:disable:completed-docs */ +import { async, fakeAsync, inject, TestBed, tick } from "@angular/core/testing"; +import { Component, ModuleWithProviders, NgModuleFactoryLoader, SystemJsNgModuleLoader } from "@angular/core"; +import { OverlayContainer } from "@angular/cdk/overlay"; +import { UIRouterModule } from "@uirouter/angular"; +import { Store } from "@ngrx/store"; +import { EffectsModule } from "@ngrx/effects"; +import { provideMockActions } from "@ngrx/effects/testing"; +import { StateObject, StateService, UIRouter } from "@uirouter/core"; +import { TranslateModule } from "@ngx-translate/core"; +import { catchError, switchMap } from "rxjs/operators"; +import { from, of, throwError } from "rxjs"; +import { + SESSION_STATES, + STARK_SESSION_SERVICE, + starkSessionExpiredStateName, + starkSessionLogoutStateName +} from "@nationalbankbelgium/stark-core"; +import { MockStarkSessionService } from "@nationalbankbelgium/stark-core/testing"; +import { StarkSessionUiModule } from "./session-ui.module"; +import createSpyObj = jasmine.createSpyObj; +import SpyObj = jasmine.SpyObj; + +describe("SessionUiModule", () => { + let $state: StateService; + let router: UIRouter; + let overlayContainer: SpyObj; + const homeStateNAme = "homepage"; + + @Component({ selector: "home-component", template: "HOME" }) + class HomeComponent {} + + const routerModule: ModuleWithProviders = UIRouterModule.forRoot({ + useHash: true, + states: [ + { + name: homeStateNAme, + url: `/${homeStateNAme}`, + parent: "", + component: HomeComponent + }, + ...SESSION_STATES // these are the parent states of the Session UI States + ] + }); + + beforeEach(async(() => { + return TestBed.configureTestingModule({ + declarations: [HomeComponent], + imports: [routerModule, EffectsModule.forRoot([]), TranslateModule.forRoot(), StarkSessionUiModule.forRoot()], + providers: [ + provideMockActions(() => of("some action")), + { + provide: Store, + useValue: createSpyObj>("Store", ["dispatch"]) + }, + { + provide: OverlayContainer, + useValue: createSpyObj("OverlayContainer", ["ngOnDestroy"]) + }, + { + provide: STARK_SESSION_SERVICE, + useValue: new MockStarkSessionService() + }, + { provide: NgModuleFactoryLoader, useClass: SystemJsNgModuleLoader } // needed for ui-router + ] + }).compileComponents(); + })); + + // Inject module dependencies + beforeEach(inject([UIRouter, OverlayContainer], (_router: UIRouter, _overlayContainer: SpyObj) => { + router = _router; + overlayContainer = _overlayContainer; + $state = router.stateService; + + overlayContainer.ngOnDestroy.calls.reset(); + })); + + afterEach(() => { + // IMPORTANT: reset the url after each test, + // otherwise UI-Router will try to find a match of the current url and navigate to it!! + router.urlService.url(""); + }); + + describe("session UI states", () => { + describe("starkSessionExpiredState", () => { + it("when navigating to the state, it should destroy the Angular CDK OverlayContainer", fakeAsync(() => { + from($state.go(homeStateNAme)) + .pipe( + switchMap((enteredState: StateObject) => { + expect(enteredState).toBeDefined(); + expect(enteredState.name).toBe(homeStateNAme); + + expect($state.$current.name).toBe(enteredState.name); + expect(overlayContainer.ngOnDestroy).not.toHaveBeenCalled(); + + return $state.go(starkSessionExpiredStateName); + }), + catchError((error: any) => { + return throwError(`currentState ${error}`); + }) + ) + .subscribe( + (enteredState: StateObject) => { + expect(enteredState).toBeDefined(); + expect(enteredState.name).toBe(starkSessionExpiredStateName); + + expect($state.$current.name).toBe(enteredState.name); + expect(overlayContainer.ngOnDestroy).toHaveBeenCalledTimes(1); + }, + (error: any) => fail(error) + ); + + tick(); + })); + }); + + describe("starkSessionLogoutState", () => { + it("when navigating to the state, it should destroy the Angular CDK OverlayContainer", fakeAsync(() => { + from($state.go(homeStateNAme)) + .pipe( + switchMap((enteredState: StateObject) => { + expect(enteredState).toBeDefined(); + expect(enteredState.name).toBe(homeStateNAme); + + expect($state.$current.name).toBe(enteredState.name); + expect(overlayContainer.ngOnDestroy).not.toHaveBeenCalled(); + + return $state.go(starkSessionLogoutStateName); + }), + catchError((error: any) => { + return throwError(`currentState ${error}`); + }) + ) + .subscribe( + (enteredState: StateObject) => { + expect(enteredState).toBeDefined(); + expect(enteredState.name).toBe(starkSessionLogoutStateName); + + expect($state.$current.name).toBe(enteredState.name); + expect(overlayContainer.ngOnDestroy).toHaveBeenCalledTimes(1); + }, + (error: any) => fail(error) + ); + + tick(); + })); + }); + }); +});