();
+ const resultPromise = increment({
+ value: input$,
+ delay: 1,
+ fail: false,
+ });
+
+ expect(increment.status()).toEqual('pending');
+ expect(increment.isPending()).toEqual(true);
+ expect(increment.isSuccess()).toEqual(false);
+
+ input$.next(1);
+ input$.next(2);
+ input$.next(3);
+ input$.complete();
+
+ await asyncTick();
+
+ const result = await resultPromise;
+
+ expect(result).toEqual({
+ status: 'success',
+ value: 6,
+ });
+
+ expect(testSetup.getCounter()).toEqual(9);
+ expect(testSetup.lastOnSuccessParam()).toMatchObject({
+ result: 6,
+ });
+
+ expect(increment.isPending()).toEqual(false);
+ expect(increment.status()).toEqual('success');
+ expect(increment.isSuccess()).toEqual(true);
+
+ expect(increment.error()).toBeUndefined();
+ });
+
+ it('informs about failed operation via the returned promise', async () => {
+ const testSetup = createTestSetup(switchOp);
+ const increment = testSetup.increment;
+
+ const p1 = increment({ value: 1, delay: 1, fail: false });
+ const p2 = increment({ value: 2, delay: 2, fail: true });
+
+ expect(increment.status()).toEqual('pending');
+ expect(increment.isPending()).toEqual(true);
+ expect(increment.isSuccess()).toEqual(false);
+
+ await asyncTick();
+
+ const result1 = await p1;
+ const result2 = await p2;
+
+ expect(result1.status).toEqual('aborted');
+ expect(result2).toEqual({
+ status: 'error',
+ error: {
+ error: 'Test-Error',
+ },
+ });
+
+ expect(increment.isPending()).toEqual(false);
+ expect(increment.hasValue()).toEqual(false);
+ expect(increment.status()).toEqual('error');
+ expect(increment.isSuccess()).toEqual(false);
+ expect(increment.error()).toEqual({
+ error: 'Test-Error',
+ });
+ });
+
+ it('can be called using an operation function', async () => {
+ const increment = TestBed.runInInjectionContext(() =>
+ rxMutation((value: number) => {
+ return calcDouble(value).pipe(delay(1));
+ }),
+ );
+
+ const resultPromise = increment(2);
+
+ expect(increment.status()).toEqual('pending');
+ expect(increment.isPending()).toEqual(true);
+ expect(increment.isSuccess()).toEqual(false);
+
+ await asyncTick();
+
+ const result = await resultPromise;
+
+ expect(result).toEqual({
+ status: 'success',
+ value: 4,
+ });
+
+ expect(increment.isPending()).toEqual(false);
+ expect(increment.isSuccess()).toEqual(true);
+
+ expect(increment.status()).toEqual('success');
+ expect(increment.error()).toBeUndefined();
+ });
+});
diff --git a/libs/ngrx-toolkit/src/lib/rx-mutation.ts b/libs/ngrx-toolkit/src/lib/mutation/rx-mutation.ts
similarity index 52%
rename from libs/ngrx-toolkit/src/lib/rx-mutation.ts
rename to libs/ngrx-toolkit/src/lib/mutation/rx-mutation.ts
index 55299619..cccf1772 100644
--- a/libs/ngrx-toolkit/src/lib/rx-mutation.ts
+++ b/libs/ngrx-toolkit/src/lib/mutation/rx-mutation.ts
@@ -10,15 +10,15 @@ import {
tap,
} from 'rxjs';
-import { concatOp, FlatteningOperator } from './flattening-operator';
-import { Mutation, MutationResult, MutationStatus } from './with-mutations';
+import { concatOp, FlatteningOperator } from '../flattening-operator';
+import { Mutation, MutationResult, MutationStatus } from './mutation';
-export type Func = (params: P) => R;
+export type Operation = (param: Parameter) => Result;
-export interface RxMutationOptions {
- operation: Func
>;
- onSuccess?: (result: R, params: P) => void;
- onError?: (error: unknown, params: P) => void;
+export interface RxMutationOptions {
+ operation: Operation>;
+ onSuccess?: (result: Result, param: Parameter) => void;
+ onError?: (error: unknown, param: Parameter) => void;
operator?: FlatteningOperator;
injector?: Injector;
}
@@ -35,46 +35,72 @@ export interface RxMutationOptions {
*
* The `operation` is the only mandatory option.
*
+ * The returned mutation can be called as an async function and returns a Promise.
+ * This promise informs about whether the mutation was successful, failed, or aborted
+ * (due to switchMap or exhaustMap semantics).
+ *
+ * The mutation also provides several Signals such as error, status or isPending (see below).
+ *
+ * Example usage without Store:
+ *
* ```typescript
- * export type Params = {
- * value: number;
- * };
+ * const counterSignal = signal(0);
*
- * export const CounterStore = signalStore(
- * { providedIn: 'root' },
- * withState({ counter: 0 }),
- * withMutations((store) => ({
- * increment: rxMutation({
- * operation: (params: Params) => {
- * return calcSum(store.counter(), params.value);
- * },
- * operator: concatOp,
- * onSuccess: (result) => {
- * console.log('result', result);
- * patchState(store, { counter: result });
- * },
- * onError: (error) => {
- * console.error('Error occurred:', error);
- * },
- * }),
- * })),
- * );
+ * const increment = rxMutation({
+ * operation: (param: Param) => {
+ * return calcSum(this.counterSignal(), param.value);
+ * },
+ * operator: concatOp,
+ * onSuccess: (result) => {
+ * this.counterSignal.set(result);
+ * },
+ * onError: (error) => {
+ * console.error('Error occurred:', error);
+ * },
+ * });
+ *
+ * const error = increment.error;
+ * const isPending = increment.isPending;
+ * const status = increment.status;
+ * const value = increment.value;
+ * const hasValue = increment.hasValue;
+ *
+ * async function incrementCounter() {
+ * const result = await increment({ value: 1 });
+ * if (result.status === 'success') {
+ * console.log('Success:', result.value);
+ * }
+ * if (result.status === 'error') {
+ * console.log('Error:', result.error);
+ * }
+ * if (result.status === 'aborted') {
+ * console.log('Operation aborted');
+ * }
+ * }
*
* function calcSum(a: number, b: number): Observable {
- * return of(a + b);
+ * return of(result).pipe(delay(500));
* }
* ```
*
* @param options
- * @returns
+ * @returns the actual mutation function along tracking data as properties/methods
*/
-export function rxMutation(
- options: RxMutationOptions
,
-): Mutation
{
+export function rxMutation(
+ optionsOrOperation:
+ | RxMutationOptions
+ | Operation>,
+): Mutation {
const inputSubject = new Subject<{
- param: P;
- resolve: (result: MutationResult) => void;
+ param: Parameter;
+ resolve: (result: MutationResult) => void;
}>();
+
+ const options =
+ typeof optionsOrOperation === 'function'
+ ? { operation: optionsOrOperation }
+ : optionsOrOperation;
+
const flatteningOp = options.operator ?? concatOp;
const destroyRef = options.injector?.get(DestroyRef) ?? inject(DestroyRef);
@@ -83,6 +109,14 @@ export function rxMutation(
const errorSignal = signal(undefined);
const idle = signal(true);
const isPending = computed(() => callCount() > 0);
+ const value = signal(undefined);
+ const isSuccess = computed(() => !idle() && !isPending() && !errorSignal());
+
+ const hasValue = function (
+ this: Mutation,
+ ): this is Mutation, Result> {
+ return typeof value() !== 'undefined';
+ };
const status = computed(() => {
if (idle()) {
@@ -99,7 +133,6 @@ export function rxMutation(
const initialInnerStatus: MutationStatus = 'idle';
let innerStatus: MutationStatus = initialInnerStatus;
- let lastResult: R;
inputSubject
.pipe(
@@ -108,15 +141,16 @@ export function rxMutation
(
callCount.update((c) => c + 1);
idle.set(false);
return options.operation(input.param).pipe(
- tap((result: R) => {
+ tap((result: Result) => {
options.onSuccess?.(result, input.param);
innerStatus = 'success';
errorSignal.set(undefined);
- lastResult = result;
+ value.set(result);
}),
catchError((error: unknown) => {
options.onError?.(error, input.param);
errorSignal.set(error);
+ value.set(undefined);
innerStatus = 'error';
return EMPTY;
}),
@@ -126,7 +160,7 @@ export function rxMutation
(
if (innerStatus === 'success') {
input.resolve({
status: 'success',
- value: lastResult,
+ value: value() as Result,
});
} else if (innerStatus === 'error') {
input.resolve({
@@ -148,8 +182,8 @@ export function rxMutation
(
)
.subscribe();
- const mutationFn = (param: P) => {
- return new Promise>((resolve) => {
+ const mutationFn = (param: Parameter) => {
+ return new Promise>((resolve) => {
if (callCount() > 0 && flatteningOp.exhaustSemantics) {
resolve({
status: 'aborted',
@@ -163,10 +197,12 @@ export function rxMutation(
});
};
- const mutation = mutationFn as Mutation
;
+ const mutation = mutationFn as Mutation;
mutation.status = status;
mutation.isPending = isPending;
mutation.error = errorSignal;
-
+ mutation.value = value;
+ mutation.hasValue = hasValue;
+ mutation.isSuccess = isSuccess;
return mutation;
}
diff --git a/libs/ngrx-toolkit/src/lib/with-mutations.spec.ts b/libs/ngrx-toolkit/src/lib/with-mutations.spec.ts
index 05e2175a..5bb3a80d 100644
--- a/libs/ngrx-toolkit/src/lib/with-mutations.spec.ts
+++ b/libs/ngrx-toolkit/src/lib/with-mutations.spec.ts
@@ -2,7 +2,7 @@ import { fakeAsync, TestBed, tick } from '@angular/core/testing';
import { patchState, signalStore, withState } from '@ngrx/signals';
import { delay, Observable, of, Subject, switchMap, throwError } from 'rxjs';
import { concatOp, exhaustOp, mergeOp, switchOp } from './flattening-operator';
-import { rxMutation } from './rx-mutation';
+import { rxMutation } from './mutation/rx-mutation';
import { withMutations } from './with-mutations';
type Param =
diff --git a/libs/ngrx-toolkit/src/lib/with-mutations.ts b/libs/ngrx-toolkit/src/lib/with-mutations.ts
index d8e050f3..5922307a 100644
--- a/libs/ngrx-toolkit/src/lib/with-mutations.ts
+++ b/libs/ngrx-toolkit/src/lib/with-mutations.ts
@@ -10,33 +10,12 @@ import {
withMethods,
WritableStateSource,
} from '@ngrx/signals';
-
-export type Mutation = {
- (params: P): Promise>;
- status: Signal;
- isPending: Signal;
- error: Signal;
-};
+import { Mutation, MutationStatus } from './mutation/mutation';
// NamedMutationMethods below will infer the actual parameter and return types
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type MutationsDictionary = Record>;
-export type MutationResult =
- | {
- status: 'success';
- value: T;
- }
- | {
- status: 'error';
- error: unknown;
- }
- | {
- status: 'aborted';
- };
-
-export type MutationStatus = 'idle' | 'pending' | 'error' | 'success';
-
// withMethods uses Record internally
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
export type MethodsDictionary = Record;