diff --git a/modules/store/spec/integration.spec.ts b/modules/store/spec/integration.spec.ts index 9267e860ce..e124788913 100644 --- a/modules/store/spec/integration.spec.ts +++ b/modules/store/spec/integration.spec.ts @@ -176,25 +176,25 @@ describe('ngRx Integration spec', () => { }); describe('feature state', () => { - const initialState = { - todos: [ - { - id: 1, - text: 'do things', - completed: false, - }, - ], - visibilityFilter: VisibilityFilters.SHOW_ALL, - }; + it('should initialize properly', () => { + const initialState = { + todos: [ + { + id: 1, + text: 'do things', + completed: false, + }, + ], + visibilityFilter: VisibilityFilters.SHOW_ALL, + }; - const reducers: ActionReducerMap = { - todos: todos, - visibilityFilter: visibilityFilter, - }; + const reducers: ActionReducerMap = { + todos: todos, + visibilityFilter: visibilityFilter, + }; - const featureInitialState = [{ id: 1, completed: false, text: 'Item' }]; + const featureInitialState = [{ id: 1, completed: false, text: 'Item' }]; - it('should initialize properly', () => { TestBed.configureTestingModule({ imports: [ StoreModule.forRoot(reducers, { initialState }), @@ -218,5 +218,40 @@ describe('ngRx Integration spec', () => { expect(state).toEqual(expected.shift()); }); }); + + it('should initialize properly with a partial state', () => { + const initialState = { + items: [{ id: 1, completed: false, text: 'Item' }], + }; + + const reducers: ActionReducerMap = { + todos: todos, + visibilityFilter: visibilityFilter, + }; + + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({} as any, { + initialState, + }), + StoreModule.forFeature('todos', reducers), + StoreModule.forFeature('items', todos), + ], + }); + + const store: Store = TestBed.get(Store); + + const expected = { + todos: { + todos: [], + visibilityFilter: VisibilityFilters.SHOW_ALL, + }, + items: [{ id: 1, completed: false, text: 'Item' }], + }; + + store.pipe(select(state => state)).subscribe(state => { + expect(state).toEqual(expected); + }); + }); }); }); diff --git a/modules/store/spec/store.spec.ts b/modules/store/spec/store.spec.ts index b0375ac9ca..22562bcf48 100644 --- a/modules/store/spec/store.spec.ts +++ b/modules/store/spec/store.spec.ts @@ -7,6 +7,9 @@ import { Store, StoreModule, select, + ReducerManagerDispatcher, + UPDATE, + REDUCER_FACTORY, } from '../'; import { counterReducer, @@ -47,56 +50,42 @@ describe('ngRx Store', () => { describe('initial state', () => { it('should handle an initial state object', (done: any) => { setup(); - - store.pipe(take(1)).subscribe({ - next(val) { - expect(val).toEqual({ counter1: 0, counter2: 1, counter3: 0 }); - }, - error: done, - complete: done, - }); + testStoreValue({ counter1: 0, counter2: 1, counter3: 0 }, done); }); it('should handle an initial state function', (done: any) => { setup(() => ({ counter1: 0, counter2: 5 })); - - store.pipe(take(1)).subscribe({ - next(val) { - expect(val).toEqual({ counter1: 0, counter2: 5, counter3: 0 }); - }, - error: done, - complete: done, - }); + testStoreValue({ counter1: 0, counter2: 5, counter3: 0 }, done); }); - function testInitialState(feature?: string) { - store = TestBed.get(Store); - dispatcher = TestBed.get(ActionsSubject); - - const actionSequence = '--a--b--c--d--e--f--g'; - const stateSequence = 'i-w-----x-----y--z---'; - const actionValues = { - a: { type: INCREMENT }, - b: { type: 'OTHER' }, - c: { type: RESET }, - d: { type: 'OTHER' }, // reproduces https://github.com/ngrx/platform/issues/880 because state is falsey - e: { type: INCREMENT }, - f: { type: INCREMENT }, - g: { type: 'OTHER' }, - }; - const counterSteps = hot(actionSequence, actionValues); - counterSteps.subscribe(action => store.dispatch(action)); - - const counterStateWithString = feature - ? (store as any).select(feature, 'counter1') - : store.select('counter1'); - - const counter1Values = { i: 1, w: 2, x: 0, y: 1, z: 2 }; + it('should keep initial state values when state is partially initialized', (done: DoneFn) => { + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({} as any, { + initialState: { + feature1: { + counter1: 1, + }, + feature3: { + counter3: 3, + }, + }, + }), + StoreModule.forFeature('feature1', { counter1: counterReducer }), + StoreModule.forFeature('feature2', { counter2: counterReducer }), + StoreModule.forFeature('feature3', { counter3: counterReducer }), + ], + }); - expect(counterStateWithString).toBeObservable( - hot(stateSequence, counter1Values) + testStoreValue( + { + feature1: { counter1: 1 }, + feature2: { counter2: 0 }, + feature3: { counter3: 3 }, + }, + done ); - } + }); it('should reset to initial state when undefined (root ActionReducerMap)', () => { TestBed.configureTestingModule({ @@ -138,6 +127,47 @@ describe('ngRx Store', () => { testInitialState('feature1'); }); + + function testInitialState(feature?: string) { + store = TestBed.get(Store); + dispatcher = TestBed.get(ActionsSubject); + + const actionSequence = '--a--b--c--d--e--f--g'; + const stateSequence = 'i-w-----x-----y--z---'; + const actionValues = { + a: { type: INCREMENT }, + b: { type: 'OTHER' }, + c: { type: RESET }, + d: { type: 'OTHER' }, // reproduces https://github.com/ngrx/platform/issues/880 because state is falsey + e: { type: INCREMENT }, + f: { type: INCREMENT }, + g: { type: 'OTHER' }, + }; + const counterSteps = hot(actionSequence, actionValues); + counterSteps.subscribe(action => store.dispatch(action)); + + const counterStateWithString = feature + ? (store as any).select(feature, 'counter1') + : store.select('counter1'); + + const counter1Values = { i: 1, w: 2, x: 0, y: 1, z: 2 }; + + expect(counterStateWithString).toBeObservable( + hot(stateSequence, counter1Values) + ); + } + + function testStoreValue(expected: any, done: DoneFn) { + store = TestBed.get(Store); + + store.pipe(take(1)).subscribe({ + next(val) { + expect(val).toEqual(expected); + }, + error: done, + complete: done, + }); + } }); describe('basic store actions', () => { @@ -267,16 +297,19 @@ describe('ngRx Store', () => { describe(`add/remove reducers`, () => { let addReducerSpy: Spy; let removeReducerSpy: Spy; + let reducerManagerDispatcherSpy: Spy; const key = 'counter4'; beforeEach(() => { setup(); const reducerManager = TestBed.get(ReducerManager); + const dispatcher = TestBed.get(ReducerManagerDispatcher); addReducerSpy = spyOn(reducerManager, 'addReducer').and.callThrough(); removeReducerSpy = spyOn( reducerManager, 'removeReducer' ).and.callThrough(); + reducerManagerDispatcherSpy = spyOn(dispatcher, 'next').and.callThrough(); }); it(`should delegate add/remove to ReducerManager`, () => { @@ -299,5 +332,118 @@ describe('ngRx Store', () => { expect(val.counter4).toBeUndefined(); }); }); + + it('should dispatch an update reducers action when a reducer is added', () => { + store.addReducer(key, counterReducer); + expect(reducerManagerDispatcherSpy).toHaveBeenCalledWith({ + type: UPDATE, + feature: key, + }); + }); + + it('should dispatch an update reducers action when a reducer is removed', () => { + store.removeReducer(key); + expect(reducerManagerDispatcherSpy).toHaveBeenCalledWith({ + type: UPDATE, + feature: key, + }); + }); + }); + + describe('add/remove features', () => { + let reducerManager: ReducerManager; + let reducerManagerDispatcherSpy: Spy; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [StoreModule.forRoot({})], + }); + + reducerManager = TestBed.get(ReducerManager); + const dispatcher = TestBed.get(ReducerManagerDispatcher); + reducerManagerDispatcherSpy = spyOn(dispatcher, 'next').and.callThrough(); + }); + + it('should dispatch an update reducers action when a feature is added', () => { + reducerManager.addFeature( + createFeature({ + key: 'feature1', + }) + ); + + expect(reducerManagerDispatcherSpy).toHaveBeenCalledWith({ + type: UPDATE, + feature: 'feature1', + }); + }); + + it('should dispatch an update reducers action for each feature that is added', () => { + reducerManager.addFeatures([ + createFeature({ + key: 'feature1', + }), + createFeature({ + key: 'feature2', + }), + ]); + + expect(reducerManagerDispatcherSpy).toHaveBeenCalledTimes(2); + + // get the first argument for the first call + expect(reducerManagerDispatcherSpy.calls.argsFor(0)[0]).toEqual({ + type: UPDATE, + feature: 'feature1', + }); + + // get the first argument for the second call + expect(reducerManagerDispatcherSpy.calls.argsFor(1)[0]).toEqual({ + type: UPDATE, + feature: 'feature2', + }); + }); + + it('should dispatch an update reducers action when a feature is removed', () => { + reducerManager.removeFeature( + createFeature({ + key: 'feature1', + }) + ); + + expect(reducerManagerDispatcherSpy).toHaveBeenCalledWith({ + type: UPDATE, + feature: 'feature1', + }); + }); + + it('should dispatch an update reducers action for each feature that is removed', () => { + reducerManager.removeFeatures([ + createFeature({ + key: 'feature1', + }), + createFeature({ + key: 'feature2', + }), + ]); + + // get the first argument for the first call + expect(reducerManagerDispatcherSpy.calls.argsFor(0)[0]).toEqual({ + type: UPDATE, + feature: 'feature1', + }); + + // get the first argument for the second call + expect(reducerManagerDispatcherSpy.calls.argsFor(1)[0]).toEqual({ + type: UPDATE, + feature: 'feature2', + }); + }); + + function createFeature({ key }: { key: string }) { + return { + key, + reducers: {}, + reducerFactory: jasmine.createSpy(`reducerFactory_${key}`), + }; + } }); }); diff --git a/modules/store/src/reducer_manager.ts b/modules/store/src/reducer_manager.ts index 89e3433f5b..de6e733777 100644 --- a/modules/store/src/reducer_manager.ts +++ b/modules/store/src/reducer_manager.ts @@ -1,89 +1,113 @@ -import { Inject, Injectable, OnDestroy, Provider } from '@angular/core'; -import { BehaviorSubject, Observable } from 'rxjs'; - -import { ActionsSubject } from './actions_subject'; -import { - Action, - ActionReducer, - ActionReducerFactory, - ActionReducerMap, - StoreFeature, -} from './models'; -import { INITIAL_REDUCERS, INITIAL_STATE, REDUCER_FACTORY } from './tokens'; -import { - createFeatureReducerFactory, - createReducerFactory, - omit, -} from './utils'; - -export abstract class ReducerObservable extends Observable< - ActionReducer -> {} -export abstract class ReducerManagerDispatcher extends ActionsSubject {} -export const UPDATE = '@ngrx/store/update-reducers' as '@ngrx/store/update-reducers'; - -@Injectable() -export class ReducerManager extends BehaviorSubject> - implements OnDestroy { - constructor( - private dispatcher: ReducerManagerDispatcher, - @Inject(INITIAL_STATE) private initialState: any, - @Inject(INITIAL_REDUCERS) private reducers: ActionReducerMap, - @Inject(REDUCER_FACTORY) - private reducerFactory: ActionReducerFactory - ) { - super(reducerFactory(reducers, initialState)); - } - - addFeature({ - reducers, - reducerFactory, - metaReducers, - initialState, - key, - }: StoreFeature) { - const reducer = - typeof reducers === 'function' - ? createFeatureReducerFactory(metaReducers)(reducers, initialState) - : createReducerFactory(reducerFactory, metaReducers)( - reducers, - initialState - ); - - this.addReducer(key, reducer); - } - - removeFeature({ key }: StoreFeature) { - this.removeReducer(key); - } - - addReducer(key: string, reducer: ActionReducer) { - this.reducers = { ...this.reducers, [key]: reducer }; - - this.updateReducers(key); - } - - removeReducer(key: string) { - this.reducers = omit(this.reducers, key) /*TODO(#823)*/ as any; - - this.updateReducers(key); - } - - private updateReducers(key: string) { - this.next(this.reducerFactory(this.reducers, this.initialState)); - this.dispatcher.next({ - type: UPDATE, - feature: key, - }); - } - - ngOnDestroy() { - this.complete(); - } -} - -export const REDUCER_MANAGER_PROVIDERS: Provider[] = [ - ReducerManager, - { provide: ReducerObservable, useExisting: ReducerManager }, - { provide: ReducerManagerDispatcher, useExisting: ActionsSubject }, -]; +import { Inject, Injectable, OnDestroy, Provider } from '@angular/core'; +import { BehaviorSubject, Observable } from 'rxjs'; + +import { ActionsSubject } from './actions_subject'; +import { + Action, + ActionReducer, + ActionReducerFactory, + ActionReducerMap, + StoreFeature, +} from './models'; +import { INITIAL_REDUCERS, INITIAL_STATE, REDUCER_FACTORY } from './tokens'; +import { + createFeatureReducerFactory, + createReducerFactory, + omit, +} from './utils'; + +export abstract class ReducerObservable extends Observable< + ActionReducer +> {} +export abstract class ReducerManagerDispatcher extends ActionsSubject {} +export const UPDATE = '@ngrx/store/update-reducers' as '@ngrx/store/update-reducers'; + +@Injectable() +export class ReducerManager extends BehaviorSubject> + implements OnDestroy { + constructor( + private dispatcher: ReducerManagerDispatcher, + @Inject(INITIAL_STATE) private initialState: any, + @Inject(INITIAL_REDUCERS) private reducers: ActionReducerMap, + @Inject(REDUCER_FACTORY) + private reducerFactory: ActionReducerFactory + ) { + super(reducerFactory(reducers, initialState)); + } + + addFeature(feature: StoreFeature) { + this.addFeatures([feature]); + } + + addFeatures(features: StoreFeature[]) { + const reducers = features.reduce( + ( + reducerDict, + { reducers, reducerFactory, metaReducers, initialState, key } + ) => { + const reducer = + typeof reducers === 'function' + ? createFeatureReducerFactory(metaReducers)(reducers, initialState) + : createReducerFactory(reducerFactory, metaReducers)( + reducers, + initialState + ); + + reducerDict[key] = reducer; + return reducerDict; + }, + {} as { [key: string]: ActionReducer } + ); + + this.addReducers(reducers); + } + + removeFeature(feature: StoreFeature) { + this.removeFeatures([feature]); + } + + removeFeatures(features: StoreFeature[]) { + this.removeReducers(features.map(p => p.key)); + } + + addReducer(key: string, reducer: ActionReducer) { + this.addReducers({ [key]: reducer }); + } + + addReducers(reducers: { [key: string]: ActionReducer }) { + this.reducers = { ...this.reducers, ...reducers }; + this.updateReducers(Object.keys(reducers)); + } + + removeReducer(featureKey: string) { + this.removeReducers([featureKey]); + } + + removeReducers(featureKeys: string[]) { + featureKeys.forEach(key => { + this.reducers = omit(this.reducers, key) /*TODO(#823)*/ as any; + }); + this.updateReducers(featureKeys); + } + + private updateReducers(featureKeys: string[]) { + this.next(this.reducerFactory(this.reducers, this.initialState)); + + featureKeys.forEach(feature => { + this.dispatcher.next({ + type: UPDATE, + feature, + }); + }); + } + + ngOnDestroy() { + this.complete(); + } +} + +export const REDUCER_MANAGER_PROVIDERS: Provider[] = [ + ReducerManager, + { provide: ReducerObservable, useExisting: ReducerManager }, + { provide: ReducerManagerDispatcher, useExisting: ActionsSubject }, +]; diff --git a/modules/store/src/store_module.ts b/modules/store/src/store_module.ts index 9275dd5955..32aa2b0616 100644 --- a/modules/store/src/store_module.ts +++ b/modules/store/src/store_module.ts @@ -61,24 +61,22 @@ export class StoreFeatureModule implements OnDestroy { private reducerManager: ReducerManager, root: StoreRootModule ) { - features - .map((feature, index) => { - const featureReducerCollection = featureReducers.shift(); - const reducers = featureReducerCollection /*TODO(#823)*/![index]; + const feats = features.map((feature, index) => { + const featureReducerCollection = featureReducers.shift(); + const reducers = featureReducerCollection /*TODO(#823)*/![index]; - return { - ...feature, - reducers, - initialState: _initialStateFactory(feature.initialState), - }; - }) - .forEach(feature => reducerManager.addFeature(feature)); + return { + ...feature, + reducers, + initialState: _initialStateFactory(feature.initialState), + }; + }); + + reducerManager.addFeatures(feats); } ngOnDestroy() { - this.features.forEach(feature => - this.reducerManager.removeFeature(feature) - ); + this.reducerManager.removeFeatures(this.features); } }