Skip to content

Commit

Permalink
feat(signal-slice): add asyncReducers (#144)
Browse files Browse the repository at this point in the history
* feat(signal-slice): accept function that returns array of sources

* Revert "feat(signal-slice): accept function that returns array of sources"

This reverts commit 4d16e93.

* feat(signal-slice): add support for source reducers

* fix(signal-slice): differentiate reducer types at run time

* fix(signal-slice): adjust typings for signalSlice with source reducer

* feat(signal-slice): add tests for asyncReducers config

* feat(signal-slice): wip implementation of asyncReducers

* fix(signal-slice): standard reducer connect call

* refactor(signal-slice): create addReducerProperties function

* docs(signal-slice): add docs for asyncReducers

* docs(signal-slice): add missing state param

Co-authored-by: Chau Tran <nartc7789@gmail.com>

---------

Co-authored-by: Chau Tran <nartc7789@gmail.com>
  • Loading branch information
joshuamorony and nartc authored Nov 13, 2023
1 parent df769eb commit 3057f8b
Show file tree
Hide file tree
Showing 3 changed files with 172 additions and 32 deletions.
24 changes: 24 additions & 0 deletions docs/src/content/docs/utilities/Signals/signal-slice.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,30 @@ The associated action can then be triggered with:
this.state.toggleActive();
```

## Async Reducers

A standard reducer accepts a function that updates the state synchronously. It
is also possible to specify `asyncReducers` that return an observable to update
the state asynchronously.

For example:

```ts
state = signalSlice({
initialState: this.initialState,
asyncReducers: {
load: (_state, $: Observable<void>) => $.pipe(
switchMap(() => this.someService.load()),
map(data => ({ someProperty: data })
)
}
})
```
In this particular case, a `load` action will be created that can be called with `this.state.load()`. When this action is called the internal `Subject` will be nexted, and it is this subject that is being supplied as the `$` parameter above. You can then `pipe` onto that `$` to perform whatever asynchronous operations you need, and then at the end you should `map` the result to whatever parts of the state signal you want to update (just like with standard `reducers`).
**NOTE:** This example covers the use case where data _needs_ to be manually triggered with a `load()` action. It is also possible to just have your data load automatically — in this case the observable that loads the data can just be supplied directly through `sources` and it will be loaded automatically without needing to trigger the `load()` action.
## Action Streams
The source/stream for each action is also exposed on the state object. That means that you can access:
Expand Down
46 changes: 43 additions & 3 deletions libs/ngxtension/signal-slice/src/signal-slice.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { TestBed } from '@angular/core/testing';
import { Subject } from 'rxjs';
import { Observable, Subject, of } from 'rxjs';
import { map, switchMap } from 'rxjs/operators';
import { SignalSlice, signalSlice } from './signal-slice';

describe(signalSlice.name, () => {
Expand All @@ -13,7 +14,7 @@ describe(signalSlice.name, () => {
};

describe('initialState', () => {
let state: SignalSlice<typeof initialState, any, any, any>;
let state: SignalSlice<typeof initialState, any, any, any, any>;

beforeEach(() => {
TestBed.runInInjectionContext(() => {
Expand All @@ -36,7 +37,7 @@ describe(signalSlice.name, () => {
const testSource$ = new Subject<Partial<typeof initialState>>();
const testSource2$ = new Subject<Partial<typeof initialState>>();

let state: SignalSlice<typeof initialState, any, any, any>;
let state: SignalSlice<typeof initialState, any, any, any, any>;

beforeEach(() => {
TestBed.runInInjectionContext(() => {
Expand Down Expand Up @@ -108,6 +109,45 @@ describe(signalSlice.name, () => {
});
});

describe('asyncReducers', () => {
it('should create action that updates signal asynchronously', () => {
TestBed.runInInjectionContext(() => {
const testAge = 35;

const state = signalSlice({
initialState,
asyncReducers: {
load: (state, $: Observable<void>) =>
$.pipe(
switchMap(() => of(testAge)),
map((age) => ({ age }))
),
},
});

state.load();
expect(state().age).toEqual(testAge);
});
});

it('should create action stream for reducer', () => {
TestBed.runInInjectionContext(() => {
const state = signalSlice({
initialState,
asyncReducers: {
load: (state, $: Observable<void>) =>
$.pipe(
switchMap(() => of(35)),
map((age) => ({ age }))
),
},
});

expect(state.load$).toBeDefined();
});
});
});

describe('selectors', () => {
it('should add custom selectors to state object', () => {
TestBed.runInInjectionContext(() => {
Expand Down
134 changes: 105 additions & 29 deletions libs/ngxtension/signal-slice/src/signal-slice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,20 @@ import {
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { connect, type PartialOrValue, type Reducer } from 'ngxtension/connect';
import { Observable, Subject, isObservable } from 'rxjs';
import { Subject, isObservable, type Observable } from 'rxjs';

type NamedReducers<TSignalValue> = {
[actionName: string]: Reducer<TSignalValue, any>;
[actionName: string]: (
state: TSignalValue,
value: any
) => PartialOrValue<TSignalValue>;
};

type NamedAsyncReducers<TSignalValue> = {
[actionName: string]: (
state: Signal<TSignalValue>,
value: any
) => Observable<PartialOrValue<TSignalValue>>;
};

type NamedSelectors = {
Expand All @@ -35,63 +45,108 @@ type Effects<TEffects extends NamedEffects> = {
[K in keyof TEffects]: EffectRef;
};

type Action<TValue> = TValue extends void
? () => void
: unknown extends TValue
? () => void
: (value: TValue | Observable<TValue>) => void;

type ActionMethod<
TSignalValue,
TReducer extends NamedReducers<TSignalValue>[string]
> = TReducer extends (state: TSignalValue, value: infer TValue) => any
? TValue extends Observable<infer TObservableValue>
? Action<TObservableValue>
: Action<TValue>
: never;

type AsyncActionMethod<
TSignalValue,
TAsyncReducer extends NamedAsyncReducers<TSignalValue>[string]
> = TAsyncReducer extends (
state: Signal<TSignalValue>,
value: infer TValue
) => any
? TValue extends Observable<infer TObservableValue>
? Action<TObservableValue>
: Action<TValue>
: never;

type ActionMethods<
TSignalValue,
TReducers extends NamedReducers<TSignalValue>
TReducers extends NamedReducers<TSignalValue>,
TAsyncReducers extends NamedAsyncReducers<TSignalValue>
> = {
[K in keyof TReducers]: TReducers[K] extends Reducer<TSignalValue, unknown>
? () => void
: TReducers[K] extends Reducer<TSignalValue, infer TValue>
? (value: TValue | Observable<TValue>) => void
: never;
[K in keyof TReducers]: ActionMethod<TSignalValue, TReducers[K]>;
} & {
[K in keyof TAsyncReducers]: AsyncActionMethod<
TSignalValue,
TAsyncReducers[K]
>;
};

type ActionStreams<
TSignalValue,
TReducers extends NamedReducers<TSignalValue>
TReducers extends NamedReducers<TSignalValue>,
TAsyncReducers extends NamedAsyncReducers<TSignalValue>
> = {
[K in keyof TReducers & string as `${K}$`]: TReducers[K] extends Reducer<
TSignalValue,
unknown
>
? Observable<void>
: TReducers[K] extends Reducer<TSignalValue, infer TValue>
? Observable<TValue>
? TValue extends Observable<any>
? TValue
: Observable<TValue>
: never;
} & {
[K in keyof TAsyncReducers &
string as `${K}$`]: TAsyncReducers[K] extends Reducer<TSignalValue, unknown>
? Observable<void>
: TReducers[K] extends Reducer<TSignalValue, infer TValue>
? TValue extends Observable<any>
? TValue
: Observable<TValue>
: never;
};

export type SignalSlice<
TSignalValue,
TReducers extends NamedReducers<TSignalValue>,
TAsyncReducers extends NamedAsyncReducers<TSignalValue>,
TSelectors extends NamedSelectors,
TEffects extends NamedEffects
> = Signal<TSignalValue> &
Selectors<TSignalValue> &
ExtraSelectors<TSelectors> &
Effects<TEffects> &
ActionMethods<TSignalValue, TReducers> &
ActionStreams<TSignalValue, TReducers>;
ActionMethods<TSignalValue, TReducers, TAsyncReducers> &
ActionStreams<TSignalValue, TReducers, TAsyncReducers>;

export function signalSlice<
TSignalValue,
TReducers extends NamedReducers<TSignalValue>,
TAsyncReducers extends NamedAsyncReducers<TSignalValue>,
TSelectors extends NamedSelectors,
TEffects extends NamedEffects
>(config: {
initialState: TSignalValue;
sources?: Array<Observable<PartialOrValue<TSignalValue>>>;
reducers?: TReducers;
asyncReducers?: TAsyncReducers;
selectors?: (state: Signal<TSignalValue>) => TSelectors;
effects?: (
state: SignalSlice<TSignalValue, TReducers, TSelectors, any>
state: SignalSlice<TSignalValue, TReducers, TAsyncReducers, TSelectors, any>
) => TEffects;
}): SignalSlice<TSignalValue, TReducers, TSelectors, TEffects> {
}): SignalSlice<TSignalValue, TReducers, TAsyncReducers, TSelectors, TEffects> {
const destroyRef = inject(DestroyRef);

const {
initialState,
sources = [],
reducers = {},
asyncReducers = {},
selectors = (() => ({})) as unknown as Exclude<
(typeof config)['selectors'],
undefined
Expand All @@ -113,22 +168,18 @@ export function signalSlice<

for (const [key, reducer] of Object.entries(reducers as TReducers)) {
const subject = new Subject();

connect(state, subject, reducer);
Object.defineProperties(readonlyState, {
[key]: {
value: (nextValue: unknown) => {
if (isObservable(nextValue)) {
nextValue.pipe(takeUntilDestroyed(destroyRef)).subscribe(subject);
} else {
subject.next(nextValue);
}
},
},
[`${key}$`]: {
value: subject.asObservable(),
},
});
subs.push(subject);
addReducerProperties(readonlyState, key, destroyRef, subject, subs);
}

for (const [key, asyncReducer] of Object.entries(
asyncReducers as TAsyncReducers
)) {
const subject = new Subject();
const observable = asyncReducer(readonlyState, subject);
connect(state, observable);
addReducerProperties(readonlyState, key, destroyRef, subject, subs);
}

for (const key in initialState) {
Expand All @@ -146,6 +197,7 @@ export function signalSlice<
const slice = readonlyState as SignalSlice<
TSignalValue,
TReducers,
TAsyncReducers,
TSelectors,
TEffects
>;
Expand All @@ -167,3 +219,27 @@ export function signalSlice<

return slice;
}

function addReducerProperties(
readonlyState: Signal<unknown>,
key: string,
destroyRef: DestroyRef,
subject: Subject<unknown>,
subs: Subject<unknown>[]
) {
Object.defineProperties(readonlyState, {
[key]: {
value: (nextValue: unknown) => {
if (isObservable(nextValue)) {
nextValue.pipe(takeUntilDestroyed(destroyRef)).subscribe(subject);
} else {
subject.next(nextValue);
}
},
},
[`${key}$`]: {
value: subject.asObservable(),
},
});
subs.push(subject);
}

0 comments on commit 3057f8b

Please sign in to comment.