From 5fd1c7b3afc35159db72ae7afb7a8240a0768040 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Stanimirovi=C4=87?= Date: Wed, 9 Jun 2021 15:34:53 +0200 Subject: [PATCH] feat(store): add createFeature (#3033) Closes #2974 --- modules/store/spec/feature_creator.spec.ts | 120 +++++++ modules/store/spec/helpers.spec.ts | 13 + .../store/spec/types/feature_creator.spec.ts | 337 ++++++++++++++++++ modules/store/src/feature_creator.ts | 107 ++++++ modules/store/src/helpers.ts | 3 + modules/store/src/index.ts | 1 + 6 files changed, 581 insertions(+) create mode 100644 modules/store/spec/feature_creator.spec.ts create mode 100644 modules/store/spec/helpers.spec.ts create mode 100644 modules/store/spec/types/feature_creator.spec.ts create mode 100644 modules/store/src/feature_creator.ts create mode 100644 modules/store/src/helpers.ts diff --git a/modules/store/spec/feature_creator.spec.ts b/modules/store/spec/feature_creator.spec.ts new file mode 100644 index 0000000000..eb78eef3b9 --- /dev/null +++ b/modules/store/spec/feature_creator.spec.ts @@ -0,0 +1,120 @@ +import { createFeature, createReducer, Store, StoreModule } from '@ngrx/store'; +import { TestBed } from '@angular/core/testing'; +import { take } from 'rxjs/operators'; + +describe('createFeature()', () => { + it('should return passed name and reducer', () => { + const fooName = 'foo'; + const fooReducer = createReducer(0); + + const { name, reducer } = createFeature({ + name: fooName, + reducer: fooReducer, + }); + + expect(name).toBe(fooName); + expect(reducer).toBe(fooReducer); + }); + + it('should create a feature selector', () => { + const { selectFooState } = createFeature({ + name: 'foo', + reducer: createReducer({ bar: '' }), + }); + + expect(selectFooState({ foo: { bar: 'baz' } })).toEqual({ bar: 'baz' }); + }); + + describe('nested selectors', () => { + it('should create when feature state is a dictionary', () => { + const initialState = { alpha: 123, beta: { bar: 'baz' }, gamma: false }; + + const { selectAlpha, selectBeta, selectGamma } = createFeature({ + name: 'foo', + reducer: createReducer(initialState), + }); + + expect(selectAlpha({ foo: initialState })).toEqual(123); + expect(selectBeta({ foo: initialState })).toEqual({ bar: 'baz' }); + expect(selectGamma({ foo: initialState })).toEqual(false); + }); + + it('should return undefined when feature state is not defined', () => { + const { selectX } = createFeature({ + name: 'foo', + reducer: createReducer({ x: 'y' }), + }); + + expect(selectX({})).toBe(undefined); + }); + + it('should not create when feature state is a primitive value', () => { + const feature = createFeature({ name: 'foo', reducer: createReducer(0) }); + + expect(Object.keys(feature)).toEqual([ + 'name', + 'reducer', + 'selectFooState', + ]); + }); + + it('should not create when feature state is null', () => { + const feature = createFeature({ + name: 'foo', + reducer: createReducer(null), + }); + + expect(Object.keys(feature)).toEqual([ + 'name', + 'reducer', + 'selectFooState', + ]); + }); + + it('should not create when feature state is an array', () => { + const feature = createFeature({ + name: 'foo', + reducer: createReducer([1, 2, 3]), + }); + + expect(Object.keys(feature)).toEqual([ + 'name', + 'reducer', + 'selectFooState', + ]); + }); + + it('should not create when feature state is a date object', () => { + const feature = createFeature({ + name: 'foo', + reducer: createReducer(new Date()), + }); + + expect(Object.keys(feature)).toEqual([ + 'name', + 'reducer', + 'selectFooState', + ]); + }); + }); + + it('should set up a feature state', (done) => { + const initialFooState = { x: 1, y: 2, z: 3 }; + const fooFeature = createFeature({ + name: 'foo', + reducer: createReducer(initialFooState), + }); + + TestBed.configureTestingModule({ + imports: [StoreModule.forRoot({}), StoreModule.forFeature(fooFeature)], + }); + + TestBed.inject(Store) + .select(fooFeature.name) + .pipe(take(1)) + .subscribe((fooState) => { + expect(fooState).toEqual(initialFooState); + done(); + }); + }); +}); diff --git a/modules/store/spec/helpers.spec.ts b/modules/store/spec/helpers.spec.ts new file mode 100644 index 0000000000..363493fdd2 --- /dev/null +++ b/modules/store/spec/helpers.spec.ts @@ -0,0 +1,13 @@ +import { capitalize } from '../src/helpers'; + +describe('helpers', () => { + describe('capitalize', () => { + it('should capitalize the text', () => { + expect(capitalize('ngrx')).toEqual('Ngrx'); + }); + + it('should return an empty string when the text is an empty string', () => { + expect(capitalize('')).toEqual(''); + }); + }); +}); diff --git a/modules/store/spec/types/feature_creator.spec.ts b/modules/store/spec/types/feature_creator.spec.ts new file mode 100644 index 0000000000..752ece7857 --- /dev/null +++ b/modules/store/spec/types/feature_creator.spec.ts @@ -0,0 +1,337 @@ +import { expecter } from 'ts-snippet'; +import { compilerOptions } from './utils'; + +describe('createFeature()', () => { + const expectSnippet = expecter( + (code) => ` + import { + ActionReducer, + createAction, + createFeature, + createReducer, + on, + props, + Store, + StoreModule, + } from '@ngrx/store'; + + ${code} + `, + { ...compilerOptions(), strict: true } + ); + + describe('with default app state type', () => { + it('should create', () => { + const snippet = expectSnippet(` + const search = createAction( + '[Products Page] Search', + props<{ query: string }>() + ); + const loadProductsSuccess = createAction( + '[Products API] Load Products Success', + props<{ products: string[] }>() + ); + + interface State { + products: string[] | null; + query: string; + } + + const initialState: State = { + products: null, + query: '', + }; + + const productsFeature = createFeature({ + name: 'products', + reducer: createReducer( + initialState, + on(search, (state, { query }) => ({ ...state, query })), + on(loadProductsSuccess, (state, { products }) => ({ + ...state, + products, + })) + ), + }); + + let { + name, + reducer, + selectProductsState, + selectProducts, + selectQuery, + } = productsFeature; + + let productsFeatureKeys: keyof typeof productsFeature; + `); + + snippet.toInfer('name', '"products"'); + snippet.toInfer('reducer', 'ActionReducer'); + snippet.toInfer( + 'selectProductsState', + 'MemoizedSelector, State, DefaultProjectorFn>' + ); + snippet.toInfer( + 'selectProducts', + 'MemoizedSelector, string[] | null, DefaultProjectorFn>' + ); + snippet.toInfer( + 'selectQuery', + 'MemoizedSelector, string, DefaultProjectorFn>' + ); + snippet.toInfer( + 'productsFeatureKeys', + '"selectProductsState" | "selectQuery" | "selectProducts" | keyof FeatureConfig<"products", State>' + ); + }); + + it('should allow use with StoreModule.forFeature', () => { + expectSnippet(` + const counterFeature = createFeature({ + name: 'counter', + reducer: createReducer(0), + }); + + StoreModule.forFeature(counterFeature); + `).toSucceed(); + }); + + it('should allow use with untyped store.select', () => { + const snippet = expectSnippet(` + const { selectCounterState, selectCount } = createFeature({ + name: 'counter', + reducer: createReducer({ count: 0 }), + }); + + let store!: Store; + const counterState$ = store.select(selectCounterState); + const count$ = store.select(selectCount); + `); + + snippet.toInfer('counterState$', 'Observable<{ count: number; }>'); + snippet.toInfer('count$', 'Observable'); + }); + + it('should allow use with typed store.select', () => { + const snippet = expectSnippet(` + const { selectCounterState } = createFeature({ + name: 'counter', + reducer: createReducer(0), + }); + + let store!: Store<{ counter: number }>; + const counterState$ = store.select(selectCounterState); + `); + + snippet.toInfer('counterState$', 'Observable'); + }); + + it('should fail when feature state contains optional properties', () => { + expectSnippet(` + interface State { + movies: string[]; + activeProductId?: number; + } + + const initialState: State = { movies: [], activeProductId: undefined }; + + const counterFeature = createFeature({ + name: 'movies', + reducer: createReducer(initialState), + }); + `).toFail(/optional properties are not allowed in the feature state/); + }); + }); + + describe('with passed app state type', () => { + it('should create', () => { + const snippet = expectSnippet(` + const enter = createAction('[Books Page] Enter'); + const loadBooksSuccess = createAction( + '[Books API] Load Books Success', + props<{ entities: Book[] }>() + ); + + interface Book { + id: number; + title: string; + } + + type LoadState = 'init' | 'loading' | 'loaded' | 'error'; + + interface BooksState { + books: Book[]; + loadState: LoadState; + } + + interface AppState { + books: BooksState; + } + + const initialState: BooksState = { + books: [], + loadState: 'init', + }; + + const booksFeature = createFeature({ + name: 'books', + reducer: createReducer( + initialState, + on(enter, (state) => ({ ...state, loadState: 'loading' })), + on(loadBooksSuccess, (state, { books }) => ({ + ...state, + books, + loadState: 'loaded', + })) + ), + }); + + const { + name, + reducer, + selectBooksState, + selectBooks, + selectLoadState, + } = booksFeature; + + let booksFeatureKeys: keyof typeof booksFeature; + `); + + snippet.toInfer('name', '"books"'); + snippet.toInfer('reducer', 'ActionReducer'); + snippet.toInfer( + 'selectBooksState', + 'MemoizedSelector>' + ); + snippet.toInfer( + 'selectBooks', + 'MemoizedSelector>' + ); + snippet.toInfer( + 'selectLoadState', + 'MemoizedSelector>' + ); + snippet.toInfer( + 'booksFeatureKeys', + '"selectBooksState" | "selectBooks" | "selectLoadState" | keyof FeatureConfig<"books", BooksState>' + ); + }); + + it('should fail when name is not key of app state', () => { + expectSnippet(` + interface AppState { + counter1: number; + counter2: number; + } + + const counterFeature = createFeature({ + name: 'counter3', + reducer: createReducer(0), + }); + `).toFail( + /Type '"counter3"' is not assignable to type '"counter1" | "counter2"'/ + ); + }); + + it('should allow use with StoreModule.forFeature', () => { + expectSnippet(` + const counterFeature = createFeature<{ counter: number }>({ + name: 'counter', + reducer: createReducer(0), + }); + + StoreModule.forFeature(counterFeature); + `).toSucceed(); + }); + + it('should allow use with untyped store.select', () => { + expectSnippet(` + const { selectCounterState, selectCount } = createFeature<{ counter: { count: number } }>({ + name: 'counter', + reducer: createReducer({ count: 0 }), + }); + + let store!: Store; + const counterState$ = store.select(selectCounterState); + const count$ = store.select(selectCount); + `).toFail( + /Type 'object' is not assignable to type '{ counter: { count: number; }; }'/ + ); + }); + + it('should allow use with typed store.select', () => { + const snippet = expectSnippet(` + const { selectCounterState } = createFeature<{ counter: number }>({ + name: 'counter', + reducer: createReducer(0), + }); + + let store!: Store<{ counter: number }>; + const counterState$ = store.select(selectCounterState); + `); + + snippet.toInfer('counterState$', 'Observable'); + }); + + it('should fail when feature state contains optional properties', () => { + expectSnippet(` + interface CounterState { + count?: number; + } + + interface AppState { + counter: CounterState; + } + + const counterFeature = createFeature({ + name: 'counter', + reducer: createReducer({} as CounterState), + }); + `).toFail(/optional properties are not allowed in the feature state/); + }); + }); + + describe('nested selectors', () => { + it('should not create with feature state as a primitive value', () => { + expectSnippet(` + const feature = createFeature({ + name: 'primitive', + reducer: createReducer('text'), + }); + + let featureKeys: keyof typeof feature; + `).toInfer( + 'featureKeys', + '"selectPrimitiveState" | keyof FeatureConfig<"primitive", string>' + ); + }); + + it('should not create with feature state as an array', () => { + expectSnippet(` + const feature = createFeature({ + name: 'array', + reducer: createReducer([1, 2, 3]), + }); + + let featureKeys: keyof typeof feature; + `).toInfer( + 'featureKeys', + '"selectArrayState" | keyof FeatureConfig<"array", number[]>' + ); + }); + + it('should not create with feature state as a date object', () => { + expectSnippet(` + const feature = createFeature({ + name: 'date', + reducer: createReducer(new Date()), + }); + + let featureKeys: keyof typeof feature; + `).toInfer( + 'featureKeys', + '"selectDateState" | keyof FeatureConfig<"date", Date>' + ); + }); + }); +}); diff --git a/modules/store/src/feature_creator.ts b/modules/store/src/feature_creator.ts new file mode 100644 index 0000000000..acf2d796a5 --- /dev/null +++ b/modules/store/src/feature_creator.ts @@ -0,0 +1,107 @@ +import { capitalize } from './helpers'; +import { ActionReducer } from './models'; +import { isPlainObject } from './meta-reducers/utils'; +import { + createFeatureSelector, + createSelector, + MemoizedSelector, +} from './selector'; + +export type Feature< + AppState extends Record, + FeatureName extends keyof AppState & string, + FeatureState extends AppState[FeatureName] +> = FeatureConfig & + FeatureSelector & + NestedSelectors; + +export interface FeatureConfig { + name: FeatureName; + reducer: ActionReducer; +} + +type FeatureSelector< + AppState extends Record, + FeatureName extends keyof AppState & string, + FeatureState extends AppState[FeatureName] +> = { + [K in FeatureName as `select${Capitalize}State`]: MemoizedSelector< + AppState, + FeatureState + >; +}; + +type Primitive = string | number | bigint | boolean | null | undefined; + +type NestedSelectors< + AppState extends Record, + FeatureState +> = FeatureState extends Primitive | unknown[] | Date + ? {} + : { + [K in keyof FeatureState & + string as `select${Capitalize}`]: MemoizedSelector< + AppState, + FeatureState[K] + >; + }; + +type NotAllowedFeatureStateCheck< + FeatureState +> = FeatureState extends Required + ? unknown + : 'optional properties are not allowed in the feature state'; + +export function createFeature< + AppState extends Record, + FeatureName extends keyof AppState & string = keyof AppState & string, + FeatureState extends AppState[FeatureName] = AppState[FeatureName] +>({ + name, + reducer, +}: FeatureConfig & + NotAllowedFeatureStateCheck): Feature< + AppState, + FeatureName, + FeatureState +> { + const featureSelector = createFeatureSelector(name); + const nestedSelectors = createNestedSelectors(featureSelector, reducer); + + return ({ + name, + reducer, + [`select${capitalize(name)}State`]: featureSelector, + ...nestedSelectors, + } as unknown) as Feature; +} + +function createNestedSelectors< + AppState extends Record, + FeatureState +>( + featureSelector: MemoizedSelector, + reducer: ActionReducer +): NestedSelectors { + const initialState = getInitialState(reducer); + const nestedKeys = (isPlainObject(initialState) + ? Object.keys(initialState) + : []) as Array; + + return nestedKeys.reduce( + (nestedSelectors, nestedKey) => ({ + ...nestedSelectors, + [`select${capitalize(nestedKey)}`]: createSelector( + featureSelector, + (parentState) => parentState?.[nestedKey] + ), + }), + {} as NestedSelectors + ); +} + +function getInitialState( + reducer: ActionReducer +): FeatureState { + return reducer(undefined, { type: '@ngrx/feature/init' }); +} diff --git a/modules/store/src/helpers.ts b/modules/store/src/helpers.ts new file mode 100644 index 0000000000..1ad7ef7112 --- /dev/null +++ b/modules/store/src/helpers.ts @@ -0,0 +1,3 @@ +export function capitalize(text: T): Capitalize { + return (text.charAt(0).toUpperCase() + text.substr(1)) as Capitalize; +} diff --git a/modules/store/src/index.ts b/modules/store/src/index.ts index bcec7179b6..b5d1a27777 100644 --- a/modules/store/src/index.ts +++ b/modules/store/src/index.ts @@ -18,6 +18,7 @@ export { createAction, props, union } from './action_creator'; export { Store, select } from './store'; export { combineReducers, compose, createReducerFactory } from './utils'; export { ActionsSubject, INIT } from './actions_subject'; +export { createFeature, FeatureConfig } from './feature_creator'; export { setNgrxMockEnvironment, isNgrxMockEnvironment } from './flags'; export { ReducerManager,