diff --git a/apps/demo/src/app/app.component.html b/apps/demo/src/app/app.component.html index 16e00bb3..a18faa3d 100644 --- a/apps/demo/src/app/app.component.html +++ b/apps/demo/src/app/app.component.html @@ -27,6 +27,7 @@ withFeatureFactory withConditional withMutation + rxMutation (without Store) diff --git a/apps/demo/src/app/counter-rx-mutation/counter-rx-mutation.css b/apps/demo/src/app/counter-rx-mutation/counter-rx-mutation.css new file mode 100644 index 00000000..e69de29b diff --git a/apps/demo/src/app/counter-rx-mutation/counter-rx-mutation.html b/apps/demo/src/app/counter-rx-mutation/counter-rx-mutation.html new file mode 100644 index 00000000..ce6a4b82 --- /dev/null +++ b/apps/demo/src/app/counter-rx-mutation/counter-rx-mutation.html @@ -0,0 +1,20 @@ +

rxMutation (without Store)

+ +
{{ counter() }}
+ + + +
+ + +
diff --git a/apps/demo/src/app/counter-rx-mutation/counter-rx-mutation.ts b/apps/demo/src/app/counter-rx-mutation/counter-rx-mutation.ts new file mode 100644 index 00000000..1b25a92c --- /dev/null +++ b/apps/demo/src/app/counter-rx-mutation/counter-rx-mutation.ts @@ -0,0 +1,69 @@ +import { concatOp, rxMutation } from '@angular-architects/ngrx-toolkit'; +import { CommonModule } from '@angular/common'; +import { Component, signal } from '@angular/core'; +import { delay, Observable, of, throwError } from 'rxjs'; + +export type Params = { + value: number; +}; + +@Component({ + selector: 'demo-counter-rx-mutation', + imports: [CommonModule], + templateUrl: './counter-rx-mutation.html', + styleUrl: './counter-rx-mutation.css', +}) +export class CounterRxMutation { + private counterSignal = signal(0); + + private increment = rxMutation({ + operation: (params: Params) => { + return calcSum(this.counterSignal(), params.value); + }, + operator: concatOp, + onSuccess: (result) => { + this.counterSignal.set(result); + }, + onError: (error) => { + console.error('Error occurred:', error); + }, + }); + + // Expose signals for template + protected counter = this.counterSignal.asReadonly(); + protected error = this.increment.error; + protected isPending = this.increment.isPending; + protected status = this.increment.status; + protected value = this.increment.value; + protected hasValue = this.increment.hasValue; + + async incrementCounter() { + const result = await this.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'); + } + } + + async incrementBy13() { + await this.increment({ value: 13 }); + } +} + +function calcSum(a: number, b: number): Observable { + const result = a + b; + if (b === 13) { + return throwError(() => ({ + message: 'error due to bad luck!', + a, + b, + result, + })); + } + return of(result).pipe(delay(500)); +} diff --git a/apps/demo/src/app/lazy-routes.ts b/apps/demo/src/app/lazy-routes.ts index d0ed7598..ba4c66e5 100644 --- a/apps/demo/src/app/lazy-routes.ts +++ b/apps/demo/src/app/lazy-routes.ts @@ -68,4 +68,11 @@ export const lazyRoutes: Route[] = [ (m) => m.CounterMutation, ), }, + { + path: 'rx-mutation', + loadComponent: () => + import('./counter-rx-mutation/counter-rx-mutation').then( + (m) => m.CounterRxMutation, + ), + }, ]; diff --git a/libs/ngrx-toolkit/src/index.ts b/libs/ngrx-toolkit/src/index.ts index 62c93eed..bae1b6d0 100644 --- a/libs/ngrx-toolkit/src/index.ts +++ b/libs/ngrx-toolkit/src/index.ts @@ -42,7 +42,7 @@ export { export { emptyFeature, withConditional } from './lib/with-conditional'; export { withFeatureFactory } from './lib/with-feature-factory'; -export * from './lib/rx-mutation'; +export * from './lib/mutation/rx-mutation'; export * from './lib/with-mutations'; export { mapToResource, withResource } from './lib/with-resource'; @@ -52,3 +52,5 @@ export { mergeOp, switchOp, } from './lib/flattening-operator'; + +export { rxMutation } from './lib/mutation/rx-mutation'; diff --git a/libs/ngrx-toolkit/src/lib/mutation/mutation.ts b/libs/ngrx-toolkit/src/lib/mutation/mutation.ts new file mode 100644 index 00000000..cb73a9a1 --- /dev/null +++ b/libs/ngrx-toolkit/src/lib/mutation/mutation.ts @@ -0,0 +1,26 @@ +import { Signal } from '@angular/core'; + +export type MutationResult = + | { + status: 'success'; + value: Result; + } + | { + status: 'error'; + error: unknown; + } + | { + status: 'aborted'; + }; + +export type MutationStatus = 'idle' | 'pending' | 'error' | 'success'; + +export type Mutation = { + (params: Parameter): Promise>; + status: Signal; + value: Signal; + isPending: Signal; + isSuccess: Signal; + error: Signal; + hasValue(): this is Mutation, Result>; +}; diff --git a/libs/ngrx-toolkit/src/lib/mutation/rx-mutation.spec.ts b/libs/ngrx-toolkit/src/lib/mutation/rx-mutation.spec.ts new file mode 100644 index 00000000..2a9e093b --- /dev/null +++ b/libs/ngrx-toolkit/src/lib/mutation/rx-mutation.spec.ts @@ -0,0 +1,594 @@ +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { delay, Observable, of, Subject, switchMap, throwError } from 'rxjs'; +import { concatOp, exhaustOp, mergeOp, switchOp } from '../flattening-operator'; +import { rxMutation } from './rx-mutation'; + +type Param = + | number + | { + value: number | Observable; + delay?: number; + fail?: boolean; + }; + +type NormalizedParam = { + value: number | Observable; + delay: number; + fail: boolean; +}; + +async function asyncTick(): Promise { + return new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, 0); + }); +} + +function calcDouble(value: number, delayInMsec = 1000): Observable { + return of(value * 2).pipe(delay(delayInMsec)); +} + +function fail(_value: number, delayInMsec = 1000): Observable { + return of(null).pipe( + delay(delayInMsec), + switchMap(() => throwError(() => ({ error: 'Test-Error' }))), + ); +} + +function createTestSetup(flatteningOperator = concatOp) { + function normalizeParam(param: Param): NormalizedParam { + if (typeof param === 'number') { + return { + value: param, + delay: 1000, + fail: false, + }; + } + + return { + value: param.value, + delay: param.delay ?? 1000, + fail: param.fail ?? false, + }; + } + + type SuccessParam = { result: number; param: Param }; + type ErrorParam = { error: unknown; param: Param }; + + let onSuccessCalls = 0; + let onErrorCalls = 0; + let counter = 3; + + let lastOnSuccessParam: SuccessParam | undefined = undefined; + let lastOnErrorParam: ErrorParam | undefined = undefined; + + return TestBed.runInInjectionContext(() => { + const increment = rxMutation({ + operation: (param: Param) => { + const normalized = normalizeParam(param); + + if (normalized.value instanceof Observable) { + return normalized.value.pipe( + switchMap((value) => { + if (normalized.fail) { + return fail(value, normalized.delay); + } + return calcDouble(value, normalized.delay); + }), + ); + } + + if (normalized.fail) { + return fail(normalized.value, normalized.delay); + } + return calcDouble(normalized.value, normalized.delay); + }, + operator: flatteningOperator, + onSuccess: (result, param) => { + lastOnSuccessParam = { result, param: param }; + onSuccessCalls++; + counter = counter + result; + }, + onError: (error, param) => { + lastOnErrorParam = { error, param: param }; + onErrorCalls++; + }, + }); + + return { + increment, + getCounter: () => counter, + onSuccessCalls: () => onSuccessCalls, + onErrorCalls: () => onErrorCalls, + lastOnSuccessParam: () => lastOnSuccessParam, + lastOnErrorParam: () => lastOnErrorParam, + }; + }); +} + +describe('rxMutation', () => { + it('should update the state', fakeAsync(() => { + const testSetup = createTestSetup(); + const increment = testSetup.increment; + + expect(increment.status()).toEqual('idle'); + expect(increment.isPending()).toEqual(false); + + increment(2); + expect(increment.status()).toEqual('pending'); + expect(increment.isPending()).toEqual(true); + + tick(2000); + expect(increment.status()).toEqual('success'); + expect(increment.isPending()).toEqual(false); + expect(increment.isSuccess()).toEqual(true); + expect(increment.error()).toEqual(undefined); + + expect(testSetup.getCounter()).toEqual(7); + })); + + it('sets error', fakeAsync(() => { + const testSetup = createTestSetup(); + const increment = testSetup.increment; + + increment({ value: 2, fail: true }); + + tick(2000); + expect(increment.status()).toEqual('error'); + expect(increment.isPending()).toEqual(false); + expect(increment.isSuccess()).toEqual(false); + expect(increment.error()).toEqual({ + error: 'Test-Error', + }); + + expect(testSetup.getCounter()).toEqual(3); + })); + + it('starts two concurrent operations using concatMap: the first one fails and the second one succeeds', fakeAsync(() => { + const testSetup = createTestSetup(concatOp); + const increment = testSetup.increment; + + increment({ value: 1, delay: 100, fail: true }); + increment({ value: 2, delay: 200, fail: false }); + + tick(100); + + expect(increment.status()).toEqual('pending'); + expect(increment.isPending()).toEqual(true); + expect(increment.error()).toEqual({ + error: 'Test-Error', + }); + + tick(200); + + expect(increment.status()).toEqual('success'); + expect(increment.isPending()).toEqual(false); + expect(increment.error()).toEqual(undefined); + + expect(testSetup.getCounter()).toEqual(7); + })); + + it('starts two concurrent operations using mergeMap: the first one fails and the second one succeeds', fakeAsync(() => { + const testSetup = createTestSetup(mergeOp); + const increment = testSetup.increment; + + increment({ value: 1, delay: 100, fail: true }); + increment({ value: 2, delay: 200, fail: false }); + + tick(100); + + expect(increment.status()).toEqual('pending'); + expect(increment.isPending()).toEqual(true); + expect(increment.isSuccess()).toEqual(false); + + expect(increment.error()).toEqual({ + error: 'Test-Error', + }); + + tick(100); + + expect(increment.status()).toEqual('success'); + expect(increment.isPending()).toEqual(false); + expect(increment.isSuccess()).toEqual(true); + + expect(increment.error()).toEqual(undefined); + + expect(testSetup.getCounter()).toEqual(7); + })); + + it('deals with race conditions using switchMap', fakeAsync(() => { + const testSetup = createTestSetup(switchOp); + const increment = testSetup.increment; + + increment(1); + + tick(500); + expect(increment.status()).toEqual('pending'); + expect(increment.isPending()).toEqual(true); + + increment(2); + tick(1000); + + expect(increment.status()).toEqual('success'); + expect(increment.isPending()).toEqual(false); + expect(increment.error()).toEqual(undefined); + expect(increment.isSuccess()).toEqual(true); + + expect(testSetup.getCounter()).toEqual(7); + expect(testSetup.onSuccessCalls()).toEqual(1); + expect(testSetup.onErrorCalls()).toEqual(0); + + expect(testSetup.lastOnSuccessParam()).toEqual({ + param: 2, + result: 4, + }); + })); + + it('deals with race conditions using mergeMap', fakeAsync(() => { + const testSetup = createTestSetup(mergeOp); + const increment = testSetup.increment; + + increment(1); + tick(500); + increment(2); + tick(500); + + expect(increment.status()).toEqual('pending'); + expect(increment.isPending()).toEqual(true); + expect(increment.isSuccess()).toEqual(false); + + // expect(testSetup.getCounter()).toEqual(7); + expect(testSetup.onSuccessCalls()).toEqual(1); + expect(testSetup.onErrorCalls()).toEqual(0); + expect(testSetup.lastOnSuccessParam()).toEqual({ + param: 1, + result: 2, + }); + + tick(500); + + expect(increment.status()).toEqual('success'); + expect(increment.isPending()).toEqual(false); + expect(increment.error()).toEqual(undefined); + expect(increment.isSuccess()).toEqual(true); + + expect(testSetup.getCounter()).toEqual(9); + expect(testSetup.onSuccessCalls()).toEqual(2); + expect(testSetup.onErrorCalls()).toEqual(0); + expect(testSetup.lastOnSuccessParam()).toEqual({ + param: 2, + result: 4, + }); + })); + + it('deals with race conditions using mergeMap where the 2nd task starts after and finishes before the 1st one', fakeAsync(() => { + const testSetup = createTestSetup(mergeOp); + const increment = testSetup.increment; + + increment({ value: 1, delay: 1000 }); + tick(500); + + expect(increment.status()).toEqual('pending'); + expect(increment.isPending()).toEqual(true); + expect(increment.isSuccess()).toEqual(false); + + increment({ value: 2, delay: 100 }); + tick(500); + + expect(increment.status()).toEqual('success'); + expect(increment.isPending()).toEqual(false); + expect(increment.error()).toEqual(undefined); + expect(increment.isSuccess()).toEqual(true); + + expect(testSetup.getCounter()).toEqual(9); + expect(testSetup.onSuccessCalls()).toEqual(2); + expect(testSetup.onErrorCalls()).toEqual(0); + expect(testSetup.lastOnSuccessParam()).toEqual({ + param: { value: 1, delay: 1000 }, + result: 2, + }); + })); + + it('deals with race conditions using concatMap', fakeAsync(() => { + const testSetup = createTestSetup(concatOp); + const increment = testSetup.increment; + + increment({ value: 1, delay: 1000 }); + tick(500); + increment({ value: 2, delay: 100 }); + tick(500); + + expect(increment.status()).toEqual('pending'); + expect(increment.isPending()).toEqual(true); + expect(increment.isSuccess()).toEqual(false); + + expect(testSetup.getCounter()).toEqual(5); + expect(testSetup.onSuccessCalls()).toEqual(1); + expect(testSetup.onErrorCalls()).toEqual(0); + expect(testSetup.lastOnSuccessParam()).toEqual({ + param: { value: 1, delay: 1000 }, + result: 2, + }); + + tick(500); + + expect(increment.status()).toEqual('success'); + expect(increment.isPending()).toEqual(false); + expect(increment.error()).toEqual(undefined); + expect(increment.isSuccess()).toEqual(true); + + expect(testSetup.getCounter()).toEqual(9); + expect(testSetup.onSuccessCalls()).toEqual(2); + expect(testSetup.onErrorCalls()).toEqual(0); + expect(testSetup.lastOnSuccessParam()).toEqual({ + param: { value: 2, delay: 100 }, + result: 4, + }); + })); + + it('deals with race conditions using exhaustMap', fakeAsync(() => { + const testSetup = createTestSetup(exhaustOp); + const increment = testSetup.increment; + + increment({ value: 1, delay: 1000 }); + tick(500); + + expect(increment.status()).toEqual('pending'); + expect(increment.isPending()).toEqual(true); + expect(increment.isSuccess()).toEqual(false); + + increment({ value: 2, delay: 100 }); + tick(500); + + expect(increment.status()).toEqual('success'); + expect(increment.isPending()).toEqual(false); + expect(increment.error()).toEqual(undefined); + expect(increment.isSuccess()).toEqual(true); + + expect(testSetup.getCounter()).toEqual(5); + expect(testSetup.onSuccessCalls()).toEqual(1); + expect(testSetup.onErrorCalls()).toEqual(0); + expect(testSetup.lastOnSuccessParam()).toEqual({ + param: { value: 1, delay: 1000 }, + result: 2, + }); + + tick(500); + + expect(increment.status()).toEqual('success'); + expect(increment.isPending()).toEqual(false); + expect(increment.error()).toEqual(undefined); + expect(increment.isSuccess()).toEqual(true); + + expect(testSetup.getCounter()).toEqual(5); + expect(testSetup.onSuccessCalls()).toEqual(1); + expect(testSetup.onErrorCalls()).toEqual(0); + expect(testSetup.lastOnSuccessParam()).toEqual({ + param: { value: 1, delay: 1000 }, + result: 2, + }); + })); + + 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.status()).toEqual('error'); + expect(increment.isSuccess()).toEqual(false); + + expect(increment.error()).toEqual({ + error: 'Test-Error', + }); + }); + + it('informs about successful operation via the returned promise', async () => { + const testSetup = createTestSetup(); + const increment = testSetup.increment; + + const resultPromise = increment({ value: 2, delay: 2, fail: false }); + + 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(); + }); + + it('informs about aborted operation when using switchMap', 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: false }); + + 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: 'success', + value: 4, + }); + + expect(increment.isPending()).toEqual(false); + expect(increment.status()).toEqual('success'); + expect(increment.isSuccess()).toEqual(true); + + expect(increment.value()).toEqual(4); + expect(increment.hasValue()).toEqual(true); + expect(increment.error()).toBeUndefined(); + }); + + it('informs about aborted operation when using exhaustMap', async () => { + const testSetup = createTestSetup(exhaustOp); + const increment = testSetup.increment; + + const p1 = increment({ value: 1, delay: 1, fail: false }); + const p2 = increment({ value: 2, delay: 1, fail: false }); + + 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).toEqual({ + status: 'success', + value: 2, + }); + + expect(result2.status).toEqual('aborted'); + + expect(increment.isPending()).toEqual(false); + expect(increment.status()).toEqual('success'); + expect(increment.isSuccess()).toEqual(true); + expect(increment.error()).toBeUndefined(); + }); + + it('calls success handler per value in the stream and returns the final value via the promise', async () => { + const testSetup = createTestSetup(switchOp); + const increment = testSetup.increment; + + const input$ = new Subject(); + 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;