From 412f160c438bec040051ea8043a226d53f8eb7a0 Mon Sep 17 00:00:00 2001 From: arturovt Date: Fri, 13 Sep 2024 17:29:56 +0300 Subject: [PATCH] feat(store): allow extending `State({...})` instead of decorating --- packages/store/src/decorators/state.ts | 52 +++++++++++++++++-- .../src/internal/lifecycle-state-manager.ts | 2 +- .../src/internal/state-context-factory.ts | 12 ++--- packages/store/src/internal/state-factory.ts | 10 +++- packages/store/tests/state-extends.spec.ts | 40 ++++++++++++++ 5 files changed, 103 insertions(+), 13 deletions(-) create mode 100644 packages/store/tests/state-extends.spec.ts diff --git a/packages/store/src/decorators/state.ts b/packages/store/src/decorators/state.ts index bfc42fd16..4dcbc60a0 100644 --- a/packages/store/src/decorators/state.ts +++ b/packages/store/src/decorators/state.ts @@ -1,14 +1,18 @@ +import { inject } from '@angular/core'; import { - ɵStateClass, ɵMETA_KEY, ɵMETA_OPTIONS_KEY, ɵMetaDataModel, + ɵStateClass, ɵStateClassInternal, ɵStoreOptions, ɵensureStoreMetadata } from '@ngxs/store/internals'; +import { Observable } from 'rxjs'; +import { StateOperator } from '../symbols'; import { ensureStateNameIsValid } from '../utils/store-validators'; +import { StateContextFactory } from '../internal/state-context-factory'; interface MutateMetaOptions { meta: ɵMetaDataModel; @@ -16,12 +20,25 @@ interface MutateMetaOptions { optionsWithInheritance: ɵStoreOptions; } +interface BaseState { + getState(): T; + setState(value: T): void; + patchState(value: Partial): void; + dispatch(actions: any | any[]): Observable; +} + +interface StateDecorator { + (target: ɵStateClass): void; + new (): BaseState; +} + /** * Decorates a class with ngxs state information. */ -export function State(options: ɵStoreOptions) { - return (target: ɵStateClass): void => { - const stateClass: ɵStateClassInternal = target; +export function State(options: ɵStoreOptions): StateDecorator { + return function (this: BaseState) { + // eslint-disable-next-line prefer-rest-params + const stateClass = (new.target || arguments[0]) as unknown as ɵStateClassInternal; const meta: ɵMetaDataModel = ɵensureStoreMetadata(stateClass); const inheritedStateClass: ɵStateClassInternal = Object.getPrototypeOf(stateClass); const optionsWithInheritance: ɵStoreOptions = getStateOptions( @@ -30,7 +47,32 @@ export function State(options: ɵStoreOptions) { ); mutateMetaData({ meta, inheritedStateClass, optionsWithInheritance }); stateClass[ɵMETA_OPTIONS_KEY] = optionsWithInheritance; - }; + + // If this function is being called as a constructor function. + if (new.target) { + const stateContextFactory = inject(StateContextFactory); + + this.getState = () => { + const ctx = stateContextFactory.createStateContext(meta.path!); + return ctx.getState(); + }; + + this.setState = (value: T | StateOperator) => { + const ctx = stateContextFactory.createStateContext(meta.path!); + ctx.setState(value); + }; + + this.patchState = (value: Partial) => { + const ctx = stateContextFactory.createStateContext(meta.path!); + ctx.patchState(value); + }; + + this.dispatch = actions => { + const ctx = stateContextFactory.createStateContext(meta.path!); + return ctx.dispatch(actions); + }; + } + } as unknown as StateDecorator; } function getStateOptions( diff --git a/packages/store/src/internal/lifecycle-state-manager.ts b/packages/store/src/internal/lifecycle-state-manager.ts index e3a09d15c..e1a27b303 100644 --- a/packages/store/src/internal/lifecycle-state-manager.ts +++ b/packages/store/src/internal/lifecycle-state-manager.ts @@ -99,6 +99,6 @@ export class LifecycleStateManager implements OnDestroy { } private _getStateContext(mappedStore: MappedStore): StateContext { - return this._stateContextFactory.createStateContext(mappedStore); + return this._stateContextFactory.createStateContext(mappedStore.path); } } diff --git a/packages/store/src/internal/state-context-factory.ts b/packages/store/src/internal/state-context-factory.ts index 2b18c4f2a..38ec2bac6 100644 --- a/packages/store/src/internal/state-context-factory.ts +++ b/packages/store/src/internal/state-context-factory.ts @@ -4,7 +4,7 @@ import { ExistingState, StateOperator, isStateOperator } from '@ngxs/store/opera import { Observable } from 'rxjs'; import { StateContext } from '../symbols'; -import { MappedStore, StateOperations } from '../internal/internals'; +import { StateOperations } from '../internal/internals'; import { InternalStateOperations } from '../internal/state-operations'; import { simplePatch } from './state-operators'; @@ -19,25 +19,25 @@ export class StateContextFactory { /** * Create the state context */ - createStateContext(mappedStore: MappedStore): StateContext { + createStateContext(path: string): StateContext { const root = this._internalStateOperations.getRootStateOperations(); return { getState(): T { const currentAppState = root.getState(); - return getState(currentAppState, mappedStore.path); + return getState(currentAppState, path); }, patchState(val: Partial): void { const currentAppState = root.getState(); const patchOperator = simplePatch(val); - setStateFromOperator(root, currentAppState, patchOperator, mappedStore.path); + setStateFromOperator(root, currentAppState, patchOperator, path); }, setState(val: T | StateOperator): void { const currentAppState = root.getState(); if (isStateOperator(val)) { - setStateFromOperator(root, currentAppState, val, mappedStore.path); + setStateFromOperator(root, currentAppState, val, path); } else { - setStateValue(root, currentAppState, val, mappedStore.path); + setStateValue(root, currentAppState, val, path); } }, dispatch(actions: any | any[]): Observable { diff --git a/packages/store/src/internal/state-factory.ts b/packages/store/src/internal/state-factory.ts index 9875c4d3a..493c271ea 100644 --- a/packages/store/src/internal/state-factory.ts +++ b/packages/store/src/internal/state-factory.ts @@ -182,6 +182,14 @@ export class StateFactory implements OnDestroy { ensureStatesAreDecorated(stateClasses); } + // Just playing around with this because the metadata is not + // set until the constructor function is called (if `State` is used + // as a factory function rather than as a decorator, which returns + // a base state). + for (const stateClass of stateClasses) { + this._injector.get(stateClass); + } + const { newStates } = this.addToStatesMap(stateClasses); if (!newStates.length) return []; @@ -295,7 +303,7 @@ export class StateFactory implements OnDestroy { if (actionMetas) { for (const actionMeta of actionMetas) { - const stateContext = this._stateContextFactory.createStateContext(metadata); + const stateContext = this._stateContextFactory.createStateContext(metadata.path); try { let result = metadata.instance[actionMeta.fn](stateContext, action); diff --git a/packages/store/tests/state-extends.spec.ts b/packages/store/tests/state-extends.spec.ts new file mode 100644 index 000000000..0c7f8f687 --- /dev/null +++ b/packages/store/tests/state-extends.spec.ts @@ -0,0 +1,40 @@ +import { Component, Injectable } from '@angular/core'; +import { bootstrapApplication } from '@angular/platform-browser'; +import { Action, InitState, NgxsOnInit, provideStore, State, Store } from '@ngxs/store'; +import { freshPlatform } from '@ngxs/store/internals/testing'; + +describe('State decorator returns a constructor function', () => { + @Component({ selector: 'app-root', template: '', standalone: true }) + class TestComponent {} + + it( + 'should call an InitState action handler before the ngxsOnInit method on root module initialisation', + freshPlatform(async () => { + // Arrange + @Injectable() + class FooState + extends State({ name: 'foo', defaults: [] }) + implements NgxsOnInit + { + ngxsOnInit() { + this.setState([...this.getState(), 'onInit']); + } + + @Action(InitState) + initState() { + this.setState([...this.getState(), 'initState']); + } + } + + // Act + const { injector } = await bootstrapApplication(TestComponent, { + providers: [provideStore([FooState])] + }); + + const store = injector.get(Store); + + // Assert + expect(store.snapshot().foo).toEqual(['initState', 'onInit']); + }) + ); +});