Skip to content

Commit

Permalink
feat(signal-slice): add "Updated" signal for action sources (#363)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
joshuamorony authored Jul 17, 2024
1 parent fb1ed2c commit 1700269
Show file tree
Hide file tree
Showing 3 changed files with 84 additions and 2 deletions.
36 changes: 35 additions & 1 deletion docs/src/content/docs/utilities/Signals/signal-slice.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>) => $.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:
Expand All @@ -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
Expand Down
32 changes: 32 additions & 0 deletions libs/ngxtension/signal-slice/src/signal-slice.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,38 @@ describe(signalSlice.name, () => {
});
});

it('should create action updates', () => {
TestBed.runInInjectionContext(() => {
const state = signalSlice({
initialState,
actionSources: {
increaseAge: (state, $: Observable<number>) =>
$.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<number>) =>
$.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({
Expand Down
18 changes: 17 additions & 1 deletion libs/ngxtension/signal-slice/src/signal-slice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TSignalValue, TPayload> = (
Expand Down Expand Up @@ -89,6 +90,13 @@ type ActionStreams<
: never;
};

type ActionUpdates<
TSignalValue,
TActionSources extends NamedActionSources<TSignalValue>,
> = {
[K in keyof TActionSources & string as `${K}Updated`]: Signal<number>;
};

export type Source<TSignalValue> = Observable<PartialOrValue<TSignalValue>>;
type SourceConfig<TSignalValue> = Array<
Source<TSignalValue> | ((state: Signal<TSignalValue>) => Source<TSignalValue>)
Expand All @@ -104,7 +112,8 @@ export type SignalSlice<
ExtraSelectors<TSelectors> &
Effects<TEffects> &
ActionMethods<TSignalValue, TActionSources> &
ActionStreams<TSignalValue, TActionSources>;
ActionStreams<TSignalValue, TActionSources> &
ActionUpdates<TSignalValue, TActionSources>;

type SelectorsState<TSignalValue extends NoOptionalProperties<TSignalValue>> =
Signal<TSignalValue> & Selectors<TSignalValue>;
Expand Down Expand Up @@ -269,20 +278,23 @@ function addReducerProperties(
subs: Subject<unknown>[],
observableFromActionSource?: Observable<any>,
) {
const version = createNotifier();
Object.defineProperties(readonlyState, {
[key]: {
value: (nextValue: unknown) => {
if (isObservable(nextValue)) {
return new Promise((res, rej) => {
nextValue.pipe(takeUntilDestroyed(destroyRef)).subscribe({
next: (value) => {
version.notify();
subject.next(value);
},
error: (err) => {
subject.error(err);
rej(err);
},
complete: () => {
version.notify();
subject.complete();
res(readonlyState());
},
Expand All @@ -300,13 +312,17 @@ function addReducerProperties(
state$.pipe(take(1)).subscribe((val) => {
res(val);
});
version.notify();
subject.next(nextValue);
});
},
},
[`${key}$`]: {
value: subject.asObservable(),
},
[`${key}Updated`]: {
value: version.listen,
},
});
subs.push(subject);
}

0 comments on commit 1700269

Please # to comment.