From c7497216df4f04c7512f013eef7caac31d96e164 Mon Sep 17 00:00:00 2001 From: Brandon Roberts Date: Tue, 11 Apr 2023 21:55:59 -0500 Subject: [PATCH 1/3] feat(router-store): add @nrwl/angular data persistence operators --- .../router-store/data-persistence/index.ts | 1 + .../data-persistence/ng-package.json | 1 + .../data-persistence/src/operators.ts | 455 ++++++++++++++++++ .../data-persistence/src/public_api.ts | 1 + .../transforms/angular-api-package/index.js | 1 + 5 files changed, 459 insertions(+) create mode 100644 modules/router-store/data-persistence/index.ts create mode 100644 modules/router-store/data-persistence/ng-package.json create mode 100644 modules/router-store/data-persistence/src/operators.ts create mode 100644 modules/router-store/data-persistence/src/public_api.ts diff --git a/modules/router-store/data-persistence/index.ts b/modules/router-store/data-persistence/index.ts new file mode 100644 index 0000000000..9aa37d2ff5 --- /dev/null +++ b/modules/router-store/data-persistence/index.ts @@ -0,0 +1 @@ +export * from './src/operators'; diff --git a/modules/router-store/data-persistence/ng-package.json b/modules/router-store/data-persistence/ng-package.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/modules/router-store/data-persistence/ng-package.json @@ -0,0 +1 @@ +{} diff --git a/modules/router-store/data-persistence/src/operators.ts b/modules/router-store/data-persistence/src/operators.ts new file mode 100644 index 0000000000..a307d4595e --- /dev/null +++ b/modules/router-store/data-persistence/src/operators.ts @@ -0,0 +1,455 @@ +import type { Type } from '@angular/core'; +import type { + ActivatedRouteSnapshot, + RouterStateSnapshot, +} from '@angular/router'; +import type { RouterNavigationAction } from '@ngrx/router-store'; +import { ROUTER_NAVIGATION } from '@ngrx/router-store'; +import type { Action } from '@ngrx/store'; +import type { Observable } from 'rxjs'; +import { isObservable, of } from 'rxjs'; +import { + catchError, + concatMap, + filter, + groupBy, + map, + mergeMap, + switchMap, +} from 'rxjs/operators'; + +export interface PessimisticUpdateOpts, A> { + run(a: A, ...slices: [...T]): Observable | Action | void; + onError(a: A, e: any): Observable | any; +} + +export interface OptimisticUpdateOpts, A> { + run(a: A, ...slices: [...T]): Observable | Action | void; + undoAction(a: A, e: any): Observable | Action; +} + +export interface FetchOpts, A> { + id?(a: A, ...slices: [...T]): any; + run(a: A, ...slices: [...T]): Observable | Action | void; + onError?(a: A, e: any): Observable | any; +} + +export interface HandleNavigationOpts> { + run( + a: ActivatedRouteSnapshot, + ...slices: [...T] + ): Observable | Action | void; + onError?(a: ActivatedRouteSnapshot, e: any): Observable | any; +} + +export type ActionOrActionWithStates, A> = + | A + | [A, ...T]; +export type ActionOrActionWithState = ActionOrActionWithStates<[T], A>; +export type ActionStatesStream, A> = Observable< + ActionOrActionWithStates +>; +export type ActionStateStream = Observable< + ActionOrActionWithStates<[T], A> +>; + +/** + * @description + * Handles pessimistic updates (updating the server first). + * + * Updating the server, when implemented naively, suffers from race conditions and poor error handling. + * + * `pessimisticUpdate` addresses these problems. It runs all fetches in order, which removes race conditions + * and forces the developer to handle errors. + * + * @usageNotes + * + * ```typescript + * @Injectable() + * class TodoEffects { + * updateTodo$ = createEffect(() => + * this.actions$.pipe( + * ofType('UPDATE_TODO'), + * pessimisticUpdate({ + * // provides an action + * run: (action: UpdateTodo) => { + * // update the backend first, and then dispatch an action that will + * // update the client side + * return this.backend.updateTodo(action.todo.id, action.todo).pipe( + * map((updated) => ({ + * type: 'UPDATE_TODO_SUCCESS', + * todo: updated, + * })) + * ); + * }, + * onError: (action: UpdateTodo, error: any) => { + * // we don't need to undo the changes on the client side. + * // we can dispatch an error, or simply log the error here and return `null` + * return null; + * }, + * }) + * ) + * ); + * + * constructor(private actions$: Actions, private backend: Backend) {} + * } + * ``` + * + * Note that if you don't return a new action from the run callback, you must set the dispatch property + * of the effect to false, like this: + * + * ```typescript + * class TodoEffects { + * updateTodo$ = createEffect(() => + * this.actions$.pipe( + * //... + * ), { dispatch: false } + * ); + * } + * ``` + * + * @param opts + */ +export function pessimisticUpdate, A extends Action>( + opts: PessimisticUpdateOpts +) { + return (source: ActionStatesStream): Observable => { + return source.pipe( + mapActionAndState(), + concatMap(runWithErrorHandling(opts.run, opts.onError)) + ); + }; +} + +/** + * + * @description + * + * Handles optimistic updates (updating the client first). + * + * It runs all fetches in order, which removes race conditions and forces the developer to handle errors. + * + * When using `optimisticUpdate`, in case of a failure, the developer has already updated the state locally, + * so the developer must provide an undo action. + * + * The error handling must be done in the callback, or by means of the undo action. + * + * @usageNotes + * + * ```typescript + * @Injectable() + * class TodoEffects { + * updateTodo$ = createEffect(() => + * this.actions$.pipe( + * ofType('UPDATE_TODO'), + * optimisticUpdate({ + * // provides an action + * run: (action: UpdateTodo) => { + * return this.backend.updateTodo(action.todo.id, action.todo).pipe( + * mapTo({ + * type: 'UPDATE_TODO_SUCCESS', + * }) + * ); + * }, + * undoAction: (action: UpdateTodo, error: any) => { + * // dispatch an undo action to undo the changes in the client state + * return { + * type: 'UNDO_TODO_UPDATE', + * todo: action.todo, + * }; + * }, + * }) + * ) + * ); + * + * constructor(private actions$: Actions, private backend: Backend) {} + * } + * ``` + * + * Note that if you don't return a new action from the run callback, you must set the dispatch property + * of the effect to false, like this: + * + * ```typescript + * class TodoEffects { + * updateTodo$ = createEffect(() => + * this.actions$.pipe( + * //... + * ), { dispatch: false } + * ); + * } + * ``` + * + * @param opts + */ +export function optimisticUpdate, A extends Action>( + opts: OptimisticUpdateOpts +) { + return (source: ActionStatesStream): Observable => { + return source.pipe( + mapActionAndState(), + concatMap(runWithErrorHandling(opts.run, opts.undoAction)) + ); + }; +} + +/** + * + * @description + * + * Handles data fetching. + * + * Data fetching implemented naively suffers from race conditions and poor error handling. + * + * `fetch` addresses these problems. It runs all fetches in order, which removes race conditions + * and forces the developer to handle errors. + * + * @usageNotes + * + * ```typescript + * @Injectable() + * class TodoEffects { + * loadTodos$ = createEffect(() => + * this.actions$.pipe( + * ofType('GET_TODOS'), + * fetch({ + * // provides an action + * run: (a: GetTodos) => { + * return this.backend.getAll().pipe( + * map((response) => ({ + * type: 'TODOS', + * todos: response.todos, + * })) + * ); + * }, + * onError: (action: GetTodos, error: any) => { + * // dispatch an undo action to undo the changes in the client state + * return null; + * }, + * }) + * ) + * ); + * + * constructor(private actions$: Actions, private backend: Backend) {} + * } + * ``` + * + * This is correct, but because it set the concurrency to 1, it may not be performant. + * + * To fix that, you can provide the `id` function, like this: + * + * ```typescript + * @Injectable() + * class TodoEffects { + * loadTodo$ = createEffect(() => + * this.actions$.pipe( + * ofType('GET_TODO'), + * fetch({ + * id: (todo: GetTodo) => { + * return todo.id; + * }, + * // provides an action + * run: (todo: GetTodo) => { + * return this.backend.getTodo(todo.id).map((response) => ({ + * type: 'LOAD_TODO_SUCCESS', + * todo: response.todo, + * })); + * }, + * onError: (action: GetTodo, error: any) => { + * // dispatch an undo action to undo the changes in the client state + * return null; + * }, + * }) + * ) + * ); + * + * constructor(private actions$: Actions, private backend: Backend) {} + * } + * ``` + * + * With this setup, the requests for Todo 1 will run concurrently with the requests for Todo 2. + * + * In addition, if there are multiple requests for Todo 1 scheduled, it will only run the last one. + * + * @param opts + */ +export function fetch, A extends Action>( + opts: FetchOpts +) { + return (source: ActionStatesStream): Observable => { + if (opts.id) { + const groupedFetches = source.pipe( + mapActionAndState(), + groupBy(([action, ...store]) => { + return opts.id(action, ...store); + }) + ); + + return groupedFetches.pipe( + mergeMap((pairs) => + pairs.pipe(switchMap(runWithErrorHandling(opts.run, opts.onError))) + ) + ); + } + + return source.pipe( + mapActionAndState(), + concatMap(runWithErrorHandling(opts.run, opts.onError)) + ); + }; +} + +/** + * @description + * + * Handles data fetching as part of router navigation. + * + * Data fetching implemented naively suffers from race conditions and poor error handling. + * + * `navigation` addresses these problems. + * + * It checks if an activated router state contains the passed in component type, and, if it does, runs the `run` + * callback. It provides the activated snapshot associated with the component and the current state. And it only runs + * the last request. + * + * @usageNotes + * + * ```typescript + * @Injectable() + * class TodoEffects { + * loadTodo$ = createEffect(() => + * this.actions$.pipe( + * // listens for the routerNavigation action from @ngrx/router-store + * navigation(TodoComponent, { + * run: (activatedRouteSnapshot: ActivatedRouteSnapshot) => { + * return this.backend + * .fetchTodo(activatedRouteSnapshot.params['id']) + * .pipe( + * map((todo) => ({ + * type: 'LOAD_TODO_SUCCESS', + * todo: todo, + * })) + * ); + * }, + * onError: ( + * activatedRouteSnapshot: ActivatedRouteSnapshot, + * error: any + * ) => { + * // we can log and error here and return null + * // we can also navigate back + * return null; + * }, + * }) + * ) + * ); + * + * constructor(private actions$: Actions, private backend: Backend) {} + * } + * ``` + * + * @param component + * @param opts + */ +export function navigation, A extends Action>( + component: Type, + opts: HandleNavigationOpts +) { + return (source: ActionStatesStream) => { + const nav = source.pipe( + mapActionAndState(), + filter(([action]) => isStateSnapshot(action)), + map(([action, ...slices]) => { + if (!isStateSnapshot(action)) { + // Because of the above filter we'll never get here, + // but this properly type narrows `action` + // @ts-ignore + return; + } + + return [ + findSnapshot(component, action.payload.routerState.root), + ...slices, + ] as [ActivatedRouteSnapshot, ...T]; + }), + filter(([snapshot]) => !!snapshot) + ); + + return nav.pipe(switchMap(runWithErrorHandling(opts.run, opts.onError))); + }; +} + +function isStateSnapshot( + action: any +): action is RouterNavigationAction { + return action.type === ROUTER_NAVIGATION; +} + +function runWithErrorHandling, A, R>( + run: (a: A, ...slices: [...T]) => Observable | R | void, + onError: any +) { + return ([action, ...slices]: [A, ...T]): Observable => { + try { + const r = wrapIntoObservable(run(action, ...slices)); + return r.pipe(catchError((e) => wrapIntoObservable(onError(action, e)))); + } catch (e) { + return wrapIntoObservable(onError(action, e)); + } + }; +} + +/** + * @whatItDoes maps Observable to + * Observable<[Action, State]> + */ +function mapActionAndState, A>() { + return (source: Observable>) => { + return source.pipe( + map((value) => normalizeActionAndState(value) as [A, ...T]) + ); + }; +} + +/** + * @whatItDoes Normalizes either a bare action or an array of action and slices + * into an array of action and slices (or undefined) + */ +function normalizeActionAndState, A>( + args: ActionOrActionWithStates +): [A, ...T] { + let action: A, slices: T; + + if (args instanceof Array) { + [action, ...slices] = args; + } else { + slices = [] as T; + action = args; + } + + return [action, ...slices]; +} + +function findSnapshot( + component: Type, + s: ActivatedRouteSnapshot +): ActivatedRouteSnapshot { + if (s.routeConfig && s.routeConfig.component === component) { + return s; + } + for (const c of s.children) { + const ss = findSnapshot(component, c); + if (ss) { + return ss; + } + } + return null; +} + +function wrapIntoObservable(obj: Observable | O | void): Observable { + if (isObservable(obj)) { + return obj; + } else if (!obj) { + return of(); + } else { + return of(obj as O); + } +} diff --git a/modules/router-store/data-persistence/src/public_api.ts b/modules/router-store/data-persistence/src/public_api.ts new file mode 100644 index 0000000000..4e26b60bc8 --- /dev/null +++ b/modules/router-store/data-persistence/src/public_api.ts @@ -0,0 +1 @@ +export * from './operators'; diff --git a/projects/ngrx.io/tools/transforms/angular-api-package/index.js b/projects/ngrx.io/tools/transforms/angular-api-package/index.js index ef22826283..31123880b5 100644 --- a/projects/ngrx.io/tools/transforms/angular-api-package/index.js +++ b/projects/ngrx.io/tools/transforms/angular-api-package/index.js @@ -84,6 +84,7 @@ module.exports = new Package('angular-api', [basePackage, typeScriptPackage]) 'effects/testing/index.ts', 'entity/index.ts', 'router-store/index.ts', + 'router-store/data-persistence/index.ts', 'data/index.ts', 'schematics/index.ts', 'component-store/index.ts', From 0b4f0236c8333e5bcb23cc4561d1eebd1eec8bd5 Mon Sep 17 00:00:00 2001 From: Brandon Roberts Date: Wed, 12 Apr 2023 15:58:48 -0500 Subject: [PATCH 2/3] chore: fix linter errors --- modules/router-store/.eslintrc.json | 8 +++++++- .../router-store/data-persistence/tsconfig.build.json | 11 +++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 modules/router-store/data-persistence/tsconfig.build.json diff --git a/modules/router-store/.eslintrc.json b/modules/router-store/.eslintrc.json index a175bf3d5f..feb82a873b 100644 --- a/modules/router-store/.eslintrc.json +++ b/modules/router-store/.eslintrc.json @@ -1,6 +1,12 @@ { "extends": ["../../.eslintrc.json"], - "ignorePatterns": ["!**/*", "schematics-core"], + "ignorePatterns": [ + "!**/*", + "schematics-core", + "data-persistence/src/public_api.ts", + "data-persistence/src/operators.ts", + "data-persistence/index.ts" + ], "overrides": [ { "files": ["*.ts"], diff --git a/modules/router-store/data-persistence/tsconfig.build.json b/modules/router-store/data-persistence/tsconfig.build.json new file mode 100644 index 0000000000..b6b0bfe344 --- /dev/null +++ b/modules/router-store/data-persistence/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.build", + "compilerOptions": { + "paths": { + "@ngrx/store": ["../../dist/packages/store"], + "@ngrx/effects": ["../../dist/packages/effects"] + } + }, + "files": ["index.ts"], + "angularCompilerOptions": {} +} From abe2163c4eaaff2a23b512e49fbff008392b512e Mon Sep 17 00:00:00 2001 From: Brandon Roberts Date: Wed, 12 Apr 2023 20:23:35 -0500 Subject: [PATCH 3/3] chore: review fixes --- modules/router-store/data-persistence/index.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/modules/router-store/data-persistence/index.ts b/modules/router-store/data-persistence/index.ts index 9aa37d2ff5..e1ee19145d 100644 --- a/modules/router-store/data-persistence/index.ts +++ b/modules/router-store/data-persistence/index.ts @@ -1 +1,6 @@ -export * from './src/operators'; +export { + fetch, + navigation, + optimisticUpdate, + pessimisticUpdate, +} from './src/operators';