-
Notifications
You must be signed in to change notification settings - Fork 41
feat: add mutation api #202
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
0affc85
feat: add mutation api
manfredsteyer b106303
refactor: change mutation to object
manfredsteyer b808fcc
feat: use passed injector to obtain destroyRef
manfredsteyer 9014ebf
fix: fix some race conditions for rx-mutate
manfredsteyer 968cdf0
fix(mutations): exhaust-map has to increase the counter even if the v…
manfredsteyer 9941e1d
refactor(mutations): derive mutation status
manfredsteyer bcecc61
fix(mutation): don't reset error signal when operation starts
manfredsteyer 28cb441
feat(mutation): include last (final) result value in mutation-result
manfredsteyer a0620e0
docs(mutations): add example component for mutations
manfredsteyer 712e6ba
docs(mutation): add jsdocs for with-mutation and rx-mutation
manfredsteyer 6f452b3
test(mutations): make tests DRY
manfredsteyer af38d2d
Merge branch 'main' into feat-mutation
manfredsteyer 8873536
fix(demo): remove unused test for mutation demo
manfredsteyer 2dd3e58
feat(mutation): wrap flattening operators so that rx-mutation can fin…
manfredsteyer 4b6fbb6
refactor(mutation): refactor tests
manfredsteyer ac1e5a7
refactor(mutation): expose isProcessing i/o callCount and rename Proc…
manfredsteyer b153f4a
refactor(pagination): put back lost linebreak
manfredsteyer File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
<h1>withMutations</h1> | ||
|
||
<div class="counter">{{ counter() }}</div> | ||
|
||
<ul> | ||
<li>isPending: {{ isPending() }}</li> | ||
<li>Status: {{ status() }}</li> | ||
<li>Error: {{ error() | json }}</li> | ||
</ul> | ||
|
||
<div> | ||
<button (click)="increment()">Increment</button> | ||
</div> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
import { CommonModule } from '@angular/common'; | ||
import { Component, inject } from '@angular/core'; | ||
import { CounterStore } from './counter.store'; | ||
|
||
@Component({ | ||
selector: 'demo-counter-mutation', | ||
imports: [CommonModule], | ||
templateUrl: './counter-mutation.html', | ||
styleUrl: './counter-mutation.css', | ||
}) | ||
export class CounterMutation { | ||
private store = inject(CounterStore); | ||
|
||
protected counter = this.store.counter; | ||
protected error = this.store.incrementError; | ||
protected isPending = this.store.incrementIsPending; | ||
protected status = this.store.incrementStatus; | ||
|
||
increment() { | ||
this.store.increment({ value: 1 }); | ||
} | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,53 @@ | ||
import { | ||
concatOp, | ||
rxMutation, | ||
withMutations, | ||
} from '@angular-architects/ngrx-toolkit'; | ||
import { patchState, signalStore, withState } from '@ngrx/signals'; | ||
import { delay, Observable } from 'rxjs'; | ||
|
||
export type Params = { | ||
value: number; | ||
}; | ||
|
||
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); | ||
}, | ||
}), | ||
})), | ||
); | ||
|
||
let error = false; | ||
|
||
function createSumObservable(a: number, b: number): Observable<number> { | ||
return new Observable<number>((subscriber) => { | ||
const result = a + b; | ||
|
||
if ((result === 7 || result === 13) && !error) { | ||
subscriber.error({ message: 'error due to bad luck!', result }); | ||
error = true; | ||
} else { | ||
subscriber.next(result); | ||
error = false; | ||
} | ||
subscriber.complete(); | ||
}); | ||
} | ||
|
||
function calcSum(a: number, b: number): Observable<number> { | ||
// return of(a + b); | ||
return createSumObservable(a, b).pipe(delay(500)); | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
import { | ||
concatMap, | ||
exhaustMap, | ||
mergeMap, | ||
ObservableInput, | ||
ObservedValueOf, | ||
OperatorFunction, | ||
switchMap, | ||
} from 'rxjs'; | ||
|
||
export type RxJsFlatteningOperator = <T, O extends ObservableInput<unknown>>( | ||
project: (value: T, index: number) => O, | ||
) => OperatorFunction<T, ObservedValueOf<O>>; | ||
|
||
/** | ||
* A wrapper for an RxJS flattening operator. | ||
* This wrapper informs about whether the operator has exhaust semantics or not. | ||
*/ | ||
export type FlatteningOperator = { | ||
rxJsOperator: RxJsFlatteningOperator; | ||
exhaustSemantics: boolean; | ||
}; | ||
|
||
export const switchOp: FlatteningOperator = { | ||
rxJsOperator: switchMap, | ||
exhaustSemantics: false, | ||
}; | ||
|
||
export const mergeOp: FlatteningOperator = { | ||
rxJsOperator: mergeMap, | ||
exhaustSemantics: false, | ||
}; | ||
|
||
export const concatOp: FlatteningOperator = { | ||
rxJsOperator: concatMap, | ||
exhaustSemantics: false, | ||
}; | ||
|
||
export const exhaustOp: FlatteningOperator = { | ||
rxJsOperator: exhaustMap, | ||
exhaustSemantics: true, | ||
}; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,172 @@ | ||
import { computed, DestroyRef, inject, Injector, signal } from '@angular/core'; | ||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; | ||
import { | ||
catchError, | ||
defer, | ||
EMPTY, | ||
finalize, | ||
Observable, | ||
Subject, | ||
tap, | ||
} from 'rxjs'; | ||
|
||
import { concatOp, FlatteningOperator } from './flattening-operator'; | ||
import { Mutation, MutationResult, MutationStatus } from './with-mutations'; | ||
|
||
export type Func<P, R> = (params: P) => R; | ||
|
||
export interface RxMutationOptions<P, R> { | ||
operation: Func<P, Observable<R>>; | ||
onSuccess?: (result: R, params: P) => void; | ||
onError?: (error: unknown, params: P) => void; | ||
operator?: FlatteningOperator; | ||
injector?: Injector; | ||
} | ||
|
||
/** | ||
* Creates a mutation that leverages RxJS. | ||
* | ||
* For each mutation the following options can be defined: | ||
* - `operation`: A function that defines the mutation logic. It returns an Observable. | ||
* - `onSuccess`: A callback that is called when the mutation is successful. | ||
* - `onError`: A callback that is called when the mutation fails. | ||
* - `operator`: An optional wrapper of an RxJS flattening operator. By default `concat` sematics are used. | ||
* - `injector`: An optional Angular injector to use for dependency injection. | ||
* | ||
* The `operation` is the only mandatory option. | ||
* | ||
* ```typescript | ||
* export type Params = { | ||
* value: number; | ||
* }; | ||
* | ||
* 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); | ||
* }, | ||
* }), | ||
* })), | ||
* ); | ||
* | ||
* function calcSum(a: number, b: number): Observable<number> { | ||
* return of(a + b); | ||
* } | ||
* ``` | ||
* | ||
* @param options | ||
* @returns | ||
*/ | ||
export function rxMutation<P, R>( | ||
options: RxMutationOptions<P, R>, | ||
): Mutation<P, R> { | ||
const inputSubject = new Subject<{ | ||
param: P; | ||
resolve: (result: MutationResult<R>) => void; | ||
}>(); | ||
const flatteningOp = options.operator ?? concatOp; | ||
|
||
const destroyRef = options.injector?.get(DestroyRef) ?? inject(DestroyRef); | ||
|
||
const callCount = signal(0); | ||
const errorSignal = signal<unknown>(undefined); | ||
const idle = signal(true); | ||
const isPending = computed(() => callCount() > 0); | ||
|
||
const status = computed<MutationStatus>(() => { | ||
if (idle()) { | ||
return 'idle'; | ||
} | ||
if (callCount() > 0) { | ||
return 'pending'; | ||
} | ||
if (errorSignal()) { | ||
return 'error'; | ||
} | ||
return 'success'; | ||
}); | ||
|
||
const initialInnerStatus: MutationStatus = 'idle'; | ||
let innerStatus: MutationStatus = initialInnerStatus; | ||
let lastResult: R; | ||
|
||
inputSubject | ||
.pipe( | ||
flatteningOp.rxJsOperator((input) => | ||
defer(() => { | ||
callCount.update((c) => c + 1); | ||
idle.set(false); | ||
return options.operation(input.param).pipe( | ||
tap((result: R) => { | ||
options.onSuccess?.(result, input.param); | ||
innerStatus = 'success'; | ||
errorSignal.set(undefined); | ||
lastResult = result; | ||
}), | ||
catchError((error: unknown) => { | ||
options.onError?.(error, input.param); | ||
errorSignal.set(error); | ||
innerStatus = 'error'; | ||
return EMPTY; | ||
}), | ||
finalize(() => { | ||
callCount.update((c) => c - 1); | ||
|
||
if (innerStatus === 'success') { | ||
input.resolve({ | ||
status: 'success', | ||
value: lastResult, | ||
}); | ||
} else if (innerStatus === 'error') { | ||
input.resolve({ | ||
status: 'error', | ||
error: errorSignal(), | ||
}); | ||
} else { | ||
input.resolve({ | ||
status: 'aborted', | ||
}); | ||
} | ||
|
||
innerStatus = initialInnerStatus; | ||
}), | ||
); | ||
}), | ||
), | ||
takeUntilDestroyed(destroyRef), | ||
) | ||
.subscribe(); | ||
|
||
const mutationFn = (param: P) => { | ||
return new Promise<MutationResult<R>>((resolve) => { | ||
if (callCount() > 0 && flatteningOp.exhaustSemantics) { | ||
resolve({ | ||
status: 'aborted', | ||
}); | ||
} else { | ||
inputSubject.next({ | ||
param, | ||
resolve, | ||
}); | ||
} | ||
}); | ||
}; | ||
|
||
const mutation = mutationFn as Mutation<P, R>; | ||
mutation.status = status; | ||
mutation.isPending = isPending; | ||
mutation.error = errorSignal; | ||
|
||
return mutation; | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.