diff --git a/modules/operators/spec/map-response.spec.ts b/modules/operators/spec/map-response.spec.ts new file mode 100644 index 0000000000..f43e4ffb11 --- /dev/null +++ b/modules/operators/spec/map-response.spec.ts @@ -0,0 +1,77 @@ +import { noop, Observable, of, throwError } from 'rxjs'; +import { mapResponse } from '..'; +import { concatMap, finalize } from 'rxjs/operators'; + +describe('mapResponse', () => { + it('should map the emitted value using the next callback', () => { + const results: number[] = []; + + of(1, 2, 3) + .pipe( + mapResponse({ + next: (value) => value + 1, + error: noop, + }) + ) + .subscribe((result) => { + results.push(result as number); + }); + + expect(results).toEqual([2, 3, 4]); + }); + + it('should map the thrown error using the error callback', (done) => { + throwError(() => 'error') + .pipe( + mapResponse({ + next: noop, + error: (error) => `mapped ${error}`, + }) + ) + .subscribe((result) => { + expect(result).toBe('mapped error'); + done(); + }); + }); + + it('should map the error thrown in next callback using error callback', (done) => { + function producesError() { + throw 'error'; + } + + of(1) + .pipe( + mapResponse({ + next: producesError, + error: (error) => `mapped ${error}`, + }) + ) + .subscribe((result) => { + expect(result).toBe('mapped error'); + done(); + }); + }); + + it('should not unsubscribe from outer observable on inner observable error', () => { + const innerCompleteCallback = jest.fn(); + const outerCompleteCallback = jest.fn(); + + new Observable((subscriber) => subscriber.next(1)) + .pipe( + concatMap(() => + throwError(() => 'error').pipe( + mapResponse({ + next: noop, + error: noop, + }), + finalize(innerCompleteCallback) + ) + ), + finalize(outerCompleteCallback) + ) + .subscribe(); + + expect(innerCompleteCallback).toHaveBeenCalled(); + expect(outerCompleteCallback).not.toHaveBeenCalled(); + }); +}); diff --git a/modules/operators/src/index.ts b/modules/operators/src/index.ts index d4f9ce0ff7..97cf92f04e 100644 --- a/modules/operators/src/index.ts +++ b/modules/operators/src/index.ts @@ -1,2 +1,3 @@ export { concatLatestFrom } from './concat_latest_from'; +export { mapResponse } from './map-response'; export { tapResponse } from './tap-response'; diff --git a/modules/operators/src/map-response.ts b/modules/operators/src/map-response.ts new file mode 100644 index 0000000000..5e07f68a08 --- /dev/null +++ b/modules/operators/src/map-response.ts @@ -0,0 +1,17 @@ +import { Observable, of } from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; + +type MapResponseObserver = { + next: (value: T) => R1; + error: (error: E) => R2; +}; + +export function mapResponse( + observer: MapResponseObserver +): (source$: Observable) => Observable { + return (source$) => + source$.pipe( + map((value) => observer.next(value)), + catchError((error) => of(observer.error(error))) + ); +} diff --git a/projects/ngrx.io/content/guide/operators/operators.md b/projects/ngrx.io/content/guide/operators/operators.md index ceb90ff437..cfb41d91c9 100644 --- a/projects/ngrx.io/content/guide/operators/operators.md +++ b/projects/ngrx.io/content/guide/operators/operators.md @@ -90,3 +90,29 @@ There is also another signature of the `tapResponse` operator that accepts the o ); }); + +## mapResponse + +The `mapResponse` operator is particularly useful in scenarios where you need to transform data and handle potential errors with minimal boilerplate. + +In the example below, we use `mapResponse` within an NgRx effect to handle loading movies from an API. It demonstrates how to map successful API responses to an action indicating success, and how to handle errors by dispatching an error action. + + + export const loadMovies = createEffect( + (actions$ = inject(Actions), moviesService = inject(MoviesService)) => { + return actions$.pipe( + ofType(MoviesPageActions.opened), + exhaustMap(() => + moviesService.getAll().pipe( + mapResponse({ + next: (movies) => MoviesApiActions.moviesLoadedSuccess({ movies }), + error: (error: { message: string }) => + MoviesApiActions.moviesLoadedFailure({ errorMsg: error.message }), + }) + ) + ) + ); + }, + { functional: true } + ); +