From 2cb6eb8c0dc0e9c4bdf706d15d8a013d783cf806 Mon Sep 17 00:00:00 2001 From: markostanimirovic Date: Fri, 21 May 2021 01:06:08 +0200 Subject: [PATCH 1/2] feat(store): add createFeature --- modules/store/spec/feature_creator.spec.ts | 85 +++++ modules/store/spec/helpers.spec.ts | 35 ++ .../store/spec/types/feature_creator.spec.ts | 361 ++++++++++++++++++ modules/store/src/feature_creator.ts | 97 +++++ modules/store/src/helpers.ts | 12 + modules/store/src/index.ts | 1 + modules/store/src/models.ts | 8 + 7 files changed, 599 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..5466e61e64 --- /dev/null +++ b/modules/store/spec/feature_creator.spec.ts @@ -0,0 +1,85 @@ +import { + createAction, + createFeature, + createReducer, + Feature, + on, +} from '@ngrx/store'; + +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 feature selector', () => { + const { selectFooState } = createFeature({ + name: 'foo', + reducer: createReducer({ bar: '' }), + }); + + expect(selectFooState({ foo: { bar: 'baz' } })).toEqual({ bar: 'baz' }); + }); + + describe('nested selectors', () => { + function setup( + initialState: FeatureState + ): Feature<{ foo: FeatureState }, 'foo', FeatureState> { + const a1 = createAction('a1'); + const reducer = createReducer( + initialState, + on(a1, (state) => state) + ); + + return createFeature({ name: 'foo', reducer }); + } + + it('should create when feature state is a dictionary', () => { + const initialState = { alpha: 123, beta: { bar: 'baz' }, gamma: false }; + + const { selectAlpha, selectBeta, selectGamma } = setup(initialState); + + expect(selectAlpha({ foo: initialState })).toEqual(123); + expect(selectBeta({ foo: initialState })).toEqual({ bar: 'baz' }); + expect(selectGamma({ foo: initialState })).toEqual(false); + }); + + it('should not create when feature state is a primitive value', () => { + const feature = setup(0); + + expect(Object.keys(feature)).toEqual([ + 'name', + 'reducer', + 'selectFooState', + ]); + }); + + it('should not create when feature state is an array', () => { + const feature = setup([1, 2, 3]); + + expect(Object.keys(feature)).toEqual([ + 'name', + 'reducer', + 'selectFooState', + ]); + }); + + it('should not create when feature state is a date object', () => { + const feature = setup(new Date()); + + expect(Object.keys(feature)).toEqual([ + 'name', + 'reducer', + 'selectFooState', + ]); + }); + }); +}); diff --git a/modules/store/spec/helpers.spec.ts b/modules/store/spec/helpers.spec.ts new file mode 100644 index 0000000000..414076940e --- /dev/null +++ b/modules/store/spec/helpers.spec.ts @@ -0,0 +1,35 @@ +import { capitalize, isDictionary } from '../src/helpers'; + +describe('helpers', () => { + describe('capitalize', () => { + it('should capitalize the text', () => { + expect(capitalize('marko')).toEqual('Marko'); + }); + + it('should return an empty string when the text is an empty string', () => { + expect(capitalize('')).toEqual(''); + }); + }); + + describe('isDictionary', () => { + it('should return true when argument is a dictionary', () => { + expect(isDictionary({ foo: 'bar' })).toBe(true); + }); + + it('should return false when argument is a primitive value', () => { + expect(isDictionary(1)).toBe(false); + }); + + it('should return false when argument is null', () => { + expect(isDictionary(null)).toBe(false); + }); + + it('should return false when argument is an array', () => { + expect(isDictionary(['foo', 'bar'])).toBe(false); + }); + + it('should return false when argument is a date object', () => { + expect(isDictionary(new Date())).toBe(false); + }); + }); +}); 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..40836d8c7d --- /dev/null +++ b/modules/store/spec/types/feature_creator.spec.ts @@ -0,0 +1,361 @@ +import { expecter } from 'ts-snippet'; +import { compilerOptions } from './utils'; + +describe('createFeature()', () => { + const expectSnippet = expecter( + (code) => ` + import { + ActionReducer, + createAction, + createFeature, + createReducer, + Feature, + 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'); + }); + }); + + 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'); + }); + }); + + describe('nested selectors', () => { + it('should not create from optional feature state properties', () => { + const snippet = expectSnippet(` + interface FeatureState { + prop1: null; + prop2: { foo: string } | null; + prop3?: { bar: number }; + prop4: { baz: boolean } | undefined; + prop5?: number; + prop6: undefined; + } + + const initialState: FeatureState = { + prop1: null, + prop2: null, + prop3: { bar: 1 }, + prop4: undefined, + prop6: undefined, + }; + + const feature = createFeature({ + name: 'optional', + reducer: createReducer(initialState), + }); + + const { + selectProp1, + selectProp2, + selectProp4, + selectProp6 + } = feature; + + let featureKeys: keyof typeof feature; + `); + + snippet.toInfer( + 'selectProp1', + 'MemoizedSelector, null, DefaultProjectorFn>' + ); + snippet.toInfer( + 'selectProp2', + 'MemoizedSelector, { foo: string; } | null, DefaultProjectorFn<{ foo: string; } | null>>' + ); + snippet.toInfer( + 'selectProp4', + 'MemoizedSelector, { baz: boolean; } | undefined, DefaultProjectorFn<{ baz: boolean; } | undefined>>' + ); + snippet.toInfer( + 'selectProp6', + 'MemoizedSelector, undefined, DefaultProjectorFn>' + ); + snippet.toInfer( + 'featureKeys', + '"selectOptionalState" | "selectProp1" | "selectProp2" | "selectProp4" | "selectProp6" | keyof FeatureConfig<"optional", FeatureState>' + ); + }); + + 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..c212159342 --- /dev/null +++ b/modules/store/src/feature_creator.ts @@ -0,0 +1,97 @@ +import { capitalize, isDictionary } from './helpers'; +import { ActionReducer, NonOptional, Primitive } from './models'; +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 NestedSelectors< + AppState extends Record, + FeatureState +> = FeatureState extends Primitive | unknown[] | Date + ? {} + : { + [K in keyof NonOptional & + string as `select${Capitalize}`]: MemoizedSelector< + AppState, + FeatureState[K] + >; + }; + +export function createFeature< + AppState extends Record, + FeatureName extends keyof AppState & string = keyof AppState & string, + FeatureState extends AppState[FeatureName] = AppState[FeatureName] +>({ + name, + reducer, +}: FeatureConfig): 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 = (isDictionary(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: '' }); +} diff --git a/modules/store/src/helpers.ts b/modules/store/src/helpers.ts new file mode 100644 index 0000000000..ce4c88d357 --- /dev/null +++ b/modules/store/src/helpers.ts @@ -0,0 +1,12 @@ +export function capitalize(text: T): Capitalize { + return (text.charAt(0).toUpperCase() + text.substr(1)) as Capitalize; +} + +export function isDictionary(arg: unknown): arg is Record { + return ( + typeof arg === 'object' && + arg !== null && + !Array.isArray(arg) && + !(arg instanceof Date) + ); +} diff --git a/modules/store/src/index.ts b/modules/store/src/index.ts index bcec7179b6..73cbb849c0 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, Feature, FeatureConfig } from './feature_creator'; export { setNgrxMockEnvironment, isNgrxMockEnvironment } from './flags'; export { ReducerManager, diff --git a/modules/store/src/models.ts b/modules/store/src/models.ts index d3813448fe..484bd3ac29 100644 --- a/modules/store/src/models.ts +++ b/modules/store/src/models.ts @@ -137,3 +137,11 @@ export interface RuntimeChecks { */ strictActionTypeUniqueness?: boolean; } + +export type Primitive = string | number | bigint | boolean | null | undefined; + +export type RequiredKeys = { + [K in keyof T]: {} extends { [P in K]: T[K] } ? never : K; +}[keyof T]; + +export type NonOptional = Pick>; From ac54fb0398758a8d6fc73c169273bd8342de8f40 Mon Sep 17 00:00:00 2001 From: markostanimirovic Date: Wed, 2 Jun 2021 00:08:28 +0200 Subject: [PATCH 2/2] applied review suggestions --- modules/store/spec/feature_creator.spec.ts | 83 +++++++++++++------ modules/store/spec/helpers.spec.ts | 26 +----- .../store/spec/types/feature_creator.spec.ts | 82 +++++++----------- modules/store/src/feature_creator.ts | 24 ++++-- modules/store/src/helpers.ts | 9 -- modules/store/src/index.ts | 2 +- modules/store/src/models.ts | 8 -- 7 files changed, 108 insertions(+), 126 deletions(-) diff --git a/modules/store/spec/feature_creator.spec.ts b/modules/store/spec/feature_creator.spec.ts index 5466e61e64..eb78eef3b9 100644 --- a/modules/store/spec/feature_creator.spec.ts +++ b/modules/store/spec/feature_creator.spec.ts @@ -1,10 +1,6 @@ -import { - createAction, - createFeature, - createReducer, - Feature, - on, -} from '@ngrx/store'; +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', () => { @@ -20,7 +16,7 @@ describe('createFeature()', () => { expect(reducer).toBe(fooReducer); }); - it('should create feature selector', () => { + it('should create a feature selector', () => { const { selectFooState } = createFeature({ name: 'foo', reducer: createReducer({ bar: '' }), @@ -30,30 +26,43 @@ describe('createFeature()', () => { }); describe('nested selectors', () => { - function setup( - initialState: FeatureState - ): Feature<{ foo: FeatureState }, 'foo', FeatureState> { - const a1 = createAction('a1'); - const reducer = createReducer( - initialState, - on(a1, (state) => state) - ); - - return createFeature({ name: 'foo', reducer }); - } - it('should create when feature state is a dictionary', () => { const initialState = { alpha: 123, beta: { bar: 'baz' }, gamma: false }; - const { selectAlpha, selectBeta, selectGamma } = setup(initialState); + 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 = setup(0); + 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', @@ -63,7 +72,10 @@ describe('createFeature()', () => { }); it('should not create when feature state is an array', () => { - const feature = setup([1, 2, 3]); + const feature = createFeature({ + name: 'foo', + reducer: createReducer([1, 2, 3]), + }); expect(Object.keys(feature)).toEqual([ 'name', @@ -73,7 +85,10 @@ describe('createFeature()', () => { }); it('should not create when feature state is a date object', () => { - const feature = setup(new Date()); + const feature = createFeature({ + name: 'foo', + reducer: createReducer(new Date()), + }); expect(Object.keys(feature)).toEqual([ 'name', @@ -82,4 +97,24 @@ describe('createFeature()', () => { ]); }); }); + + 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 index 414076940e..363493fdd2 100644 --- a/modules/store/spec/helpers.spec.ts +++ b/modules/store/spec/helpers.spec.ts @@ -1,35 +1,13 @@ -import { capitalize, isDictionary } from '../src/helpers'; +import { capitalize } from '../src/helpers'; describe('helpers', () => { describe('capitalize', () => { it('should capitalize the text', () => { - expect(capitalize('marko')).toEqual('Marko'); + expect(capitalize('ngrx')).toEqual('Ngrx'); }); it('should return an empty string when the text is an empty string', () => { expect(capitalize('')).toEqual(''); }); }); - - describe('isDictionary', () => { - it('should return true when argument is a dictionary', () => { - expect(isDictionary({ foo: 'bar' })).toBe(true); - }); - - it('should return false when argument is a primitive value', () => { - expect(isDictionary(1)).toBe(false); - }); - - it('should return false when argument is null', () => { - expect(isDictionary(null)).toBe(false); - }); - - it('should return false when argument is an array', () => { - expect(isDictionary(['foo', 'bar'])).toBe(false); - }); - - it('should return false when argument is a date object', () => { - expect(isDictionary(new Date())).toBe(false); - }); - }); }); diff --git a/modules/store/spec/types/feature_creator.spec.ts b/modules/store/spec/types/feature_creator.spec.ts index 40836d8c7d..752ece7857 100644 --- a/modules/store/spec/types/feature_creator.spec.ts +++ b/modules/store/spec/types/feature_creator.spec.ts @@ -9,7 +9,6 @@ describe('createFeature()', () => { createAction, createFeature, createReducer, - Feature, on, props, Store, @@ -126,6 +125,22 @@ describe('createFeature()', () => { 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', () => { @@ -257,65 +272,26 @@ describe('createFeature()', () => { snippet.toInfer('counterState$', 'Observable'); }); - }); - describe('nested selectors', () => { - it('should not create from optional feature state properties', () => { - const snippet = expectSnippet(` - interface FeatureState { - prop1: null; - prop2: { foo: string } | null; - prop3?: { bar: number }; - prop4: { baz: boolean } | undefined; - prop5?: number; - prop6: undefined; + it('should fail when feature state contains optional properties', () => { + expectSnippet(` + interface CounterState { + count?: number; } - const initialState: FeatureState = { - prop1: null, - prop2: null, - prop3: { bar: 1 }, - prop4: undefined, - prop6: undefined, - }; + interface AppState { + counter: CounterState; + } - const feature = createFeature({ - name: 'optional', - reducer: createReducer(initialState), + const counterFeature = createFeature({ + name: 'counter', + reducer: createReducer({} as CounterState), }); - - const { - selectProp1, - selectProp2, - selectProp4, - selectProp6 - } = feature; - - let featureKeys: keyof typeof feature; - `); - - snippet.toInfer( - 'selectProp1', - 'MemoizedSelector, null, DefaultProjectorFn>' - ); - snippet.toInfer( - 'selectProp2', - 'MemoizedSelector, { foo: string; } | null, DefaultProjectorFn<{ foo: string; } | null>>' - ); - snippet.toInfer( - 'selectProp4', - 'MemoizedSelector, { baz: boolean; } | undefined, DefaultProjectorFn<{ baz: boolean; } | undefined>>' - ); - snippet.toInfer( - 'selectProp6', - 'MemoizedSelector, undefined, DefaultProjectorFn>' - ); - snippet.toInfer( - 'featureKeys', - '"selectOptionalState" | "selectProp1" | "selectProp2" | "selectProp4" | "selectProp6" | keyof FeatureConfig<"optional", FeatureState>' - ); + `).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({ diff --git a/modules/store/src/feature_creator.ts b/modules/store/src/feature_creator.ts index c212159342..acf2d796a5 100644 --- a/modules/store/src/feature_creator.ts +++ b/modules/store/src/feature_creator.ts @@ -1,5 +1,6 @@ -import { capitalize, isDictionary } from './helpers'; -import { ActionReducer, NonOptional, Primitive } from './models'; +import { capitalize } from './helpers'; +import { ActionReducer } from './models'; +import { isPlainObject } from './meta-reducers/utils'; import { createFeatureSelector, createSelector, @@ -30,19 +31,27 @@ type FeatureSelector< >; }; +type Primitive = string | number | bigint | boolean | null | undefined; + type NestedSelectors< AppState extends Record, FeatureState > = FeatureState extends Primitive | unknown[] | Date ? {} : { - [K in keyof NonOptional & + [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, @@ -50,7 +59,8 @@ export function createFeature< >({ name, reducer, -}: FeatureConfig): Feature< +}: FeatureConfig & + NotAllowedFeatureStateCheck): Feature< AppState, FeatureName, FeatureState @@ -74,7 +84,7 @@ function createNestedSelectors< reducer: ActionReducer ): NestedSelectors { const initialState = getInitialState(reducer); - const nestedKeys = (isDictionary(initialState) + const nestedKeys = (isPlainObject(initialState) ? Object.keys(initialState) : []) as Array; @@ -83,7 +93,7 @@ function createNestedSelectors< ...nestedSelectors, [`select${capitalize(nestedKey)}`]: createSelector( featureSelector, - (parentState) => parentState[nestedKey] + (parentState) => parentState?.[nestedKey] ), }), {} as NestedSelectors @@ -93,5 +103,5 @@ function createNestedSelectors< function getInitialState( reducer: ActionReducer ): FeatureState { - return reducer(undefined, { type: '' }); + return reducer(undefined, { type: '@ngrx/feature/init' }); } diff --git a/modules/store/src/helpers.ts b/modules/store/src/helpers.ts index ce4c88d357..1ad7ef7112 100644 --- a/modules/store/src/helpers.ts +++ b/modules/store/src/helpers.ts @@ -1,12 +1,3 @@ export function capitalize(text: T): Capitalize { return (text.charAt(0).toUpperCase() + text.substr(1)) as Capitalize; } - -export function isDictionary(arg: unknown): arg is Record { - return ( - typeof arg === 'object' && - arg !== null && - !Array.isArray(arg) && - !(arg instanceof Date) - ); -} diff --git a/modules/store/src/index.ts b/modules/store/src/index.ts index 73cbb849c0..b5d1a27777 100644 --- a/modules/store/src/index.ts +++ b/modules/store/src/index.ts @@ -18,7 +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, Feature, FeatureConfig } from './feature_creator'; +export { createFeature, FeatureConfig } from './feature_creator'; export { setNgrxMockEnvironment, isNgrxMockEnvironment } from './flags'; export { ReducerManager, diff --git a/modules/store/src/models.ts b/modules/store/src/models.ts index 484bd3ac29..d3813448fe 100644 --- a/modules/store/src/models.ts +++ b/modules/store/src/models.ts @@ -137,11 +137,3 @@ export interface RuntimeChecks { */ strictActionTypeUniqueness?: boolean; } - -export type Primitive = string | number | bigint | boolean | null | undefined; - -export type RequiredKeys = { - [K in keyof T]: {} extends { [P in K]: T[K] } ? never : K; -}[keyof T]; - -export type NonOptional = Pick>;