Skip to content

Commit b106303

Browse files
committed
refactor: change mutation to object
1 parent 0affc85 commit b106303

File tree

4 files changed

+178
-61
lines changed

4 files changed

+178
-61
lines changed

libs/ngrx-toolkit/src/lib/mutation.ts

Whitespace-only changes.
Lines changed: 50 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { Injector } from '@angular/core';
1+
import { Injector, signal } from '@angular/core';
22
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
33
import {
44
catchError,
55
concatMap,
6+
finalize,
67
Observable,
78
ObservableInput,
89
ObservedValueOf,
@@ -11,7 +12,8 @@ import {
1112
Subject,
1213
tap,
1314
} from 'rxjs';
14-
import { MutationResult } from './with-mutations';
15+
16+
import { Mutation, MutationResult, MutationStatus } from './with-mutations';
1517

1618
export type Func<P, R> = (params: P) => R;
1719

@@ -29,43 +31,70 @@ export interface RxMutationOptions<P, R> {
2931

3032
export function rxMutation<P, R>(
3133
options: RxMutationOptions<P, R>,
32-
): Func<P, Promise<MutationResult>> {
33-
const mutationSubject = new Subject<P>();
34+
): Mutation<P> {
35+
const inputSubject = new Subject<{
36+
param: P;
37+
resolve: (result: MutationResult) => void;
38+
}>();
3439
const flatten = options.operator ?? concatMap;
3540

3641
// TODO: Use injector
3742

38-
mutationSubject
43+
const status = signal<MutationStatus>('idle');
44+
const callCount = signal(0);
45+
const errorSignal = signal<unknown>(undefined);
46+
47+
inputSubject
3948
.pipe(
40-
flatten((param: P) =>
41-
options.operation(param).pipe(
49+
flatten((input) =>
50+
options.operation(input.param).pipe(
4251
tap((result: R) => {
43-
options.onSuccess?.(result, param);
44-
// TODO: Decrease counter
52+
options.onSuccess?.(result, input.param);
53+
status.set('success');
54+
input.resolve({
55+
status: 'success',
56+
});
4557
}),
4658
catchError((error: unknown) => {
47-
const mutationError =
48-
options.onError?.(error, param) ?? error ?? 'Mutation failed';
49-
// TODO: Decrease counter
50-
// TODO: Set mutationError
51-
console.error('mutation error', mutationError);
59+
options.onError?.(error, input.param);
60+
const mutationError = error ?? 'Mutation failed';
61+
errorSignal.set(mutationError);
62+
status.set('error');
63+
input.resolve({
64+
status: 'error',
65+
error: mutationError,
66+
});
5267
return of(null);
5368
}),
69+
finalize(() => {
70+
callCount.update((c) => c - 1);
71+
if (status() === 'processing') {
72+
input.resolve({
73+
status: 'aborted',
74+
});
75+
}
76+
}),
5477
),
5578
),
5679
takeUntilDestroyed(),
5780
)
5881
.subscribe();
5982

60-
return (param: P) => {
83+
const mutationFn = (param: P) => {
6184
return new Promise<MutationResult>((resolve) => {
62-
// TODO: Increase Counter
63-
mutationSubject.next(param);
64-
65-
// TODO: resolve promise when done
66-
resolve({
67-
status: 'success',
85+
callCount.update((c) => c + 1);
86+
status.set('processing');
87+
inputSubject.next({
88+
param,
89+
resolve,
6890
});
6991
});
7092
};
93+
94+
const mutation = mutationFn as Mutation<P>;
95+
mutation.status = status;
96+
mutation.callCount = callCount;
97+
mutation.error = errorSignal;
98+
99+
return mutation;
71100
}

libs/ngrx-toolkit/src/lib/with-mutations.spec.ts

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,25 @@
11
import { fakeAsync, TestBed, tick } from '@angular/core/testing';
22
import { patchState, signalStore, withState } from '@ngrx/signals';
3-
import { delay, Observable, of, switchMap } from 'rxjs';
3+
import { delay, Observable, of, switchMap, throwError } from 'rxjs';
44
import { rxMutation } from './rx-mutation';
55
import { withMutations } from './with-mutations';
66

7+
async function asyncTick(): Promise<void> {
8+
return new Promise((resolve) => {
9+
setTimeout(() => {
10+
resolve();
11+
}, 1000);
12+
});
13+
}
14+
715
function calcDouble(value: number): Observable<number> {
816
return of(value * 2).pipe(delay(1000));
917
}
1018

19+
function fail(_value: number): Observable<number> {
20+
return throwError(() => ({ error: 'Test-Error' })).pipe(delay(1000));
21+
}
22+
1123
describe('mutation', () => {
1224
it('rxMutation should update the state', fakeAsync(() => {
1325
TestBed.runInInjectionContext(() => {
@@ -27,12 +39,55 @@ describe('mutation', () => {
2739
})),
2840
);
2941
const store = new Store();
42+
43+
expect(store.incrementStatus()).toEqual('idle');
44+
expect(store.incrementProcessing()).toEqual(false);
45+
3046
store.increment(2);
47+
expect(store.incrementStatus()).toEqual('processing');
48+
expect(store.incrementProcessing()).toEqual(true);
49+
3150
tick(2000);
51+
expect(store.incrementStatus()).toEqual('success');
52+
expect(store.incrementProcessing()).toEqual(false);
53+
expect(store.incrementError()).toEqual(undefined);
54+
3255
expect(store.counter()).toEqual(7);
3356
});
3457
}));
3558

59+
it('rxMutation sets error', fakeAsync(() => {
60+
TestBed.runInInjectionContext(() => {
61+
const Store = signalStore(
62+
withState({ counter: 3 }),
63+
withMutations((store) => ({
64+
increment: rxMutation({
65+
operation: (value: number) => {
66+
return fail(value);
67+
},
68+
onSuccess: (result) => {
69+
patchState(store, (state) => ({
70+
counter: state.counter + result,
71+
}));
72+
},
73+
}),
74+
})),
75+
);
76+
const store = new Store();
77+
78+
store.increment(2);
79+
80+
tick(2000);
81+
expect(store.incrementStatus()).toEqual('error');
82+
expect(store.incrementProcessing()).toEqual(false);
83+
expect(store.incrementError()).toEqual({
84+
error: 'Test-Error',
85+
});
86+
87+
expect(store.counter()).toEqual(3);
88+
});
89+
}));
90+
3691
it('rxMutation deals with race conditions', fakeAsync(() => {
3792
let onSuccessCalls = 0;
3893
let onErrorCalls = 0;
@@ -68,10 +123,18 @@ describe('mutation', () => {
68123
const store = new Store();
69124

70125
store.increment(1);
126+
71127
tick(500);
128+
expect(store.incrementStatus()).toEqual('processing');
129+
expect(store.incrementProcessing()).toEqual(true);
130+
72131
store.increment(2);
73132
tick(1000);
74133

134+
expect(store.incrementStatus()).toEqual('success');
135+
expect(store.incrementProcessing()).toEqual(false);
136+
expect(store.incrementError()).toEqual(undefined);
137+
75138
expect(store.counter()).toEqual(7);
76139
expect(onSuccessCalls).toEqual(1);
77140
expect(onErrorCalls).toEqual(0);
@@ -81,4 +144,38 @@ describe('mutation', () => {
81144
});
82145
});
83146
}));
147+
148+
it('rxMutation informs about aborted operations', async () => {
149+
await TestBed.runInInjectionContext(async () => {
150+
const Store = signalStore(
151+
withState({ counter: 3 }),
152+
withMutations((store) => ({
153+
increment: rxMutation({
154+
operation: (value: number) => {
155+
return calcDouble(value);
156+
},
157+
operator: switchMap,
158+
onSuccess: (result, params) => {
159+
patchState(store, (state) => ({
160+
counter: state.counter + result,
161+
}));
162+
},
163+
}),
164+
})),
165+
);
166+
167+
const store = new Store();
168+
169+
const p1 = store.increment(1);
170+
const p2 = store.increment(2);
171+
172+
await asyncTick();
173+
174+
const result1 = await p1;
175+
const result2 = await p2;
176+
177+
expect(result1.status).toEqual('aborted');
178+
expect(result2.status).toEqual('success');
179+
});
180+
});
84181
});

libs/ngrx-toolkit/src/lib/with-mutations.ts

Lines changed: 30 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,29 @@
11
import { computed, Signal } from '@angular/core';
22
import {
3+
EmptyFeatureResult,
34
signalStoreFeature,
45
SignalStoreFeature,
56
SignalStoreFeatureResult,
67
StateSignals,
78
StateSource,
89
withComputed,
910
withMethods,
10-
withState,
1111
WritableStateSource,
1212
} from '@ngrx/signals';
1313

14-
// NamedMutationMethods below will infer the actual parameter and return types
15-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
16-
type MutationsDictionary = Record<string, Mutation<any, any>>;
14+
export type Mutation<P> = {
15+
(params: P): Promise<MutationResult>;
16+
status: Signal<MutationStatus>;
17+
callCount: Signal<number>;
18+
error: Signal<unknown>;
19+
};
1720

18-
export type Mutation<P, R> = (params: P) => Promise<R>;
21+
// NamedMutationMethods below will infer the actual parameter and return types
22+
type MutationsDictionary = Record<string, Mutation<never>>;
1923

2024
export type MutationResult = {
21-
status: 'success' | 'aborted';
25+
status: 'success' | 'aborted' | 'error';
26+
error?: unknown;
2227
};
2328

2429
export type MutationStatus = 'idle' | 'processing' | 'error' | 'success';
@@ -27,32 +32,25 @@ export type MutationStatus = 'idle' | 'processing' | 'error' | 'success';
2732
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
2833
export type MethodsDictionary = Record<string, Function>;
2934

30-
type NamedMutationState<T extends MutationsDictionary> = {
31-
[Prop in keyof T as `_${Prop & string}Count`]: number;
32-
} & {
33-
[Prop in keyof T as `${Prop & string}Status`]: MutationStatus;
34-
} & {
35-
[Prop in keyof T as `${Prop & string}Error`]: Error | undefined;
36-
};
37-
3835
type NamedMutationProps<T extends MutationsDictionary> = {
3936
[Prop in keyof T as `${Prop & string}Processing`]: Signal<boolean>;
37+
} & {
38+
[Prop in keyof T as `${Prop & string}Status`]: Signal<MutationStatus>;
39+
} & {
40+
[Prop in keyof T as `${Prop & string}Error`]: Signal<Error | undefined>;
4041
};
4142

4243
type NamedMutationMethods<T extends MutationsDictionary> = {
43-
[Prop in keyof T as `${Prop & string}`]: T[Prop] extends Mutation<
44-
infer P,
45-
infer R
46-
>
47-
? (p: P) => Promise<R>
44+
[Prop in keyof T as `${Prop & string}`]: T[Prop] extends Mutation<infer P>
45+
? Mutation<P>
4846
: never;
4947
};
5048

51-
export type NamedMutationResult<T extends MutationsDictionary> = {
52-
state: NamedMutationState<T>;
53-
props: NamedMutationProps<T>;
54-
methods: NamedMutationMethods<T>;
55-
};
49+
export type NamedMutationResult<T extends MutationsDictionary> =
50+
EmptyFeatureResult & {
51+
props: NamedMutationProps<T>;
52+
methods: NamedMutationMethods<T>;
53+
};
5654

5755
export function withMutations<
5856
Input extends SignalStoreFeatureResult,
@@ -94,39 +92,32 @@ function createMutationsFeature<Result extends MutationsDictionary>(
9492
) {
9593
const keys = Object.keys(mutations);
9694

97-
const initState = keys.reduce(
98-
(acc, key) => ({
99-
...acc,
100-
[`_${key}Count`]: 0,
101-
[`${key}Status`]: 'idle',
102-
[`${key}Error`]: undefined,
103-
}),
104-
{} as NamedMutationState<Result>,
105-
);
106-
10795
const feature = signalStoreFeature(
108-
withState(initState),
10996
withMethods(() =>
11097
keys.reduce(
11198
(acc, key) => ({
11299
...acc,
113-
[key]: async (params: unknown) => {
100+
[key]: async (params: never) => {
114101
const mutation = mutations[key];
115-
if (!mutation) throw new Error(`Mutation ${key} not found`);
102+
if (!mutation) {
103+
throw new Error(`Mutation ${key} not found`);
104+
}
116105
const result = await mutation(params);
117106
return result;
118107
},
119108
}),
120109
{} as MethodsDictionary,
121110
),
122111
),
123-
withComputed((store) =>
112+
withComputed(() =>
124113
keys.reduce(
125114
(acc, key) => ({
126115
...acc,
127116
[`${key}Processing`]: computed(() => {
128-
return store[`_${key}Count`]() > 0;
117+
return mutations[key].callCount() > 0;
129118
}),
119+
[`${key}Status`]: mutations[key].status,
120+
[`${key}Error`]: mutations[key].error,
130121
}),
131122
{} as NamedMutationProps<Result>,
132123
),

0 commit comments

Comments
 (0)