From 17002692934b23ba6a818e8055ea4bb76cee3ed1 Mon Sep 17 00:00:00 2001 From: Josh Morony Date: Wed, 17 Jul 2024 20:47:57 +0930 Subject: [PATCH] feat(signal-slice): add "Updated" signal for action sources (#363) * feat(signal-slice): add updated signal for action sources * feat(signal-slice): docs for updated signals * docs(signal-slice): add experimental warning for action updates * refactor(signal-slice): use createNotifier for version signal --- .../docs/utilities/Signals/signal-slice.md | 36 ++++++++++++++++++- .../signal-slice/src/signal-slice.spec.ts | 32 +++++++++++++++++ .../signal-slice/src/signal-slice.ts | 18 +++++++++- 3 files changed, 84 insertions(+), 2 deletions(-) diff --git a/docs/src/content/docs/utilities/Signals/signal-slice.md b/docs/src/content/docs/utilities/Signals/signal-slice.md index 36ba0483..e1ae1360 100644 --- a/docs/src/content/docs/utilities/Signals/signal-slice.md +++ b/docs/src/content/docs/utilities/Signals/signal-slice.md @@ -169,6 +169,40 @@ this subject as an `actionSource` allows you to still trigger it through can be accessed on the state object, even if you need to create an external subject. +## Action Updates + +:::caution Action Updates are currently experimental, the API may be changed or removed entirely. Please feel free to reach out to joshuamorony with feedback or open an issue. ::: + +Each `actionSource` will have an equivalent `Updated` version signal automatically generated that will be incremented each time the `actionSource` emits or completes, e.g: + +```ts + state = signalSlice({ + initialState: this.initialState, + actionSources: { + load: (_state, $: Observable) => $.pipe( + switchMap(() => this.someService.load()), + map(data => ({ someProperty: data }) + ) + } + }) + + effect(() => { + // triggered when `load` emits/completes and on init + console.log(state.loadUpdated()) + }) +``` + +This signal will return the current version, starting at `0`. If you do not want your `effect` to be triggered on initialisation you can check for the `0` version value, e.g: + +```ts +effect(() => { + if (state.loadUpdated()) { + // triggered ONLY when `load` emits/completes + // NOT on init + } +}); +``` + ## Action Streams The source/stream for each action is also exposed on the state object. That means that you can access: @@ -177,7 +211,7 @@ The source/stream for each action is also exposed on the state object. That mean this.state.add$; ``` -Which will allow you to react to the `add` action being called. +Which will allow you to react to the `add` action being called via an observable. ## Selectors diff --git a/libs/ngxtension/signal-slice/src/signal-slice.spec.ts b/libs/ngxtension/signal-slice/src/signal-slice.spec.ts index f39251bd..184a0137 100644 --- a/libs/ngxtension/signal-slice/src/signal-slice.spec.ts +++ b/libs/ngxtension/signal-slice/src/signal-slice.spec.ts @@ -176,6 +176,38 @@ describe(signalSlice.name, () => { }); }); + it('should create action updates', () => { + TestBed.runInInjectionContext(() => { + const state = signalSlice({ + initialState, + actionSources: { + increaseAge: (state, $: Observable) => + $.pipe(map((amount) => ({ age: state().age + amount }))), + }, + }); + + expect(state.increaseAgeUpdated).toBeDefined(); + }); + }); + + it('should increment updated signal every time source emits', () => { + TestBed.runInInjectionContext(() => { + const state = signalSlice({ + initialState, + actionSources: { + increaseAge: (state, $: Observable) => + $.pipe(map((amount) => ({ age: state().age + amount }))), + }, + }); + + expect(state.increaseAgeUpdated()).toEqual(0); + state.increaseAge(1); + expect(state.increaseAgeUpdated()).toEqual(1); + state.increaseAge(1); + expect(state.increaseAgeUpdated()).toEqual(2); + }); + }); + it('should resolve the updated state as a promise after reducer is invoked', (done) => { TestBed.runInInjectionContext(() => { const state = signalSlice({ diff --git a/libs/ngxtension/signal-slice/src/signal-slice.ts b/libs/ngxtension/signal-slice/src/signal-slice.ts index 6725492f..f910a6c3 100644 --- a/libs/ngxtension/signal-slice/src/signal-slice.ts +++ b/libs/ngxtension/signal-slice/src/signal-slice.ts @@ -11,6 +11,7 @@ import { } from '@angular/core'; import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; import { connect, type PartialOrValue, type Reducer } from 'ngxtension/connect'; +import { createNotifier } from 'ngxtension/create-notifier'; import { Subject, isObservable, share, take, type Observable } from 'rxjs'; type ActionSourceFn = ( @@ -89,6 +90,13 @@ type ActionStreams< : never; }; +type ActionUpdates< + TSignalValue, + TActionSources extends NamedActionSources, +> = { + [K in keyof TActionSources & string as `${K}Updated`]: Signal; +}; + export type Source = Observable>; type SourceConfig = Array< Source | ((state: Signal) => Source) @@ -104,7 +112,8 @@ export type SignalSlice< ExtraSelectors & Effects & ActionMethods & - ActionStreams; + ActionStreams & + ActionUpdates; type SelectorsState> = Signal & Selectors; @@ -269,6 +278,7 @@ function addReducerProperties( subs: Subject[], observableFromActionSource?: Observable, ) { + const version = createNotifier(); Object.defineProperties(readonlyState, { [key]: { value: (nextValue: unknown) => { @@ -276,6 +286,7 @@ function addReducerProperties( return new Promise((res, rej) => { nextValue.pipe(takeUntilDestroyed(destroyRef)).subscribe({ next: (value) => { + version.notify(); subject.next(value); }, error: (err) => { @@ -283,6 +294,7 @@ function addReducerProperties( rej(err); }, complete: () => { + version.notify(); subject.complete(); res(readonlyState()); }, @@ -300,6 +312,7 @@ function addReducerProperties( state$.pipe(take(1)).subscribe((val) => { res(val); }); + version.notify(); subject.next(nextValue); }); }, @@ -307,6 +320,9 @@ function addReducerProperties( [`${key}$`]: { value: subject.asObservable(), }, + [`${key}Updated`]: { + value: version.listen, + }, }); subs.push(subject); }