From 81a817d1a7801f6e7042bcc1a7a4e16a4e3a8e09 Mon Sep 17 00:00:00 2001 From: Mateus Carniatto Date: Thu, 9 Mar 2023 14:10:16 +0100 Subject: [PATCH] feat(selector-utils): add advanced selector utils (#1824) * feat(selector-utils): Impl. selector utils * test(selector-utils): Initial unit tests * refactor(select-utils): Rename createMappedSelector to createModelSelector * docs: fix selector util code examples * docs: fix description of pick selector * docs: add note about performance benefit of pick selector * docs: add index to sections for selector utils * refactor(store): rename selector util files * refactor(store): fix internal type references * feat(store): export selector utils in public api * test(store): move selector util spec files * chore(store): add error handling to createPropertySelectors * test(store): clean up createPropertySelectors tests * test: remove the .only usage in test * test(store): add more createPropertySelectors tests * test(store): fix issue in createPropertySelectors tests * test(store): add some createPickSelector tests * test(store): add more createPickSelector tests * docs: more docs improvements * test: fix broken test snapshots * test(store): add tests for createModelSelector * refactor: extract checks for selector map * docs: more docs improvements * chore: update CHANGELOG.md * chore: update CHANGELOG.md * docs(store): fix issue --------- Co-authored-by: carniatto Co-authored-by: markwhitfeld --- CHANGELOG.md | 1 + docs/SUMMARY.md | 1 + docs/advanced/selector-utils.md | 175 ++++++++++++ packages/store/src/public_api.ts | 14 +- .../src/selectors/create-model-selector.ts | 53 ++++ .../src/selectors/create-pick-selector.ts | 22 ++ .../selectors/create-property-selectors.ts | 34 +++ packages/store/src/selectors/index.ts | 4 + .../src/selectors/selector-checks.util.ts | 26 ++ .../src/selectors/selector-types.util.ts | 3 +- .../selectors/create-model-selector.spec.ts | 262 ++++++++++++++++++ .../selectors/create-pick-selector.spec.ts | 202 ++++++++++++++ .../create-property-selectors.spec.ts | 189 +++++++++++++ 13 files changed, 982 insertions(+), 4 deletions(-) create mode 100644 docs/advanced/selector-utils.md create mode 100644 packages/store/src/selectors/create-model-selector.ts create mode 100644 packages/store/src/selectors/create-pick-selector.ts create mode 100644 packages/store/src/selectors/create-property-selectors.ts create mode 100644 packages/store/src/selectors/index.ts create mode 100644 packages/store/src/selectors/selector-checks.util.ts create mode 100644 packages/store/tests/selectors/create-model-selector.spec.ts create mode 100644 packages/store/tests/selectors/create-pick-selector.spec.ts create mode 100644 packages/store/tests/selectors/create-property-selectors.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 51ee4a0d1..abfbf078e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ### For next major version - Feature: Build packages in Ivy format [#1945](https://github.com/ngxs/store/pull/1945) +- Feature: Add advanced selector utilities [#1824](https://github.com/ngxs/store/pull/1824) - Feature: Expose `ActionContext` and `ActionStatus` [#1766](https://github.com/ngxs/store/pull/1766) - Feature: `ofAction*` methods should have strong types [#1808](https://github.com/ngxs/store/pull/1808) - Feature: Improve contextual type inference for state operators [#1806](https://github.com/ngxs/store/pull/1806) [#1947](https://github.com/ngxs/store/pull/1947) diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 7213a5d09..ebf3bc50e 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -23,6 +23,7 @@ - [Monitoring Unhandled Actions](advanced/monitoring-unhandled-actions.md) - [Optimizing Selectors](advanced/optimizing-selectors.md) - [Options](advanced/options.md) + - [Selector Utils](advanced/selector-utils.md) - [Shared State](advanced/shared-state.md) - [State Token](advanced/token.md) - [State Operators](advanced/operators.md) diff --git a/docs/advanced/selector-utils.md b/docs/advanced/selector-utils.md new file mode 100644 index 000000000..5ce0f3df4 --- /dev/null +++ b/docs/advanced/selector-utils.md @@ -0,0 +1,175 @@ +# State Selector Utils + +## Why? + +Selectors are one of the most powerful features in `NGXS`. When used in a correct way they are very performant due to the built-in memoization. However, in order to use selectors correctly we usually need to break down the state into smaller selectors that, in turn, will be used by other selectors. This approach is important to guarantee that selectors are only run when a change of interest has happened. +The process of breaking down your state into simple selectors for each property of the state model can be tedious and usually comes with a lot of boilerplate. The objective the selector utils is to make it easy to generate these selectors, combine selectors from multiple states, and create a selector based on a subset of properties of your state. + +These are the provided utils: + +- [createPropertySelectors](#create-property-selectors) - create a selector for each property of an object returned by a selector. +- [createModelSelector](#create-model-selector) - create a selector that returns an object which is composed from values returned by multiple selectors. +- [createPickSelector](#create-pick-selector) - create a selector that returns a subset of an object's properties, and changes only when those properties change. + +## Create Property Selectors + +Let's start with a common example. Here we have a small state containing animals. Check the snippet below: + +```ts +import { Injectable } from '@angular/core'; +import { State, Action, StateContext } from '@ngxs/store'; + +export interface AnimalsStateModel { + zebras: string[]; + pandas: string[]; + monkeys?: string[]; +} + +@State({ + name: 'animals', + defaults: { + zebras: [], + pandas: [], + monkeys: [], + }, +}) +@Injectable() +export class AnimalsState {} + +export class AnimalsSelectors { + @Selector(AnimalsState) + static zebras(state: AnimalStateModel) { + return state.zebras; + } + + @Selector(AnimalsState) + static pandas(state: AnimalStateModel) { + return state.pandas; + } + + @Selector(AnimalsState) + static monkeys(state: AnimalStateModel) { + return state.monkeys; + } +} +``` + +Here we see how verbose the split of a state into selectors can look. We can use the `createPropertySelectors` to cleanup this code a bit. See the snippet below: + +```ts +import { Selector, createPropertySelectors } from '@ngxs/store'; + +export class AnimalsSelectors { + // creates map of selectors for each state property + static slices = createPropertySelectors(AnimalSate); + + // slices can be used in other selectors + @Selector([AnimalsSelectors.slices.zebras, AnimalsSelectors.slices.pandas]) + static countZebrasAndPandas(zebras: string[], pandas: string[]) { + return zebras.length + pandas.length; + } +} + +@Component({ + selector: 'my-zoo' + template: ` +

Zebras

+
    +
  1. {{ zebra }}
  2. +
+ `, + style: '' +}) +export class MyZooComponent { + // slices can be use directly in the components + zebras$ = this.store.select(AnimalsSelectors.slices.zebras); + + constructor(private store: Store) {} +} +``` + +Here we see how the `createPropertySelectors` is used to create a map of selectors for each property of the state. The `createPropertySelectors` takes a state class and returns a map of selectors for each property of the state. The `createPropertySelectors` is very useful when we need to create a selector for each property of the state. + +> **TYPE SAFETY:** Note that, in the `createPropertySelectors` call above, the model type was provided to the function as a type parameter. This was only necessary because the state class (`AnimalSate`) was provided and the class does not include model information. The `createPropertySelectors` function will not require a type parameter if a typed selector or a `StateToken` that includes the type of the model is provided to the function. + +## Create Model Selector + +Sometimes we need to create a selector simply groups other selectors. For example, we might want to create a selector that maps the state to a map of pandas and zoos. We can use the `createModelSelector` to create such a selector. See the snippet below: + +```ts +import { Selector, createModelSelector } from '@ngxs/store'; + +export class AnimalsSelectors { + static slices = createPropertySelectors(AnimalSate); + + static pandasAndZoos = createModelSelector({ + pandas: AnimalsSelectors.slices.pandas, + zoos: ZoosSelectors.slices.zoos + }); +} + +@Component({ + selector: 'my-zoo' + template: ` +

Pandas and Zoos

+
    +
  1. Panda Count: {{ model.pandas?.length || 0 }}
  2. +
  3. Zoos Count: {{ model.zoos?.length || 0 }}
  4. +
+ `, + style: '' +}) +export class MyZooComponent { + pandasAndZoos$ = this.store.select(AnimalsSelectors.pandasAndZoos); + + constructor(private store: Store) {} +} +``` + +Here we see how the `createModelSelector` is used to create a selector that maps the state to a map of pandas and zoos. The `createModelSelector` takes a map of selectors and returns a selector that maps the state to a map of the values returned by the selectors. The `createModelSelector` is very useful when we need to create a selector that groups other selectors. + +> **TYPE SAFETY:** Note that it is always best to use typed selectors in the selector map provided to the `createModelSelector` function. The output model is inferred from the selector map. A state class (eg. `AnimalSate`) does not include model information and this causes issues with the type inference. It is also questionable why an entire state would be included in a model, because this breaks encapsulation and would also cause change detection to trigger more often. + +## Create Pick Selector + +Sometimes we need to create a selector that picks a subset of properties from the state. For example, we might want to create a selector that picks only the `zebras` and `pandas` properties from the state. We can use the `createPickSelector` to create such a selector. See the snippet below: + +```ts +import { Selector, createPickSelector } from '@ngxs/store'; + +export class AnimalsSelectors { + static fullAnimalsState = createSelector([AnimalState], (state: AnimalStateModel) => state); + + static zebrasAndPandas = createPickSelector(fullAnimalsState, [ + 'zebras', + 'pandas' + ]); +} + +@Component({ + selector: 'my-zoo' + template: ` +

Zebras and Pandas

+
    +
  1. Zerba Count: {{ zebrasAndPandas.zebras?.length || 0 }}
  2. +
  3. Panda Count: {{ zebrasAndPandas.pandas?.length || 0 }}
  4. +
+ `, + style: '' +}) +export class MyZooComponent { + zebrasAndPandas$ = this.store.select(AnimalsSelectors.zebrasAndPandas); + + constructor(private store: Store) {} +} +``` + +The `zebrasAndPandas` object above would only contain the `zebras` and `pandas` properties, and not have the `monkeys` property. + +Here we see how the `createPickSelector` is used to create a selector that picks a subset of properties from the state, or from any other selector that returns an object for that matter. The `createPickSelector` takes a selector which returns an object and an array of property names and returns a selector that returns a copy of the object, with only the properties that have been picked. The `createPickSelector` is very useful when we need to create a selector that picks a subset of properties from the state. + +> **TYPE SAFETY:** The `createPickSelector` function should only be provided a strongly typed selector or a `StateToken` that includes the type of the model. This is so that type safety is maintained for the picked properties. + +**Noteable Performance win!** + +One of the most useful things about the `createPickSelector` selector (versus rolling your own that creates a trimmed object from the provided selector), is that it will only emit a new value when a picked property changes, and will not emit a new value if any of the other properties change. An Angular change detection performance enthusiasts dream! diff --git a/packages/store/src/public_api.ts b/packages/store/src/public_api.ts index 2546f788f..514bb37a4 100644 --- a/packages/store/src/public_api.ts +++ b/packages/store/src/public_api.ts @@ -19,7 +19,7 @@ export { getSelectorMetadata, getStoreMetadata, ensureStoreMetadata, - ensureSelectorMetadata + ensureSelectorMetadata, } from './public_to_deprecate'; export { ofAction, @@ -28,7 +28,7 @@ export { ofActionCanceled, ofActionErrored, ofActionCompleted, - ActionCompletion + ActionCompletion, } from './operators/of-action'; export { StateContext, @@ -37,7 +37,7 @@ export { NgxsAfterBootstrap, NgxsOnChanges, NgxsModuleOptions, - NgxsSimpleChange + NgxsSimpleChange, } from './symbols'; export { Selector } from './decorators/selector/selector'; export { getActionTypeFromInstance, actionMatcher } from './utils/utils'; @@ -50,3 +50,11 @@ export { StateToken } from './state-token/state-token'; export { NgxsDevelopmentOptions } from './dev-features/symbols'; export { NgxsDevelopmentModule } from './dev-features/ngxs-development.module'; export { NgxsUnhandledActionsLogger } from './dev-features/ngxs-unhandled-actions-logger'; + +export { + createModelSelector, + createPickSelector, + createPropertySelectors, + PropertySelectors, + TypedSelector, +} from './selectors'; diff --git a/packages/store/src/selectors/create-model-selector.ts b/packages/store/src/selectors/create-model-selector.ts new file mode 100644 index 000000000..79ebc01bf --- /dev/null +++ b/packages/store/src/selectors/create-model-selector.ts @@ -0,0 +1,53 @@ +import { createSelector } from '../utils/selector-utils'; +import { ensureValidSelector, ensureValueProvided } from './selector-checks.util'; +import { TypedSelector } from './selector-types.util'; + +interface SelectorMap { + [key: string]: TypedSelector; +} + +type ModelSelector = (...args: any[]) => MappedResult; + +type MappedResult = { + [P in keyof TSelectorMap]: TSelectorMap[P] extends TypedSelector ? R : never; +}; + +export function createModelSelector(selectorMap: T): ModelSelector { + const selectorKeys = Object.keys(selectorMap); + const selectors = Object.values(selectorMap); + ensureValidSelectorMap({ + prefix: '[createModelSelector]', + selectorMap, + selectorKeys, + selectors, + }); + + return createSelector(selectors, (...args) => { + return selectorKeys.reduce((obj, key, index) => { + (obj as any)[key] = args[index]; + return obj; + }, {} as MappedResult); + }) as ModelSelector; +} + +function ensureValidSelectorMap({ + prefix, + selectorMap, + selectorKeys, + selectors, +}: { + prefix: string; + selectorMap: T; + selectorKeys: string[]; + selectors: TypedSelector[]; +}) { + ensureValueProvided(selectorMap, { prefix, noun: 'selector map' }); + ensureValueProvided(typeof selectorMap === 'object', { prefix, noun: 'valid selector map' }); + ensureValueProvided(selectorKeys.length, { prefix, noun: 'non-empty selector map' }); + selectors.forEach((selector, index) => + ensureValidSelector(selector, { + prefix, + noun: `selector for the '${selectorKeys[index]}' property`, + }) + ); +} diff --git a/packages/store/src/selectors/create-pick-selector.ts b/packages/store/src/selectors/create-pick-selector.ts new file mode 100644 index 000000000..00436f417 --- /dev/null +++ b/packages/store/src/selectors/create-pick-selector.ts @@ -0,0 +1,22 @@ +import { createSelector } from '../utils/selector-utils'; +import { ensureValidSelector } from './selector-checks.util'; +import { TypedSelector } from './selector-types.util'; + +type KeysToValues = { + [Index in keyof Keys]: Keys[Index] extends keyof T ? T[Keys[Index]] : never; +}; + +export function createPickSelector( + selector: TypedSelector, + keys: [...Keys] +) { + ensureValidSelector(selector, { prefix: '[createPickSelector]' }); + const validKeys = keys.filter(Boolean); + const selectors = validKeys.map((key) => createSelector([selector], (s: TModel) => s[key])); + return createSelector([...selectors], (...props: KeysToValues) => { + return validKeys.reduce((acc, key, index) => { + acc[key] = props[index]; + return acc; + }, {} as Pick); + }); +} diff --git a/packages/store/src/selectors/create-property-selectors.ts b/packages/store/src/selectors/create-property-selectors.ts new file mode 100644 index 000000000..cb49e0056 --- /dev/null +++ b/packages/store/src/selectors/create-property-selectors.ts @@ -0,0 +1,34 @@ +import { createSelector } from '../utils/selector-utils'; +import { ensureValidSelector } from './selector-checks.util'; +import { SelectorDef } from './selector-types.util'; + +export type PropertySelectors = { + [P in keyof NonNullable]-?: ( + model: TModel + ) => TModel extends null | undefined ? undefined : NonNullable[P]; +}; + +export function createPropertySelectors( + parentSelector: SelectorDef +): PropertySelectors { + ensureValidSelector(parentSelector, { + prefix: '[createPropertySelectors]', + noun: 'parent selector', + }); + const cache: Partial> = {}; + return new Proxy>( + {} as unknown as PropertySelectors, + { + get(_target: any, prop: keyof TModel) { + const selector = + cache[prop] || + (createSelector( + [parentSelector], + (s: TModel) => s?.[prop] + ) as PropertySelectors[typeof prop]); + cache[prop] = selector; + return selector; + }, + } as ProxyHandler> + ); +} diff --git a/packages/store/src/selectors/index.ts b/packages/store/src/selectors/index.ts new file mode 100644 index 000000000..2a44b60f1 --- /dev/null +++ b/packages/store/src/selectors/index.ts @@ -0,0 +1,4 @@ +export * from './create-model-selector'; +export * from './create-pick-selector'; +export * from './create-property-selectors'; +export * from './selector-types.util'; diff --git a/packages/store/src/selectors/selector-checks.util.ts b/packages/store/src/selectors/selector-checks.util.ts new file mode 100644 index 000000000..9d1b6ded8 --- /dev/null +++ b/packages/store/src/selectors/selector-checks.util.ts @@ -0,0 +1,26 @@ +import { getSelectorMetadata, getStoreMetadata } from '../internal/internals'; +import { SelectorDef } from './selector-types.util'; + +export function ensureValidSelector( + selector: SelectorDef, + context: { prefix?: string; noun?: string } = {} +) { + const noun = context.noun || 'selector'; + const prefix = context.prefix ? context.prefix + ': ' : ''; + ensureValueProvided(selector, { noun, prefix: context.prefix }); + const metadata = getSelectorMetadata(selector) || getStoreMetadata(selector as any); + if (!metadata) { + throw new Error(`${prefix}The value provided as the ${noun} is not a valid selector.`); + } +} + +export function ensureValueProvided( + value: any, + context: { prefix?: string; noun?: string } = {} +) { + const noun = context.noun || 'value'; + const prefix = context.prefix ? context.prefix + ': ' : ''; + if (!value) { + throw new Error(`${prefix}A ${noun} must be provided.`); + } +} diff --git a/packages/store/src/selectors/selector-types.util.ts b/packages/store/src/selectors/selector-types.util.ts index 6d72346ff..43e305aaa 100644 --- a/packages/store/src/selectors/selector-types.util.ts +++ b/packages/store/src/selectors/selector-types.util.ts @@ -1,6 +1,7 @@ -import { StateToken } from '@ngxs/store'; import { StateClass } from '@ngxs/store/internals'; +import { StateToken } from '../state-token/state-token'; + export type SelectorFunc = (...arg: any[]) => TModel; export type TypedSelector = StateToken | SelectorFunc; diff --git a/packages/store/tests/selectors/create-model-selector.spec.ts b/packages/store/tests/selectors/create-model-selector.spec.ts new file mode 100644 index 000000000..ef7bd40ad --- /dev/null +++ b/packages/store/tests/selectors/create-model-selector.spec.ts @@ -0,0 +1,262 @@ +import { Injectable } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { + NgxsModule, + State, + Store, + createModelSelector, + createSelector, + createPropertySelectors, +} from '../../src/public_api'; + +describe('createModelSelector', () => { + interface MockStateModel { + property1: string; + property2: number[]; + property3: { hello: string }; + } + + @State({ + name: 'mockstate', + defaults: { + property1: '', + property2: [], + property3: { hello: 'world' }, + }, + }) + @Injectable() + class MockState {} + + function setupFixture() { + TestBed.configureTestingModule({ + imports: [NgxsModule.forRoot([MockState])], + }); + const store: Store = TestBed.inject(Store); + const setState = (newState: MockStateModel) => store.reset({ mockstate: newState }); + const patchState = (newState: Partial) => { + const currentState = store.selectSnapshot(MockState); + setState({ ...currentState, ...newState }); + }; + const stateSelector = createSelector([MockState], (state: MockStateModel) => state); + return { store, MockState, setState, patchState, stateSelector }; + } + + describe('[failures]', () => { + it('should fail if a null selector map is provided', () => { + // Arrange + let error: Error | null = null; + // Act + try { + createModelSelector(null as unknown as any); + } catch (err) { + error = err as Error; + } + // Assert + expect(error).not.toBeNull(); + expect(error?.message).toMatchInlineSnapshot( + `"Cannot convert undefined or null to object"` + ); + }); + + it('should fail if a undefined selector map is provided', () => { + // Arrange + let error: Error | null = null; + // Act + try { + createModelSelector(undefined as unknown as any); + } catch (err) { + error = err as Error; + } + // Assert + expect(error).not.toBeNull(); + expect(error?.message).toMatchInlineSnapshot( + `"Cannot convert undefined or null to object"` + ); + }); + + it('should fail if a non-object is provided for the selector map', () => { + // Arrange + let error: Error | null = null; + // Act + try { + createModelSelector('not a map' as unknown as any); + } catch (err) { + error = err as Error; + } + // Assert + expect(error).not.toBeNull(); + expect(error?.message).toMatchInlineSnapshot( + `"[createModelSelector]: A valid selector map must be provided."` + ); + }); + + it('should fail if an empty selector map is provided', () => { + // Arrange + let error: Error | null = null; + // Act + try { + createModelSelector({}); + } catch (err) { + error = err as Error; + } + // Assert + expect(error).not.toBeNull(); + expect(error?.message).toMatchInlineSnapshot( + `"[createModelSelector]: A non-empty selector map must be provided."` + ); + }); + + it('should fail if a null selector is provided in the selector map', () => { + // Arrange + const { stateSelector } = setupFixture(); + let error: Error | null = null; + // Act + try { + createModelSelector({ + prop: null as unknown as typeof stateSelector, + everything: stateSelector, + }); + } catch (err) { + error = err as Error; + } + // Assert + expect(error).not.toBeNull(); + expect(error?.message).toMatchInlineSnapshot( + `"[createModelSelector]: A selector for the 'prop' property must be provided."` + ); + }); + + it('should fail if an undefined selector is provided in the selector map', () => { + // Arrange + const { stateSelector } = setupFixture(); + let error: Error | null = null; + // Act + try { + createModelSelector({ + everything: stateSelector, + test: undefined as unknown as typeof stateSelector, + }); + } catch (err) { + error = err as Error; + } + // Assert + expect(error).not.toBeNull(); + expect(error?.message).toMatchInlineSnapshot( + `"[createModelSelector]: A selector for the 'test' property must be provided."` + ); + }); + + it('should fail if a class that is not a selector is provided in the selector map', () => { + // Arrange + const { stateSelector } = setupFixture(); + let error: Error | null = null; + class NotAState {} + // Act + try { + createModelSelector({ + everything: stateSelector, + test: NotAState as unknown as typeof stateSelector, + }); + } catch (err) { + error = err as Error; + } + // Assert + expect(error).not.toBeNull(); + expect(error?.message).toMatchInlineSnapshot( + `"[createModelSelector]: The value provided as the selector for the 'test' property is not a valid selector."` + ); + }); + + it('should fail if a class that is not a selector is provided in the selector map', () => { + // Arrange + const { stateSelector } = setupFixture(); + let error: Error | null = null; + function NotASelector() {} + // Act + try { + createModelSelector({ + everything: stateSelector, + test: NotASelector as unknown as typeof stateSelector, + }); + } catch (err) { + error = err as Error; + } + // Assert + expect(error).not.toBeNull(); + expect(error?.message).toMatchInlineSnapshot( + `"[createModelSelector]: The value provided as the selector for the 'test' property is not a valid selector."` + ); + }); + }); + + it('should create a model from the selectors in the selector map', () => { + // Arrange + const { store, stateSelector, setState } = setupFixture(); + setState({ property1: 'Tada', property2: [1, 3, 5], property3: { hello: 'there' } }); + const props = createPropertySelectors(stateSelector); + const prop3 = createPropertySelectors(props.property3); + // Act + const modelSelector = createModelSelector({ + foo: props.property2, + bar: props.property1, + hello: prop3.hello, + }); + // Assert + expect(modelSelector).toBeDefined(); + expect(store.selectSnapshot(modelSelector)).toStrictEqual({ + foo: [1, 3, 5], + bar: 'Tada', + hello: 'there', + }); + }); + + describe('[memoization]', () => { + it('should change if a specified property changes', () => { + // Arrange + const { store, stateSelector, setState, patchState } = setupFixture(); + setState({ property1: 'Tada', property2: [1, 3, 5], property3: { hello: 'there' } }); + const props = createPropertySelectors(stateSelector); + const prop3 = createPropertySelectors(props.property3); + // Act + const modelSelector = createModelSelector({ + foo: props.property2, + bar: props.property1, + hello: prop3.hello, + }); + // Assert + expect(modelSelector).toBeDefined(); + const snapshot1 = store.selectSnapshot(modelSelector); + expect(snapshot1).toStrictEqual({ foo: [1, 3, 5], bar: 'Tada', hello: 'there' }); + patchState({ property1: 'Hi' }); + const snapshot2 = store.selectSnapshot(modelSelector); + expect(snapshot2).not.toBe(snapshot1); + expect(snapshot2).toStrictEqual({ foo: [1, 3, 5], bar: 'Hi', hello: 'there' }); + patchState({ property2: [2, 4] }); + const snapshot3 = store.selectSnapshot(modelSelector); + expect(snapshot3).not.toBe(snapshot1); + expect(snapshot3).not.toBe(snapshot2); + expect(snapshot3).toStrictEqual({ foo: [2, 4], bar: 'Hi', hello: 'there' }); + }); + + it('should not change if an unspecified property changes', () => { + // Arrange + const { store, stateSelector, setState, patchState } = setupFixture(); + setState({ property1: 'Tada', property2: [1, 3, 5], property3: { hello: 'there' } }); + const props = createPropertySelectors(stateSelector); + const prop3 = createPropertySelectors(props.property3); + // Act + const modelSelector = createModelSelector({ + bar: props.property1, + hello: prop3.hello, + }); + // Assert + expect(modelSelector).toBeDefined(); + const snapshot1 = store.selectSnapshot(modelSelector); + expect(snapshot1).toStrictEqual({ bar: 'Tada', hello: 'there' }); + patchState({ property2: [2, 4] }); + const snapshot2 = store.selectSnapshot(modelSelector); + expect(snapshot2).toBe(snapshot1); + expect(snapshot1).toStrictEqual({ bar: 'Tada', hello: 'there' }); + }); + }); +}); diff --git a/packages/store/tests/selectors/create-pick-selector.spec.ts b/packages/store/tests/selectors/create-pick-selector.spec.ts new file mode 100644 index 000000000..e779c57ed --- /dev/null +++ b/packages/store/tests/selectors/create-pick-selector.spec.ts @@ -0,0 +1,202 @@ +import { Injectable } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { + NgxsModule, + State, + Store, + createPickSelector, + createSelector, +} from '../../src/public_api'; + +describe('createPickSelector', () => { + interface MockStateModel { + property1: string; + property2: number[]; + property3: { hello: string }; + } + + @State({ + name: 'mockstate', + defaults: { + property1: '', + property2: [], + property3: { hello: 'world' }, + }, + }) + @Injectable() + class MockState {} + + function setupFixture() { + TestBed.configureTestingModule({ + imports: [NgxsModule.forRoot([MockState])], + }); + const store: Store = TestBed.inject(Store); + const setState = (newState: MockStateModel) => store.reset({ mockstate: newState }); + const patchState = (newState: Partial) => { + const currentState = store.selectSnapshot(MockState); + setState({ ...currentState, ...newState }); + }; + const stateSelector = createSelector([MockState], (state: MockStateModel) => state); + return { store, MockState, setState, patchState, stateSelector }; + } + + describe('[failures]', () => { + it('should fail if a null selector is provided', () => { + // Arrange + const { stateSelector } = setupFixture(); + let error: Error | null = null; + // Act + try { + createPickSelector(null as unknown as typeof stateSelector, ['property1']); + } catch (err) { + error = err as Error; + } + // Assert + expect(error).not.toBeNull(); + expect(error?.message).toMatchInlineSnapshot( + `"[createPickSelector]: A selector must be provided."` + ); + }); + + it('should fail if a undefined selector is provided', () => { + // Arrange + const { stateSelector } = setupFixture(); + let error: Error | null = null; + // Act + try { + createPickSelector(undefined as unknown as typeof stateSelector, ['property1']); + } catch (err) { + error = err as Error; + } + // Assert + expect(error).not.toBeNull(); + expect(error?.message).toMatchInlineSnapshot( + `"[createPickSelector]: A selector must be provided."` + ); + }); + + it('should fail if a class that is not a selector is provided', () => { + // Arrange + const { stateSelector } = setupFixture(); + let error: Error | null = null; + class NotAState {} + // Act + try { + createPickSelector(NotAState as unknown as typeof stateSelector, ['property1']); + } catch (err) { + error = err as Error; + } + // Assert + expect(error).not.toBeNull(); + expect(error?.message).toMatchInlineSnapshot( + `"[createPickSelector]: The value provided as the selector is not a valid selector."` + ); + }); + + it('should fail if a function that is not a selector is provided', () => { + // Arrange + const { stateSelector } = setupFixture(); + let error: Error | null = null; + function NotASelector() {} + // Act + try { + createPickSelector(NotASelector as unknown as typeof stateSelector, ['property1']); + } catch (err) { + error = err as Error; + } + // Assert + expect(error).not.toBeNull(); + expect(error?.message).toMatchInlineSnapshot( + `"[createPickSelector]: The value provided as the selector is not a valid selector."` + ); + }); + }); + + it('should select only the specified properties', () => { + // Arrange + const { store, stateSelector, setState } = setupFixture(); + setState({ property1: 'Tada', property2: [1, 3, 5], property3: { hello: 'there' } }); + // Act + const pickSelector = createPickSelector(stateSelector, ['property1', 'property2']); + // Assert + expect(pickSelector).toBeDefined(); + expect(store.selectSnapshot(pickSelector)).toStrictEqual({ + property1: 'Tada', + property2: [1, 3, 5], + }); + }); + + it('should ignore an undefined key in the specified properties', () => { + // Arrange + const { store, stateSelector, setState } = setupFixture(); + setState({ property1: 'Tada', property2: [1, 3, 5], property3: { hello: 'there' } }); + // Act + const pickSelector = createPickSelector(stateSelector, [ + 'property1', + undefined as any, + 'property2', + ]); + // Assert + expect(pickSelector).toBeDefined(); + expect(store.selectSnapshot(pickSelector)).toStrictEqual({ + property1: 'Tada', + property2: [1, 3, 5], + }); + }); + + it('should ignore a null key in the specified properties', () => { + // Arrange + const { store, stateSelector, setState } = setupFixture(); + setState({ property1: 'Tada', property2: [1, 3, 5], property3: { hello: 'there' } }); + // Act + const pickSelector = createPickSelector(stateSelector, [ + 'property1', + null as any, + 'property2', + ]); + // Assert + expect(pickSelector).toBeDefined(); + expect(store.selectSnapshot(pickSelector)).toStrictEqual({ + property1: 'Tada', + property2: [1, 3, 5], + }); + }); + + describe('[memoization]', () => { + it('should change if a specified property changes', () => { + // Arrange + const { store, stateSelector, setState, patchState } = setupFixture(); + setState({ property1: 'Tada', property2: [1, 3, 5], property3: { hello: 'there' } }); + // Act + const pickSelector = createPickSelector(stateSelector, ['property1', 'property2']); + // Assert + expect(pickSelector).toBeDefined(); + const snapshot1 = store.selectSnapshot(pickSelector); + expect(snapshot1).toStrictEqual({ property1: 'Tada', property2: [1, 3, 5] }); + patchState({ property1: 'Hi' }); + const snapshot2 = store.selectSnapshot(pickSelector); + expect(snapshot2).not.toBe(snapshot1); + expect(snapshot2).toStrictEqual({ property1: 'Hi', property2: [1, 3, 5] }); + patchState({ property2: [2, 4] }); + const snapshot3 = store.selectSnapshot(pickSelector); + expect(snapshot3).not.toBe(snapshot1); + expect(snapshot3).not.toBe(snapshot2); + expect(snapshot3).toStrictEqual({ property1: 'Hi', property2: [2, 4] }); + }); + + it('should not change if an unspecified property changes', () => { + // Arrange + const { store, stateSelector, setState, patchState } = setupFixture(); + setState({ property1: 'Tada', property2: [1, 3, 5], property3: { hello: 'there' } }); + // Act + const pickSelector = createPickSelector(stateSelector, ['property1', 'property2']); + // Assert + expect(pickSelector).toBeDefined(); + const snapshot1 = store.selectSnapshot(pickSelector); + expect(snapshot1).toStrictEqual({ property1: 'Tada', property2: [1, 3, 5] }); + patchState({ property3: { hello: 'you' } }); + const snapshot2 = store.selectSnapshot(pickSelector); + expect(snapshot2).toBe(snapshot1); + }); + }); +}); diff --git a/packages/store/tests/selectors/create-property-selectors.spec.ts b/packages/store/tests/selectors/create-property-selectors.spec.ts new file mode 100644 index 000000000..ec8eb6cb1 --- /dev/null +++ b/packages/store/tests/selectors/create-property-selectors.spec.ts @@ -0,0 +1,189 @@ +import { Injectable } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { NgxsModule, State, Store, createPropertySelectors } from '../../src/public_api'; + +describe('createPropertySelectors', () => { + interface MyStateModel { + property1: string; + property2: number[]; + emptyProperty: { + loading?: boolean; + }; + } + + @State({ + name: 'myState', + defaults: { + property1: 'testValue', + property2: [1, 2, 3], + emptyProperty: {}, + }, + }) + @Injectable() + class MyState {} + + function setupFixture() { + TestBed.configureTestingModule({ + imports: [NgxsModule.forRoot([MyState])], + }); + const store: Store = TestBed.inject(Store); + const setState = (newState: MyStateModel) => store.reset({ myState: newState }); + return { store, MyState, setState }; + } + + describe('[failures]', () => { + it('should fail if a null selector is provided', () => { + // Arrange + let error: Error | null = null; + // Act + try { + createPropertySelectors(null as any); + } catch (err) { + error = err as Error; + } + // Assert + expect(error).not.toBeNull(); + expect(error?.message).toMatchInlineSnapshot( + `"[createPropertySelectors]: A parent selector must be provided."` + ); + }); + + it('should fail if a undefined selector is provided', () => { + // Arrange + let error: Error | null = null; + // Act + try { + createPropertySelectors(undefined as any); + } catch (err) { + error = err as Error; + } + // Assert + expect(error).not.toBeNull(); + expect(error?.message).toMatchInlineSnapshot( + `"[createPropertySelectors]: A parent selector must be provided."` + ); + }); + + it('should fail if a class that is not a selector is provided', () => { + // Arrange + let error: Error | null = null; + // Act + try { + class NotAState {} + createPropertySelectors(NotAState); + } catch (err) { + error = err as Error; + } + // Assert + expect(error).not.toBeNull(); + expect(error?.message).toMatchInlineSnapshot( + `"[createPropertySelectors]: The value provided as the parent selector is not a valid selector."` + ); + }); + + it('should fail if a function that is not a selector is provided', () => { + // Arrange + let error: Error | null = null; + function NotASelector() {} + // Act + try { + createPropertySelectors(NotASelector); + } catch (err) { + error = err as Error; + } + // Assert + expect(error).not.toBeNull(); + expect(error?.message).toMatchInlineSnapshot( + `"[createPropertySelectors]: The value provided as the parent selector is not a valid selector."` + ); + }); + }); + + it('should create a selector for each property of state', () => { + // Arrange + // Act + const slices = createPropertySelectors(MyState); + // Assert + expect(slices).toHaveProperty('property1'); + expect(slices).toHaveProperty('property2'); + expect(slices).toHaveProperty('emptyProperty'); + }); + + it('should return selectors returning the correct value of the state', () => { + // Arrange + const exampleState: MyStateModel = { + property1: 'foo', + property2: [5, 4, 3], + emptyProperty: { + loading: true, + }, + }; + // Act + const slices = createPropertySelectors(MyState); + // Assert + + expect(slices.property1(exampleState)).toBe('foo'); + expect(slices.property2(exampleState)).toStrictEqual([5, 4, 3]); + }); + + it('should handle missing properties in the state as undefined', () => { + // Arrange + const { store, MyState, setState } = setupFixture(); + + const slices = createPropertySelectors(MyState); + + // Act + const slicesOnEmptyProperty = createPropertySelectors( + slices.emptyProperty + ); + + // Assert + expect(store.selectSnapshot(slices.emptyProperty)).toEqual({}); + expect(store.selectSnapshot(slicesOnEmptyProperty.loading)).toBe(undefined); + + setState({ + property1: 'testValue', + property2: [1, 2, 3], + emptyProperty: { + loading: true, + }, + }); + + expect(store.selectSnapshot(slicesOnEmptyProperty.loading)).toBe(true); + }); + + it('should memoise each internal selector', () => { + // Arrange + const { store, MyState } = setupFixture(); + const myState = store.selectSnapshot(MyState); + // Act + const slices1 = createPropertySelectors(MyState); + const slices2 = createPropertySelectors(MyState); + // Assert + expect(slices1.property1(myState) === slices2.property1(myState)).toBeTruthy(); + expect(slices1.property2(myState) === slices2.property2(myState)).toBeTruthy(); + expect(slices1.emptyProperty(myState) === slices2.emptyProperty(myState)).toBeTruthy(); + }); + + it('should return the same selectors from slice object on each call', () => { + // Arrange + // Act + const slice = createPropertySelectors(MyState); + // Assert + expect(slice.property1).toBe(slice.property1); + expect(slice.property2).toBe(slice.property2); + expect(slice.emptyProperty).toBe(slice.emptyProperty); + }); + + it('should return a different slices object on each call', () => { + // Arrange + // Act + const slices1 = createPropertySelectors(MyState); + const slices2 = createPropertySelectors(MyState); + // Assert + expect(slices1).not.toBe(slices2); + expect(slices1.property1).not.toBe(slices2.property1); + expect(slices1.property2).not.toBe(slices2.property2); + expect(slices1.emptyProperty).not.toBe(slices2.emptyProperty); + }); +});