From 4ad9a946b762c9d928fe54476f65557e65e11e3d Mon Sep 17 00:00:00 2001 From: kouMatsumoto Date: Wed, 26 Sep 2018 20:23:58 +0900 Subject: [PATCH] feat(router-store): update stateKey definition to take a string or selector Closes #1300 --- modules/router-store/spec/integration.spec.ts | 147 ++++++++++-------- .../spec/router_store_module.spec.ts | 131 ++++++++++++++++ modules/router-store/spec/utils.ts | 62 ++++++++ modules/router-store/src/index.ts | 1 + .../router-store/src/router_store_module.ts | 10 +- 5 files changed, 282 insertions(+), 69 deletions(-) create mode 100644 modules/router-store/spec/router_store_module.spec.ts create mode 100644 modules/router-store/spec/utils.ts diff --git a/modules/router-store/spec/integration.spec.ts b/modules/router-store/spec/integration.spec.ts index c3a248bde6..75ef89af74 100644 --- a/modules/router-store/spec/integration.spec.ts +++ b/modules/router-store/spec/integration.spec.ts @@ -1,4 +1,4 @@ -import { Component, Provider, Injectable, ErrorHandler } from '@angular/core'; +import { Injectable, ErrorHandler } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { NavigationEnd, @@ -7,8 +7,7 @@ import { NavigationCancel, NavigationError, } from '@angular/router'; -import { RouterTestingModule } from '@angular/router/testing'; -import { Store, StoreModule, ScannedActionsSubject } from '@ngrx/store'; +import { Store, ScannedActionsSubject } from '@ngrx/store'; import { filter, first, mapTo, take } from 'rxjs/operators'; import { @@ -22,9 +21,9 @@ import { routerReducer, RouterReducerState, RouterStateSerializer, - StoreRouterConfig, - StoreRouterConnectingModule, + StateKeyOrSelector, } from '../src'; +import { createTestModule } from './utils'; describe('integration spec', () => { it('should work', (done: any) => { @@ -677,12 +676,76 @@ describe('integration spec', () => { }; createTestModule({ - reducers: { reducer }, + reducers: { 'router-reducer': reducer }, config: { stateKey: 'router-reducer' }, }); const router: Router = TestBed.get(Router); - const log = logOfRouterAndActionsAndStore(); + const log = logOfRouterAndActionsAndStore({ stateKey: 'router-reducer' }); + + router + .navigateByUrl('/') + .then(() => { + expect(log).toEqual([ + { type: 'store', state: '' }, // init event. has nothing to do with the router + { type: 'store', state: '' }, // ROUTER_REQUEST event in the store + { type: 'action', action: ROUTER_REQUEST }, + { type: 'router', event: 'NavigationStart', url: '/' }, + { type: 'store', state: '/' }, // ROUTER_NAVIGATION event in the store + { type: 'action', action: ROUTER_NAVIGATION }, + { type: 'router', event: 'RoutesRecognized', url: '/' }, + { type: 'router', event: 'GuardsCheckStart', url: '/' }, + { type: 'router', event: 'GuardsCheckEnd', url: '/' }, + { type: 'router', event: 'ResolveStart', url: '/' }, + { type: 'router', event: 'ResolveEnd', url: '/' }, + { type: 'store', state: '/' }, // ROUTER_NAVIGATED event in the store + { type: 'action', action: ROUTER_NAVIGATED }, + { type: 'router', event: 'NavigationEnd', url: '/' }, + ]); + }) + .then(() => { + log.splice(0); + return router.navigateByUrl('next'); + }) + .then(() => { + expect(log).toEqual([ + { type: 'store', state: '/' }, + { type: 'action', action: ROUTER_REQUEST }, + { type: 'router', event: 'NavigationStart', url: '/next' }, + { type: 'store', state: '/next' }, + { type: 'action', action: ROUTER_NAVIGATION }, + { type: 'router', event: 'RoutesRecognized', url: '/next' }, + { type: 'router', event: 'GuardsCheckStart', url: '/next' }, + { type: 'router', event: 'GuardsCheckEnd', url: '/next' }, + { type: 'router', event: 'ResolveStart', url: '/next' }, + { type: 'router', event: 'ResolveEnd', url: '/next' }, + { type: 'store', state: '/next' }, + { type: 'action', action: ROUTER_NAVIGATED }, + { type: 'router', event: 'NavigationEnd', url: '/next' }, + ]); + + done(); + }); + }); + + it('should work when defining state selector', (done: any) => { + const reducer = (state: string = '', action: RouterAction) => { + if (action.type === ROUTER_NAVIGATION) { + return action.payload.routerState.url.toString(); + } else { + return state; + } + }; + + createTestModule({ + reducers: { routerReducer: reducer }, + config: { stateKey: (state: any) => state.routerReducer }, + }); + + const router: Router = TestBed.get(Router); + const log = logOfRouterAndActionsAndStore({ + stateKey: (state: any) => state.routerReducer, + }); router .navigateByUrl('/') @@ -825,62 +888,6 @@ describe('integration spec', () => { }); }); -function createTestModule( - opts: { - reducers?: any; - canActivate?: Function; - canLoad?: Function; - providers?: Provider[]; - config?: StoreRouterConfig; - } = {} -) { - @Component({ - selector: 'test-app', - template: '', - }) - class AppCmp {} - - @Component({ - selector: 'pagea-cmp', - template: 'pagea-cmp', - }) - class SimpleCmp {} - - TestBed.configureTestingModule({ - declarations: [AppCmp, SimpleCmp], - imports: [ - StoreModule.forRoot(opts.reducers), - RouterTestingModule.withRoutes([ - { path: '', component: SimpleCmp }, - { - path: 'next', - component: SimpleCmp, - canActivate: ['CanActivateNext'], - }, - { - path: 'load', - loadChildren: 'test', - canLoad: ['CanLoadNext'], - }, - ]), - StoreRouterConnectingModule.forRoot(opts.config), - ], - providers: [ - { - provide: 'CanActivateNext', - useValue: opts.canActivate || (() => true), - }, - { - provide: 'CanLoadNext', - useValue: opts.canLoad || (() => true), - }, - opts.providers || [], - ], - }); - - TestBed.createComponent(AppCmp); -} - function waitForNavigation(router: Router, event: any = NavigationEnd) { return router.events .pipe( @@ -897,7 +904,11 @@ function waitForNavigation(router: Router, event: any = NavigationEnd) { * Example: router event is fired -> store is updated -> store log appears before router log * Also, actions$ always fires the next action AFTER the store is updated */ -function logOfRouterAndActionsAndStore(): any[] { +function logOfRouterAndActionsAndStore( + options: { stateKey: StateKeyOrSelector } = { + stateKey: 'reducer', + } +): any[] { const router: Router = TestBed.get(Router); const store: Store = TestBed.get(Store); // Not using effects' Actions to avoid @ngrx/effects dependency @@ -915,6 +926,12 @@ function logOfRouterAndActionsAndStore(): any[] { actions$.subscribe(action => log.push({ type: 'action', action: action.type }) ); - store.subscribe(store => log.push({ type: 'store', state: store.reducer })); + store.subscribe(store => { + if (typeof options.stateKey === 'function') { + log.push({ type: 'store', state: options.stateKey(store) }); + } else { + log.push({ type: 'store', state: store[options.stateKey] }); + } + }); return log; } diff --git a/modules/router-store/spec/router_store_module.spec.ts b/modules/router-store/spec/router_store_module.spec.ts new file mode 100644 index 0000000000..51be5277a7 --- /dev/null +++ b/modules/router-store/spec/router_store_module.spec.ts @@ -0,0 +1,131 @@ +import { TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { + routerReducer, + RouterReducerState, + StoreRouterConnectingModule, +} from '@ngrx/router-store'; +import { select, Store } from '@ngrx/store'; +import { withLatestFrom } from 'rxjs/operators'; + +import { createTestModule } from './utils'; + +describe('Router Store Module', () => { + describe('with defining state key', () => { + const customStateKey = 'router-reducer'; + let storeRouterConnectingModule: StoreRouterConnectingModule; + let store: Store; + let router: Router; + + interface State { + [customStateKey]: RouterReducerState; + } + + beforeEach(() => { + createTestModule({ + reducers: { + [customStateKey]: routerReducer, + }, + config: { + stateKey: customStateKey, + }, + }); + + store = TestBed.get(Store); + router = TestBed.get(Router); + storeRouterConnectingModule = TestBed.get(StoreRouterConnectingModule); + }); + + it('should have custom state key as own property', () => { + expect((storeRouterConnectingModule).stateKey).toBe(customStateKey); + }); + + it('should call navigateIfNeeded with args selected by custom state key', (done: any) => { + let logs: any[] = []; + store + .pipe( + select(customStateKey), + withLatestFrom(store) + ) + .subscribe(([routerStoreState, storeState]) => { + logs.push([routerStoreState, storeState]); + }); + + spyOn(storeRouterConnectingModule, 'navigateIfNeeded').and.callThrough(); + logs = []; + + // this dispatches `@ngrx/router-store/navigation` action + // and store emits its payload. + router.navigateByUrl('/').then(() => { + const actual = (( + storeRouterConnectingModule + )).navigateIfNeeded.calls.allArgs(); + + expect(actual.length).toBe(1); + expect(actual[0]).toEqual(logs[0]); + done(); + }); + }); + }); + + describe('with defining state selector', () => { + const customStateKey = 'routerReducer'; + const customStateSelector = (state: State) => state.routerReducer; + + let storeRouterConnectingModule: StoreRouterConnectingModule; + let store: Store; + let router: Router; + + interface State { + [customStateKey]: RouterReducerState; + } + + beforeEach(() => { + createTestModule({ + reducers: { + [customStateKey]: routerReducer, + }, + config: { + stateKey: customStateSelector, + }, + }); + + store = TestBed.get(Store); + router = TestBed.get(Router); + storeRouterConnectingModule = TestBed.get(StoreRouterConnectingModule); + }); + + it('should have same state selector as own property', () => { + expect((storeRouterConnectingModule).stateKey).toBe( + customStateSelector + ); + }); + + it('should call navigateIfNeeded with args selected by custom state selector', (done: any) => { + let logs: any[] = []; + store + .pipe( + select(customStateSelector), + withLatestFrom(store) + ) + .subscribe(([routerStoreState, storeState]) => { + logs.push([routerStoreState, storeState]); + }); + + spyOn(storeRouterConnectingModule, 'navigateIfNeeded').and.callThrough(); + logs = []; + + // this dispatches `@ngrx/router-store/navigation` action + // and store emits its payload. + router.navigateByUrl('/').then(() => { + const actual = (( + storeRouterConnectingModule + )).navigateIfNeeded.calls.allArgs(); + + expect(actual.length).toBe(1); + expect(actual[0]).toEqual(logs[0]); + done(); + }); + }); + }); +}); diff --git a/modules/router-store/spec/utils.ts b/modules/router-store/spec/utils.ts new file mode 100644 index 0000000000..9040aa1042 --- /dev/null +++ b/modules/router-store/spec/utils.ts @@ -0,0 +1,62 @@ +import { Component, Provider } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { StoreModule } from '@ngrx/store'; + +import { StoreRouterConfig, StoreRouterConnectingModule } from '../src'; + +export function createTestModule( + opts: { + reducers?: any; + canActivate?: Function; + canLoad?: Function; + providers?: Provider[]; + config?: StoreRouterConfig; + } = {} +) { + @Component({ + selector: 'test-app', + template: '', + }) + class AppCmp {} + + @Component({ + selector: 'page-cmp', + template: 'page-cmp', + }) + class SimpleCmp {} + + TestBed.configureTestingModule({ + declarations: [AppCmp, SimpleCmp], + imports: [ + StoreModule.forRoot(opts.reducers), + RouterTestingModule.withRoutes([ + { path: '', component: SimpleCmp }, + { + path: 'next', + component: SimpleCmp, + canActivate: ['CanActivateNext'], + }, + { + path: 'load', + loadChildren: 'test', + canLoad: ['CanLoadNext'], + }, + ]), + StoreRouterConnectingModule.forRoot(opts.config), + ], + providers: [ + { + provide: 'CanActivateNext', + useValue: opts.canActivate || (() => true), + }, + { + provide: 'CanLoadNext', + useValue: opts.canLoad || (() => true), + }, + opts.providers || [], + ], + }); + + TestBed.createComponent(AppCmp); +} diff --git a/modules/router-store/src/index.ts b/modules/router-store/src/index.ts index b57b0c59b7..0cd5d67d1b 100644 --- a/modules/router-store/src/index.ts +++ b/modules/router-store/src/index.ts @@ -18,6 +18,7 @@ export { } from './actions'; export { routerReducer, RouterReducerState } from './reducer'; export { + StateKeyOrSelector, StoreRouterConnectingModule, StoreRouterConfig, NavigationActionTiming, diff --git a/modules/router-store/src/router_store_module.ts b/modules/router-store/src/router_store_module.ts index 91242234ad..47444a80d8 100644 --- a/modules/router-store/src/router_store_module.ts +++ b/modules/router-store/src/router_store_module.ts @@ -13,7 +13,7 @@ import { RoutesRecognized, NavigationStart, } from '@angular/router'; -import { select, Store } from '@ngrx/store'; +import { select, Selector, Store } from '@ngrx/store'; import { withLatestFrom } from 'rxjs/operators'; import { @@ -30,8 +30,10 @@ import { SerializedRouterStateSnapshot, } from './serializer'; +export type StateKeyOrSelector = string | Selector; + export interface StoreRouterConfig { - stateKey?: string; + stateKey?: StateKeyOrSelector; serializer?: new (...args: any[]) => RouterStateSerializer; /** * By default, ROUTER_NAVIGATION is dispatched before guards and resolvers run. @@ -152,7 +154,7 @@ export class StoreRouterConnectingModule { private storeState: any; private trigger = RouterTrigger.NONE; - private stateKey: string; + private stateKey: StateKeyOrSelector; constructor( private store: Store, @@ -161,7 +163,7 @@ export class StoreRouterConnectingModule { private errorHandler: ErrorHandler, @Inject(ROUTER_CONFIG) private config: StoreRouterConfig ) { - this.stateKey = this.config.stateKey as string; + this.stateKey = this.config.stateKey as StateKeyOrSelector; this.setUpStoreStateListener(); this.setUpRouterEventsListener();