diff --git a/modules/store/spec/feature_creator.spec.ts b/modules/store/spec/feature_creator.spec.ts index eb78eef3b9..d2b208bb48 100644 --- a/modules/store/spec/feature_creator.spec.ts +++ b/modules/store/spec/feature_creator.spec.ts @@ -1,4 +1,10 @@ -import { createFeature, createReducer, Store, StoreModule } from '@ngrx/store'; +import { + createFeature, + createReducer, + createSelector, + Store, + StoreModule, +} from '@ngrx/store'; import { TestBed } from '@angular/core/testing'; import { take } from 'rxjs/operators'; @@ -98,6 +104,104 @@ describe('createFeature()', () => { }); }); + describe('extra selectors', () => { + it('should create extra selectors', () => { + const initialState = { count1: 9, count2: 10 }; + const counterFeature = createFeature({ + name: 'counter', + reducer: createReducer(initialState), + extraSelectors: ({ + selectCounterState, + selectCount1, + selectCount2, + }) => ({ + selectSquaredCount2: createSelector( + selectCounterState, + ({ count2 }) => count2 * count2 + ), + selectTotalCount: createSelector( + selectCount1, + selectCount2, + (count1, count2) => count1 + count2 + ), + }), + }); + + expect(counterFeature.selectCounterState({ counter: initialState })).toBe( + initialState + ); + expect(counterFeature.selectCount1({ counter: initialState })).toBe( + initialState.count1 + ); + expect(counterFeature.selectCount2({ counter: initialState })).toBe( + initialState.count2 + ); + expect( + counterFeature.selectSquaredCount2({ counter: initialState }) + ).toBe(initialState.count2 * initialState.count2); + expect(counterFeature.selectTotalCount({ counter: initialState })).toBe( + initialState.count1 + initialState.count2 + ); + expect(Object.keys(counterFeature)).toEqual([ + 'name', + 'reducer', + 'selectCounterState', + 'selectCount1', + 'selectCount2', + 'selectSquaredCount2', + 'selectTotalCount', + ]); + }); + + it('should override base selectors if extra selectors have the same names', () => { + const initialState = { count1: 10, count2: 100 }; + const counterFeature = createFeature({ + name: 'counter', + reducer: createReducer(initialState), + extraSelectors: ({ + selectCounterState, + selectCount1, + selectCount2, + }) => ({ + selectCounterState: createSelector( + selectCounterState, + ({ count1, count2 }) => `ngrx-${count1}-${count2}` + ), + selectCount2: createSelector( + selectCount2, + (count2) => `ngrx-${count2}` + ), + selectTotalCount: createSelector( + selectCount1, + selectCount2, + (count1, count2) => count1 + count2 + ), + }), + }); + + expect(counterFeature.selectCounterState({ counter: initialState })).toBe( + `ngrx-${initialState.count1}-${initialState.count2}` + ); + expect(counterFeature.selectCount1({ counter: initialState })).toBe( + initialState.count1 + ); + expect(counterFeature.selectCount2({ counter: initialState })).toBe( + `ngrx-${initialState.count2}` + ); + expect(counterFeature.selectTotalCount({ counter: initialState })).toBe( + initialState.count1 + initialState.count2 + ); + expect(Object.keys(counterFeature)).toEqual([ + 'name', + 'reducer', + 'selectCounterState', + 'selectCount1', + 'selectCount2', + 'selectTotalCount', + ]); + }); + }); + it('should set up a feature state', (done) => { const initialFooState = { x: 1, y: 2, z: 3 }; const fooFeature = createFeature({ diff --git a/modules/store/spec/types/feature_creator.spec.ts b/modules/store/spec/types/feature_creator.spec.ts index 5d97385e01..c4e105bc92 100644 --- a/modules/store/spec/types/feature_creator.spec.ts +++ b/modules/store/spec/types/feature_creator.spec.ts @@ -9,8 +9,10 @@ describe('createFeature()', () => { createAction, createFeature, createReducer, + createSelector, on, props, + Selector, Store, StoreModule, } from '@ngrx/store'; @@ -85,6 +87,34 @@ describe('createFeature()', () => { ); }); + it('should create a feature when reducer is created outside', () => { + const snippet = expectSnippet(` + const counterReducer = createReducer({ count: 0 }); + const counterFeature = createFeature({ + name: 'counter', + reducer: counterReducer, + }); + + const { + name, + reducer, + selectCounterState, + selectCount, + } = counterFeature; + `); + + snippet.toInfer('name', '"counter"'); + snippet.toInfer('reducer', 'ActionReducer<{ count: number; }, Action>'); + snippet.toInfer( + 'selectCounterState', + 'MemoizedSelector, { count: number; }, DefaultProjectorFn<{ count: number; }>>' + ); + snippet.toInfer( + 'selectCount', + 'MemoizedSelector, number, DefaultProjectorFn>' + ); + }); + it('should allow use with StoreModule.forFeature', () => { expectSnippet(` const counterFeature = createFeature({ @@ -217,6 +247,39 @@ describe('createFeature()', () => { ); }); + it('should create a feature when reducer is created outside', () => { + const snippet = expectSnippet(` + interface State { + bar: string; + } + const initialState: State = { bar: 'ngrx' }; + + const fooReducer = createReducer(initialState); + const fooFeature = createFeature<{ foo: State }>({ + name: 'foo', + reducer: fooReducer, + }); + + const { + name, + reducer, + selectFooState, + selectBar, + } = fooFeature; + `); + + snippet.toInfer('name', '"foo"'); + snippet.toInfer('reducer', 'ActionReducer'); + snippet.toInfer( + 'selectFooState', + 'MemoizedSelector<{ foo: State; }, State, DefaultProjectorFn>' + ); + snippet.toInfer( + 'selectBar', + 'MemoizedSelector<{ foo: State; }, string, DefaultProjectorFn>' + ); + }); + it('should fail when name is not key of app state', () => { expectSnippet(` interface AppState { @@ -334,4 +397,351 @@ describe('createFeature()', () => { ); }); }); + + describe('extra selectors', () => { + it('should create extra selectors', () => { + const snippet = expectSnippet(` + const increment = createAction('increment'); + + interface State { + count: number; + } + const initialState: State = { + count: 0, + }; + + const counterFeature = createFeature({ + name: 'counter', + reducer: createReducer( + initialState, + on(increment, (state) => ({ count: state.count + 1 })) + ), + extraSelectors: ({ selectCounterState, selectCount }) => ({ + selectCounterState2: createSelector( + selectCounterState, + (state) => state + ), + selectCountPlus1: createSelector( + selectCount, + (count) => count + 1 + ), + }), + }); + + const { + name, + reducer, + selectCounterState, + selectCount, + selectCounterState2, + selectCountPlus1, + } = counterFeature; + let counterFeatureKeys: keyof typeof counterFeature; + `); + + snippet.toInfer('name', '"counter"'); + snippet.toInfer('reducer', 'ActionReducer'); + snippet.toInfer( + 'selectCounterState', + 'MemoizedSelector, State, DefaultProjectorFn>' + ); + snippet.toInfer( + 'selectCount', + 'MemoizedSelector, number, DefaultProjectorFn>' + ); + snippet.toInfer( + 'selectCounterState2', + 'MemoizedSelector, State, (s1: State) => State>' + ); + snippet.toInfer( + 'selectCountPlus1', + 'MemoizedSelector, number, (s1: number) => number>' + ); + snippet.toInfer( + 'counterFeatureKeys', + '"name" | "reducer" | "selectCounterState" | "selectCount" | "selectCounterState2" | "selectCountPlus1"' + ); + }); + + it('should create extra selectors when reducer is created outside', () => { + const snippet = expectSnippet(` + const counterReducer = createReducer({ count: 0 }); + + const counterFeature = createFeature({ + name: 'counter', + reducer: counterReducer, + extraSelectors: ({ selectCounterState, selectCount }) => ({ + selectSquaredCount: createSelector( + selectCounterState, + selectCount, + ({ count }, c) => count * c + ), + }), + }); + + const { + name, + reducer, + selectCounterState, + selectCount, + selectSquaredCount, + } = counterFeature; + let counterFeatureKeys: keyof typeof counterFeature; + `); + + snippet.toInfer('name', '"counter"'); + snippet.toInfer('reducer', 'ActionReducer<{ count: number; }, Action>'); + snippet.toInfer( + 'selectCounterState', + 'MemoizedSelector, { count: number; }, DefaultProjectorFn<{ count: number; }>>' + ); + snippet.toInfer( + 'selectCount', + 'MemoizedSelector, number, DefaultProjectorFn>' + ); + snippet.toInfer( + 'selectSquaredCount', + 'MemoizedSelector, number, (s1: { count: number; }, s2: number) => number>' + ); + snippet.toInfer( + 'counterFeatureKeys', + '"name" | "reducer" | "selectCounterState" | "selectCount" | "selectSquaredCount"' + ); + }); + + it('should override base selectors if extra selectors have the same names', () => { + const snippet = expectSnippet(` + const counterFeature = createFeature({ + name: 'counter', + reducer: createReducer({ count1: 0, count2: 0 }), + extraSelectors: ({ selectCounterState, selectCount1, selectCount2 }) => ({ + selectCounterState: createSelector( + selectCount1, + selectCount2, + (count3, count4) => ({ count3, count4 }) + ), + selectCount1: createSelector(selectCount1, (count) => count + ''), + selectCount10: createSelector(selectCount2, (count) => count + 1), + }), + }); + + const { + selectCounterState, + selectCount1, + selectCount2, + selectCount10, + } = counterFeature; + let counterFeatureKeys: keyof typeof counterFeature; + `); + + snippet.toInfer( + 'selectCounterState', + 'MemoizedSelector, { count3: number; count4: number; }, (s1: number, s2: number) => { count3: number; count4: number; }>' + ); + snippet.toInfer( + 'selectCount1', + 'MemoizedSelector, string, (s1: number) => string>' + ); + snippet.toInfer( + 'selectCount2', + 'MemoizedSelector, number, DefaultProjectorFn>' + ); + snippet.toInfer( + 'selectCount10', + 'MemoizedSelector, number, (s1: number) => number>' + ); + snippet.toInfer( + 'counterFeatureKeys', + '"name" | "reducer" | "selectCounterState" | "selectCount1" | "selectCount2" | "selectCount10"' + ); + }); + + it('should not break the feature object when extra selector names are not string literals', () => { + const snippet = expectSnippet(` + const untypedSelectors: Record, unknown>> = {}; + + const counterFeature1 = createFeature({ + name: 'counter1', + reducer: createReducer(0), + extraSelectors: ({ selectCounter1State }) => ({ + ['selectInvalid' as string]: createSelector( + selectCounter1State, + (count) => count + ), + selectSquaredCount: createSelector( + selectCounter1State, + (count) => count * count + ), + ...untypedSelectors, + }), + }); + + const counterFeature2 = createFeature({ + name: 'counter2', + reducer: createReducer(0), + extraSelectors: () => untypedSelectors, + }); + + const { selectCounter1State } = counterFeature1; + const { selectCounter2State } = counterFeature2; + + let counterFeature1Keys: keyof typeof counterFeature1; + let counterFeature2Keys: keyof typeof counterFeature2; + `); + + snippet.toInfer( + 'selectCounter1State', + 'MemoizedSelector, number, DefaultProjectorFn>' + ); + snippet.toInfer( + 'selectCounter2State', + 'MemoizedSelector, number, DefaultProjectorFn>' + ); + snippet.toInfer( + 'counterFeature1Keys', + '"selectCounter1State" | keyof FeatureConfig<"counter1", number>' + ); + snippet.toInfer( + 'counterFeature2Keys', + '"selectCounter2State" | keyof FeatureConfig<"counter2", number>' + ); + }); + + it('should not break the feature object when extra selectors are an empty object', () => { + const snippet = expectSnippet(` + const counterFeature = createFeature({ + name: 'counter', + reducer: createReducer(0), + extraSelectors: () => ({}), + }); + + const { selectCounterState } = counterFeature; + let counterFeatureKeys: keyof typeof counterFeature; + `); + + snippet.toInfer( + 'selectCounterState', + 'MemoizedSelector, number, DefaultProjectorFn>' + ); + snippet.toInfer( + 'counterFeatureKeys', + '"name" | "reducer" | "selectCounterState"' + ); + }); + + it('should create a feature when extra selectors dictionary is typed as a type', () => { + const snippet = expectSnippet(` + type ExtraSelectors = { + selectCountStr: Selector, string>; + } + + function getExtraSelectors( + selectCount: Selector, number> + ): ExtraSelectors { + return { + selectCountStr: createSelector( + selectCount, + (count) => count + '' + ), + }; + } + + const counterFeature = createFeature({ + name: 'counter', + reducer: createReducer(0), + extraSelectors: ({ selectCounterState }) => + getExtraSelectors(selectCounterState), + }); + + const { selectCountStr } = counterFeature; + let counterFeatureKeys: keyof typeof counterFeature; + `); + + snippet.toInfer( + 'selectCountStr', + 'Selector, string>' + ); + snippet.toInfer( + 'counterFeatureKeys', + '"name" | "reducer" | "selectCounterState" | "selectCountStr"' + ); + }); + + // This is known behavior. + // Record is not compatible with interface of selectors. + it('should fail when extra selectors dictionary is typed as an interface', () => { + expectSnippet(` + interface ExtraSelectors { + selectSquaredCount: Selector, number>; + } + + function getExtraSelectors( + selectCount: Selector, number> + ): ExtraSelectors { + return { + selectSquaredCount: createSelector( + selectCount, + (count) => count * count + ), + }; + } + + const counterFeature = createFeature({ + name: 'counter', + reducer: createReducer(0), + extraSelectors: ({ selectCounterState }) => + getExtraSelectors(selectCounterState), + }); + `).toFail( + /Index signature for type 'string' is missing in type 'ExtraSelectors'./ + ); + }); + + it('should fail when extra selectors result is not a dictionary of selectors', () => { + expectSnippet(` + const counterFeature = createFeature({ + name: 'counter', + reducer: createReducer(0), + extraSelectors: ({ selectCounterState }) => ({ + selectSquaredCount: createSelector( + selectCounterState, + (count) => count * count + ), + x: 1, + }), + }); + `).toFail(); + + expectSnippet(` + const counterFeature = createFeature({ + name: 'counter', + reducer: createReducer(0), + extraSelectors: () => 'ngrx', + }); + `).toFail(); + }); + + it('should fail when feature state contains optional properties', () => { + expectSnippet(` + const counterFeature = createFeature({ + name: 'counter', + reducer: createReducer({} as { count?: number; }), + extraSelectors: () => ({}), + }); + `).toFail(); + + expectSnippet(` + interface State { + count?: number; + } + const initialState: State = {}; + + const counterFeature = createFeature({ + name: 'counter', + reducer: createReducer(initialState), + extraSelectors: () => ({}), + }); + `).toFail(); + }); + }); }); diff --git a/modules/store/src/feature_creator.ts b/modules/store/src/feature_creator.ts index 9ebf344123..d77ce90041 100644 --- a/modules/store/src/feature_creator.ts +++ b/modules/store/src/feature_creator.ts @@ -1,5 +1,5 @@ import { capitalize } from './helpers'; -import { ActionReducer } from './models'; +import { ActionReducer, Selector } from './models'; import { isPlainObject } from './meta-reducers/utils'; import { createFeatureSelector, @@ -8,32 +8,105 @@ import { } from './selector'; import { FeatureSelector, NestedSelectors } from './feature_creator_models'; -export type Feature< +export interface FeatureConfig { + name: FeatureName; + reducer: ActionReducer; +} + +type Feature< AppState extends Record, FeatureName extends keyof AppState & string, FeatureState extends AppState[FeatureName] > = FeatureConfig & - FeatureSelector & + BaseSelectors; + +type FeatureWithExtraSelectors< + FeatureName extends string, + FeatureState, + ExtraSelectors extends SelectorsDictionary +> = string extends keyof ExtraSelectors + ? Feature, FeatureName, FeatureState> + : Omit< + Feature, FeatureName, FeatureState>, + keyof ExtraSelectors + > & + ExtraSelectors; + +type BaseSelectors< + AppState extends Record, + FeatureName extends keyof AppState & string, + FeatureState extends AppState[FeatureName] +> = FeatureSelector & NestedSelectors; -export interface FeatureConfig { - name: FeatureName; - reducer: ActionReducer; -} +type SelectorsDictionary = Record< + string, + Selector, unknown> +>; + +type ExtraSelectorsFactory< + FeatureName extends string, + FeatureState, + ExtraSelectors extends SelectorsDictionary +> = ( + baseSelectors: BaseSelectors, FeatureName, FeatureState> +) => ExtraSelectors; type NotAllowedFeatureStateCheck = FeatureState extends Required ? unknown : 'optional properties are not allowed in the feature state'; +/** + * Creates a feature object with extra selectors. + * + * @param featureConfig An object that contains a feature name, a feature + * reducer, and extra selectors factory. + * @returns An object that contains a feature name, a feature reducer, + * a feature selector, a selector for each feature state property, and + * extra selectors. + */ +export function createFeature< + FeatureName extends string, + FeatureState, + ExtraSelectors extends SelectorsDictionary +>( + featureConfig: FeatureConfig & { + extraSelectors: ExtraSelectorsFactory< + FeatureName, + FeatureState, + ExtraSelectors + >; + } & NotAllowedFeatureStateCheck +): FeatureWithExtraSelectors; +/** + * Creates a feature object. + * + * @param featureConfig An object that contains a feature name and a feature + * reducer. + * @returns An object that contains a feature name, a feature reducer, + * a feature selector, and a selector for each feature state property. + */ +export function createFeature< + AppState extends Record, + FeatureName extends keyof AppState & string = keyof AppState & string, + FeatureState extends AppState[FeatureName] = AppState[FeatureName] +>( + featureConfig: FeatureConfig & + NotAllowedFeatureStateCheck +): Feature; /** * @description * A function that accepts a feature name and a feature reducer, and creates * a feature selector and a selector for each feature state property. + * This function also provides the ability to add extra selectors to + * the feature object. * - * @param featureConfig An object that contains a feature name and a feature reducer. + * @param featureConfig An object that contains a feature name and a feature + * reducer as required, and extra selectors factory as an optional argument. * @returns An object that contains a feature name, a feature reducer, - * a feature selector, and a selector for each feature state property. + * a feature selector, a selector for each feature state property, and extra + * selectors. * * @usageNotes * @@ -87,25 +160,85 @@ type NotAllowedFeatureStateCheck = * selectSelectedId, // type: MemoizedSelector * } = productsFeature; * ``` + * + * **Creating Feature with Extra Selectors** + * + * ```ts + * type CallState = 'init' | 'loading' | 'loaded' | { error: string }; + * + * interface State extends EntityState { + * callState: CallState; + * } + * + * const adapter = createEntityAdapter(); + * const initialState: State = adapter.getInitialState({ + * callState: 'init', + * }); + * + * export const productsFeature = createFeature({ + * name: 'products', + * reducer: createReducer(initialState), + * extraSelectors: ({ selectProductsState, selectCallState }) => ({ + * ...adapter.getSelectors(selectBooksState), + * ...getCallStateSelectors(selectCallState) + * }), + * }); + * + * const { + * name, + * reducer, + * // feature selector + * selectProductsState, + * // feature state properties selectors + * selectIds, + * selectEntities, + * selectCallState, + * // selectors returned by `adapter.getSelectors` + * selectAll, + * selectTotal, + * // selectors returned by `getCallStateSelectors` + * selectIsLoading, + * selectIsLoaded, + * selectError, + * } = productsFeature; + * ``` */ export function createFeature< AppState extends Record, - FeatureName extends keyof AppState & string = keyof AppState & string, - FeatureState extends AppState[FeatureName] = AppState[FeatureName] + FeatureName extends keyof AppState & string, + FeatureState extends AppState[FeatureName], + ExtraSelectors extends SelectorsDictionary >( - featureConfig: FeatureConfig & - NotAllowedFeatureStateCheck -): Feature { - const { name, reducer } = featureConfig; + featureConfig: FeatureConfig & { + extraSelectors?: ExtraSelectorsFactory< + FeatureName, + FeatureState, + ExtraSelectors + >; + } +): Feature & ExtraSelectors { + const { + name, + reducer, + extraSelectors: extraSelectorsFactory, + } = featureConfig; + const featureSelector = createFeatureSelector(name); const nestedSelectors = createNestedSelectors(featureSelector, reducer); + const baseSelectors = { + [`select${capitalize(name)}State`]: featureSelector, + ...nestedSelectors, + } as BaseSelectors, FeatureName, FeatureState>; + const extraSelectors = extraSelectorsFactory + ? extraSelectorsFactory(baseSelectors) + : {}; return { name, reducer, - [`select${capitalize(name)}State`]: featureSelector, - ...nestedSelectors, - } as unknown as Feature; + ...baseSelectors, + ...extraSelectors, + } as Feature & ExtraSelectors; } function createNestedSelectors< diff --git a/modules/store/src/reducer_creator.ts b/modules/store/src/reducer_creator.ts index 5946c45cf9..893c05fb10 100644 --- a/modules/store/src/reducer_creator.ts +++ b/modules/store/src/reducer_creator.ts @@ -111,10 +111,14 @@ export function on< * ); * ``` */ -export function createReducer( - initialState: S, - ...ons: ReducerTypes[] -): ActionReducer { +export function createReducer< + S, + A extends Action = Action, + // Additional generic for the return type is introduced to enable correct + // type inference when `createReducer` is used within `createFeature`. + // For more info see: https://github.com/microsoft/TypeScript/issues/52114 + R extends ActionReducer = ActionReducer +>(initialState: S, ...ons: ReducerTypes[]): R { const map = new Map>(); for (const on of ons) { for (const type of on.types) { @@ -132,5 +136,5 @@ export function createReducer( return function (state: S = initialState, action: A): S { const reducer = map.get(action.type); return reducer ? reducer(state, action) : state; - }; + } as R; }