From 010a5185f7200f0d30e75fc0cbe51b6fd24f4a10 Mon Sep 17 00:00:00 2001 From: vsavkin Date: Wed, 23 Aug 2017 18:13:08 -0400 Subject: [PATCH] feat: add utilities for data persistence --- README.md | 99 ++++++ karma.conf.js | 59 ++++ package.json | 29 +- scripts/test.sh | 3 + src/index.ts | 1 + src/utils/data-persistence.ts | 232 ++++++++++++ test/test.ts | 31 ++ test/utils/data-persistence.spec.ts | 523 ++++++++++++++++++++++++++++ tsconfig.json | 2 + 9 files changed, 976 insertions(+), 3 deletions(-) create mode 100644 karma.conf.js create mode 100755 scripts/test.sh create mode 100644 src/utils/data-persistence.ts create mode 100644 test/test.ts create mode 100644 test/utils/data-persistence.spec.ts diff --git a/README.md b/README.md index e0531b0c5c..e621b5d681 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ NX (Nrwl Extensions) is a set of libraries and schematics for the Angular framework. + + ## Installing Add the following dependencies to your project's `package.json` and run `npm install`: @@ -65,6 +67,103 @@ Add `--skipImport` to generate files without adding imports to the module. +## Data Persistence + +Nrwl Extensions come with utilities to simplify data persistence (data fetching, optimistic and pessimistic updates). + +### Optimistic Updates + +```typescript +class TodoEffects { + @Effect() updateTodo = this.s.optimisticUpdate('UPDATE_TODO', { + // provides an action and the current state of the store + run(a: UpdateTodo, state: TodosState) { + return this.backend(state.user, a.payload); + }, + + undoAction(a: UpdateTodo, e: any): Action { + // dispatch an undo action to undo the changes in the client state + return ({ + type: 'UNDO_UPDATE_TODO', + payload: a + }); + } + }); + + constructor(private s: DataPersistence, private backend: Backend) {} +} +``` + +### Pessimistic Updates + +```typescript +@Injectable() +class TodoEffects { + @Effect() updateTodo = this.s.pessimisticUpdate('UPDATE_TODO', { + // provides an action and the current state of the store + run(a: UpdateTodo, state: TodosState) { + // update the backend first, and then dispatch an action that will + // update the client side + return this.backend(state.user, a.payload).map(updated => ({ + type: 'TODO_UPDATED', + payload: updated + })); + }, + onError(a: UpdateTodo, e: 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 s: DataPersistence, private backend: Backend) {} +} +``` + +### Date Fetching + +```typescript +@Injectable() +class TodoEffects { + @Effect() loadTodo = this.s.fetch('GET_TODOS', { + // provides an action and the current state of the store + run(a: GetTodos, state: TodosState) { + return this.backend(state.user, a.payload).map(r => ({ + type: 'TODOS', + payload: r + }); + }, + onError(a: GetTodos, e: any): Action { + // dispatch an undo action to undo the changes in the client state + // return null; + } + }); + constructor(private s: DataPersistence, private backend: Backend) {} +} +``` + +### Date Fetching On Router Navigation + +```typescript +@Injectable() +class TodoEffects { + @Effect() loadTodo = this.s.navigation(TodoComponent, { + run: (a: ActivatedRouteSnapshot, state: TodosState) => { + return this.backend.fetchTodo(a.params['id']).map(todo => ({ + type: 'TODO_LOADED', + payload: todo + })); + }, + onError: (a: ActivatedRouteSnapshot, e: any) => { + // we can log and error here and return null + // we can also navigate back + return null; + } + }); + constructor(private s: DataPersistence, private backend: Backend) {} +} +``` + + ## Testing Nrwl Extensions come with utilities to simplify testing Angular applications. See `app.effects.spec.ts`. Read https://github.com/vsavkin/testing_ngrx_effects for more information. diff --git a/karma.conf.js b/karma.conf.js new file mode 100644 index 0000000000..62fa62c7bc --- /dev/null +++ b/karma.conf.js @@ -0,0 +1,59 @@ +module.exports = function(config) { + const webpackConfig = {}; + config.set({ + basePath: '.', + + // frameworks to use + // available frameworks: https://npmjs.org/browse/keyword/karma-adapter + frameworks: ['jasmine'], + + // list of files / patterns to load in the browser + files: [ + { pattern: 'build/test/test.js', watched: false} + ], + + // list of files to exclude + exclude: [], + + // preprocess matching files before serving them to the browser + // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor + preprocessors: { + 'build/test/test.js': ['webpack'] + }, + + reporters: ['dots'], + + webpack: webpackConfig, + + webpackMiddleware: { + stats: 'errors-only' + }, + + plugins: [ + require('karma-jasmine'), + require('karma-chrome-launcher'), + require('karma-webpack') + ], + + // web server port + port: 9876, + + // enable / disable colors in the output (reporters and logs) + colors: true, + + // level of logging + logLevel:config.LOG_INFO, + + // enable / disable watching file and executing tests whenever any file changes + autoWatch: true, + + // start these browsers + // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher + // browsers: ['PhantomJS'], + browsers: ['Chrome'], + + // Concurrency level + // how many browser should be started simultaneous + concurrency: Infinity + }); +}; diff --git a/package.json b/package.json index baeba64aa1..c8c941d3f6 100644 --- a/package.json +++ b/package.json @@ -7,23 +7,46 @@ "build": "./scripts/build.sh", "e2e": "yarn build && ./scripts/e2e.sh", "package": "./scripts/package.sh", - "release": "./scripts/release.sh" + "release": "./scripts/release.sh", + "test": "yarn build && ./scripts/test.sh" }, "dependencies" :{ "jasmine-marbles": "0.1.0" }, "peerDependencies" :{ "@angular-devkit/schematics": "*", - "rxjs": "*" + "rxjs": ">5.4.2", + "@angular/core": ">4.0.0", + "@angular/common": ">4.0.0", + "@angular/router": ">4.0.0", + "@angular/platform-browser": ">4.0.0", + "@ngrx/store": ">4.0.0", + "@ngrx/router-store": ">4.0.0", + "@ngrx/effects": ">4.0.0" }, + "devDependencies": { "rxjs": "5.4.3", + "@angular/core": "4.3.5", + "@angular/common": "4.3.5", + "@angular/platform-browser": "4.3.5", + "@angular/compiler": "4.3.5", + "@angular/platform-browser-dynamic": "4.3.5", + "@angular/router": "4.3.5", + "@ngrx/store": "4.0.3", + "@ngrx/router-store": "4.0.3", + "@ngrx/effects": "4.0.3", "typescript": "2.4.2", "@types/node": "8.0.7", "@types/jasmine": "2.5.53", "jest": "20.0.4", "@angular-devkit/schematics": "0.0.17", - "@schematics/angular": "git+https://github.com/Brocco/zzz-ng-schematics.git" + "@schematics/angular": "git+https://github.com/Brocco/zzz-ng-schematics.git", + "jasmine-core": "~2.6.2", + "karma": "~1.7.0", + "karma-chrome-launcher": "~2.1.1", + "karma-jasmine": "~1.1.0", + "karma-webpack": "2.0.4" }, "author": "Victor Savkin", "license": "MIT", diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000000..ff5d165628 --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +karma start --single-run diff --git a/src/index.ts b/src/index.ts index e69de29bb2..9f7c951503 100644 --- a/src/index.ts +++ b/src/index.ts @@ -0,0 +1 @@ +export { DataPersistence } from './utils/data-persistence'; diff --git a/src/utils/data-persistence.ts b/src/utils/data-persistence.ts new file mode 100644 index 0000000000..6afce3f137 --- /dev/null +++ b/src/utils/data-persistence.ts @@ -0,0 +1,232 @@ +import {Actions} from '@ngrx/effects'; +import {Action, State, Store} from '@ngrx/store'; +import {Observable} from 'rxjs/Observable'; +import {ActivatedRouteSnapshot, RouterStateSnapshot} from '@angular/router'; +import {ROUTER_NAVIGATION, RouterNavigationAction} from '@ngrx/router-store'; +import {Injectable, Type} from '@angular/core'; +import {filter} from 'rxjs/operator/filter'; +import {withLatestFrom} from 'rxjs/operator/withLatestFrom'; +import {switchMap} from 'rxjs/operator/switchMap'; +import {_catch} from 'rxjs/operator/catch'; +import {concatMap} from 'rxjs/operator/concatMap'; +import {map} from 'rxjs/operator/map'; +import {of} from 'rxjs/observable/of'; + +/** + * See DataPersistence.pessimisticUpdate for more information. + */ +export interface PessimisticUpdateOpts { + run(a: Action, state?: any): Observable | Action | void; + onError(a: Action, e: any): Observable | any; +} +/** + * See DataPersistence.pessimisticUpdate for more information. + */ +export interface OptimisticUpdateOpts { + run(a: Action, state?: any): Observable | any; + undoAction(a: Action, e: any): Observable | Action; +} + +/** + * See DataPersistence.navigation for more information. + */ +export interface FetchOpts { + run(a: Action, state?: any): Observable | Action | void; + onError?(a: Action, e: any): Observable | any; +} + +/** + * See DataPersistence.navigation for more information. + */ +export interface HandleNavigationOpts { + run(a: ActivatedRouteSnapshot, state?: any): Observable | Action | void; + onError?(a: ActivatedRouteSnapshot, e: any): Observable | any; +} + +/** + * Provides convenience methods for implementing common NgRx/Router workflows + * + * * `navigation` handles fetching data when handling router navigation. + * * `pessimisticUpdate` handles updating the server before or after the client has been updated. + */ +@Injectable() +export class DataPersistence { + constructor(public store: Store, public actions: Actions) {} + + /** + * + * Handles pessimistic updates (updating the server first). + * + * Example: + * + * ``` + * @Injectable() + * class TodoEffects { + * @Effect() updateTodo = this.s.pessimisticUpdate('UPDATE_TODO', { + * // provides an action and the current state of the store + * run(a: UpdateTodo, state: TodosState) { + * // update the backend first, and then dispatch an action that will + * // update the client side + * return this.backend(state.user, a.payload).map(updated => ({ + * type: 'TODO_UPDATED', + * payload: updated + * })); + * }, + * + * onError(a: UpdateTodo, e: 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 s: DataPersistence, private backend: Backend) {} + * } + * ``` + * + */ + pessimisticUpdate(actionType: string, opts: PessimisticUpdateOpts): Observable { + const nav = this.actions.ofType(actionType); + const pairs = withLatestFrom.call(nav, this.store); + return concatMap.call(pairs, this.runWithErrorHandling(opts.run, opts.onError)); + } + + /** + * + * Handles optimistic updates (updating the client first). + * + * Example: + * + * ``` + * @Injectable() + * class TodoEffects { + * @Effect() updateTodo = this.s.optimisticUpdate('UPDATE_TODO', { + * // provides an action and the current state of the store + * run(a: UpdateTodo, state: TodosState) { + * return this.backend(state.user, a.payload); + * }, + * + * undoAction(a: UpdateTodo, e: any): Action { + * // dispatch an undo action to undo the changes in the client state + * return ({ + * type: 'UNDO_UPDATE_TODO', + * payload: a + * }); + * } + * }); + * + * constructor(private s: DataPersistence, private backend: Backend) {} + * } + * ``` + */ + optimisticUpdate(actionType: string, opts: OptimisticUpdateOpts): Observable { + const nav = this.actions.ofType(actionType); + const pairs = withLatestFrom.call(nav, this.store); + return concatMap.call(pairs, this.runWithErrorHandling(opts.run, opts.undoAction)); + } + + /** + * + * Handles data fetching. + * + * Example: + * + * ``` + * @Injectable() + * class TodoEffects { + * @Effect() loadTodo = this.s.fetch('GET_TODOS', { + * // provides an action and the current state of the store + * run(a: GetTodos, state: TodosState) { + * return this.backend(state.user, a.payload).map(r => ({ + * type: 'TODOS', + * payload: r + * }); + * }, + * + * onError(a: GetTodos, e: any): Action { + * // dispatch an undo action to undo the changes in the client state + * // return null; + * } + * }); + * + * constructor(private s: DataPersistence, private backend: Backend) {} + * } + * ``` + */ + fetch(actionType: string, opts: FetchOpts): Observable { + const nav = this.actions.ofType(actionType); + const pairs = withLatestFrom.call(nav, this.store); + return switchMap.call(pairs, this.runWithErrorHandling(opts.run, opts.onError)); + } + + /** + * Handles ROUTER_NAVIGATION event. + * + * This is useful for loading extra data needed for a router navigation. + * + * Example: + * ``` + * @Injectable() + * class TodoEffects { + * @Effect() loadTodo = this.s.navigation(TodoComponent, { + * run: (a: ActivatedRouteSnapshot, state: TodosState) => { + * return this.backend.fetchTodo(a.params['id']).map(todo => ({ + * type: 'TODO_LOADED', + * payload: todo + * })); + * }, + * onError: (a: ActivatedRouteSnapshot, e: any) => { + * // we can log and error here and return null + * // we can also navigate back + * return null; + * } + * }); + * constructor(private s: DataPersistence, private backend: Backend) {} + * } + * ``` + * + */ + navigation(component: Type, opts: HandleNavigationOpts): Observable { + const nav = filter.call( + map.call(this.actions.ofType(ROUTER_NAVIGATION), + (a: RouterNavigationAction) => findSnapshot(component, a.payload.routerState.root)), + s => !!s); + + const pairs = withLatestFrom.call(nav, this.store); + return switchMap.call(pairs, this.runWithErrorHandling(opts.run, opts.onError)); + } + + private runWithErrorHandling(run: any, onError: any) { + return a => { + try { + const r = wrapIntoObservable(run(a[0], a[1])); + return _catch.call(r, e => wrapIntoObservable(onError(a[0], e))) + } catch (e) { + return wrapIntoObservable(onError(a[0], e)); + } + } + } +} + +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: any): Observable { + if (!!obj && typeof obj.subscribe === 'function') { + return obj; + } else if (!obj) { + return of(); + } else { + return of(obj); + } +} diff --git a/test/test.ts b/test/test.ts new file mode 100644 index 0000000000..90a86695da --- /dev/null +++ b/test/test.ts @@ -0,0 +1,31 @@ +// This file is required by karma.conf.js and loads recursively all the .spec and framework files + +import 'core-js/es6/reflect'; +import 'core-js/es7/reflect'; +import 'zone.js/dist/zone'; + +import 'zone.js/dist/long-stack-trace-zone'; +import 'zone.js/dist/proxy.js'; +import 'zone.js/dist/sync-test'; +import 'zone.js/dist/jasmine-patch'; +import 'zone.js/dist/async-test'; +import 'zone.js/dist/fake-async-test'; + +import {getTestBed} from '@angular/core/testing'; +import {BrowserDynamicTestingModule, platformBrowserDynamicTesting} from '@angular/platform-browser-dynamic/testing'; + +// Prevent Karma from running prematurely. +declare const __karma__: any; +__karma__.loaded = function () {}; + +// First, initialize the Angular testing environment. +getTestBed().initTestEnvironment( + BrowserDynamicTestingModule, + platformBrowserDynamicTesting() +); + +const context = (require).context('./', true, /\.spec\.js$/); +// And load the modules. +context.keys().map(context); +// Finally, start Karma to run the tests. +__karma__.start(); diff --git a/test/utils/data-persistence.spec.ts b/test/utils/data-persistence.spec.ts new file mode 100644 index 0000000000..d04cd9a843 --- /dev/null +++ b/test/utils/data-persistence.spec.ts @@ -0,0 +1,523 @@ +import {DataPersistence} from '../../src/index'; +import {Actions, Effect, EffectsModule} from '@ngrx/effects'; +import {ActivatedRouteSnapshot, Router} from '@angular/router'; +import {Store, StoreModule} from '@ngrx/store'; +import {ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing'; +import {RouterTestingModule} from '@angular/router/testing'; +import {Component, Injectable} from '@angular/core'; +import {StoreRouterConnectingModule} from '@ngrx/router-store'; +import {of} from 'rxjs/observable/of'; +import {Observable} from 'rxjs/Observable'; +import {_throw} from 'rxjs/observable/throw'; +import {provideMockActions} from '@ngrx/effects/testing'; +import {Subject} from 'rxjs/Subject'; +import {readAll} from '../../src/utils/testing'; + +// interfaces +type Todo = { id: number; user: string; }; +type Todos = { selected: Todo; }; +type TodosState = { todos: Todos; user: string; }; + +// actions +type TodoLoaded = { type: 'TODO_LOADED', payload: Todo }; +type UpdateTodo = { type: 'UPDATE_TODO', payload: {newTitle: string;} }; +type Action = TodoLoaded; + +// reducers +function todosReducer(state: Todos, action: Action): Todos { + if (action.type === 'TODO_LOADED') { + return {selected: action.payload}; + } else { + return state; + } +} + +function userReducer(state: string, action: Action): string { + return 'bob'; +} + + + +@Component({ + template: `ROOT[]` +}) +class RootCmp {} + +@Component({ + template: ` + Todo [ +
+ ID {{t.id}} + User {{t.user}} +
+ ] + ` +}) +class TodoComponent { + todo = this.store.select('todos', 'selected'); + constructor(private store: Store) {} +} + +describe('DataPersistence', () => { + describe('navigation', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [ + RootCmp, + TodoComponent + ], + imports: [ + StoreModule.forRoot({todos: todosReducer, user: userReducer}), + StoreRouterConnectingModule, + RouterTestingModule.withRoutes([ + { path: 'todo/:id', component: TodoComponent} + ]) + ], + providers: [ + DataPersistence + ] + }); + }); + + describe('successful navigation', () => { + @Injectable() + class TodoEffects { + @Effect() loadTodo = this.s.navigation(TodoComponent, { + run: (a: ActivatedRouteSnapshot, state: TodosState) => { + return ({ + type: 'TODO_LOADED', + payload: { + id: a.params['id'], + user: state.user + } + }); + }, + onError: () => {return null;} + }); + constructor(private s: DataPersistence) {} + } + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [TodoEffects], + imports: [EffectsModule.forRoot([TodoEffects])] + }) + }); + + it('should work', fakeAsync(() => { + const root = TestBed.createComponent(RootCmp); + + const router: Router = TestBed.get(Router); + router.navigateByUrl('/todo/123'); + tick(0); + root.detectChanges(false); + + expect(root.elementRef.nativeElement.innerHTML).toContain('ID 123'); + expect(root.elementRef.nativeElement.innerHTML).toContain('User bob'); + })); + }); + + describe('`run` throwing an error', () => { + @Injectable() + class TodoEffects { + @Effect() loadTodo = this.s.navigation(TodoComponent, { + run: (a: ActivatedRouteSnapshot, state: TodosState) => { + if (a.params['id'] === '123') { + throw new Error('boom'); + } else { + return ({ + type: 'TODO_LOADED', + payload: { + id: a.params['id'], + user: state.user + } + }); + } + }, + onError: (a, e) => { + return ({ + type: 'ERROR', + payload: { + error: e + } + }); + } + }); + constructor(private s: DataPersistence) {} + } + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [TodoEffects], + imports: [EffectsModule.forRoot([TodoEffects])] + }) + }); + + it('should work', fakeAsync(() => { + const root = TestBed.createComponent(RootCmp); + + const router: Router = TestBed.get(Router); + let action; + TestBed.get(Actions).subscribe(a => action = a); + + router.navigateByUrl('/todo/123'); + tick(0); + root.detectChanges(false); + expect(root.elementRef.nativeElement.innerHTML).not.toContain('ID 123'); + expect(action.type).toEqual('ERROR'); + expect(action.payload.error.message).toEqual('boom'); + + // can recover after an error + router.navigateByUrl('/todo/456'); + tick(0); + root.detectChanges(false); + expect(root.elementRef.nativeElement.innerHTML).toContain('ID 456'); + })); + }); + + describe('`run` returning an error observable', () => { + @Injectable() + class TodoEffects { + @Effect() loadTodo = this.s.navigation(TodoComponent, { + run: (a: ActivatedRouteSnapshot, state: TodosState) => { + if (a.params['id'] === '123') { + return _throw('boom'); + } else { + return ({ + type: 'TODO_LOADED', + payload: { + id: a.params['id'], + user: state.user + } + }); + } + }, + onError: (a, e) => { + return ({ + type: 'ERROR', + payload: { + error: e + } + }); + } + }); + constructor(private s: DataPersistence) {} + } + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [TodoEffects], + imports: [EffectsModule.forRoot([TodoEffects])] + }) + }); + + it('should work', fakeAsync(() => { + const root = TestBed.createComponent(RootCmp); + + const router: Router = TestBed.get(Router); + let action; + TestBed.get(Actions).subscribe(a => action = a); + + router.navigateByUrl('/todo/123'); + tick(0); + root.detectChanges(false); + expect(root.elementRef.nativeElement.innerHTML).not.toContain('ID 123'); + expect(action.type).toEqual('ERROR'); + expect(action.payload.error).toEqual('boom'); + + router.navigateByUrl('/todo/456'); + tick(0); + root.detectChanges(false); + expect(root.elementRef.nativeElement.innerHTML).toContain('ID 456'); + })); + }); + }); + + describe('fetch', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + DataPersistence + ] + }); + }); + + describe('successful', () => { + @Injectable() + class TodoEffects { + @Effect() loadTodo = this.s.fetch('GET_TODOS', { + run(a: any, state: TodosState) { + return ({ + type: 'TODOS', + payload: { + user: state.user, + todos: 'some todos' + } + }); + }, + + onError(a: UpdateTodo, e: any) { + return null; + } + }); + + constructor(private s: DataPersistence) {} + } + + function userReducer() { + return 'bob'; + } + + let actions: Observable; + + beforeEach(() => { + actions = new Subject(); + TestBed.configureTestingModule({ + providers: [TodoEffects, provideMockActions(() => actions)], + imports: [StoreModule.forRoot({user: userReducer})] + }) + }); + + it('should work', async (done) => { + actions = of({ + type: 'GET_TODOS', + payload: {} + }); + + expect(await readAll(TestBed.get(TodoEffects).loadTodo)).toEqual([ + { + type: 'TODOS', + payload: {user: 'bob', todos: 'some todos'} + } + ]); + + done(); + }); + }); + }); + + describe('pessimisticUpdate', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + DataPersistence + ] + }); + }); + + describe('successful', () => { + @Injectable() + class TodoEffects { + @Effect() loadTodo = this.s.pessimisticUpdate('UPDATE_TODO', { + run(a: UpdateTodo, state: TodosState) { + return ({ + type: 'TODO_UPDATED', + payload: { + user: state.user, + newTitle: a.payload.newTitle + } + }); + }, + + onError(a: UpdateTodo, e: any) { + return null; + } + }); + + constructor(private s: DataPersistence) {} + } + + function userReducer() { + return 'bob'; + } + + let actions: Observable; + + beforeEach(() => { + actions = new Subject(); + TestBed.configureTestingModule({ + providers: [TodoEffects, provideMockActions(() => actions)], + imports: [StoreModule.forRoot({user: userReducer})] + }) + }); + + it('should work', async (done) => { + actions = of({ + type: 'UPDATE_TODO', + payload: {newTitle: 'newTitle'} + }); + + expect(await readAll(TestBed.get(TodoEffects).loadTodo)).toEqual([ + { + type: 'TODO_UPDATED', + payload: {user: 'bob', newTitle: 'newTitle'} + } + ]); + + done(); + }); + }); + + describe('`run` throws an error', () => { + @Injectable() + class TodoEffects { + @Effect() loadTodo = this.s.pessimisticUpdate('UPDATE_TODO', { + run(a: UpdateTodo, state: TodosState) { + throw new Error('boom'); + }, + + onError(a: UpdateTodo, e: any) { + return ({ + type: 'ERROR', + payload: { + error: e + } + }); + } + }); + + constructor(private s: DataPersistence) {} + } + + function userReducer() { + return 'bob'; + } + + let actions: Observable; + + beforeEach(() => { + actions = new Subject(); + TestBed.configureTestingModule({ + providers: [TodoEffects, provideMockActions(() => actions)], + imports: [StoreModule.forRoot({user: userReducer})] + }) + }); + + it('should work', async (done) => { + actions = of({ + type: 'UPDATE_TODO', + payload: {newTitle: 'newTitle'} + }); + + const [a]:any = await readAll(TestBed.get(TodoEffects).loadTodo); + + expect(a.type).toEqual('ERROR'); + expect(a.payload.error.message).toEqual('boom'); + + done(); + }); + }); + + describe('`run` returns an observable that errors', () => { + @Injectable() + class TodoEffects { + @Effect() loadTodo = this.s.pessimisticUpdate('UPDATE_TODO', { + run(a: UpdateTodo, state: TodosState) { + return _throw('boom'); + }, + + onError(a: UpdateTodo, e: any) { + return ({ + type: 'ERROR', + payload: { + error: e + } + }); + } + }); + + constructor(private s: DataPersistence) {} + } + + function userReducer() { + return 'bob'; + } + + let actions: Observable; + + beforeEach(() => { + actions = new Subject(); + TestBed.configureTestingModule({ + providers: [TodoEffects, provideMockActions(() => actions)], + imports: [StoreModule.forRoot({user: userReducer})] + }) + }); + + it('should work', async (done) => { + actions = of({ + type: 'UPDATE_TODO', + payload: {newTitle: 'newTitle'} + }); + + const [a]:any = await readAll(TestBed.get(TodoEffects).loadTodo); + + expect(a.type).toEqual('ERROR'); + expect(a.payload.error).toEqual('boom'); + + done(); + }); + }) + }); + + describe('optimisticUpdate', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + DataPersistence + ] + }); + }); + + describe('`run` throws an error', () => { + @Injectable() + class TodoEffects { + @Effect() loadTodo = this.s.optimisticUpdate('UPDATE_TODO', { + run(a: UpdateTodo, state: TodosState) { + throw new Error('boom'); + }, + + undoAction(a: UpdateTodo, e: any) { + return ({ + type: 'UNDO_UPDATE_TODO', + payload: a.payload + }); + } + }); + + constructor(private s: DataPersistence) {} + } + + function userReducer() { + return 'bob'; + } + + let actions: Observable; + + beforeEach(() => { + actions = new Subject(); + TestBed.configureTestingModule({ + providers: [TodoEffects, provideMockActions(() => actions)], + imports: [StoreModule.forRoot({user: userReducer})] + }) + }); + + it('should work', async (done) => { + actions = of({ + type: 'UPDATE_TODO', + payload: {newTitle: 'newTitle'} + }); + + const [a]:any = await readAll(TestBed.get(TodoEffects).loadTodo); + + expect(a.type).toEqual('UNDO_UPDATE_TODO'); + expect(a.payload.newTitle).toEqual('newTitle'); + + done(); + }); + }); + }); +}); + +function createRoot(router: Router, type: any): ComponentFixture { + return TestBed.createComponent(type); +} diff --git a/tsconfig.json b/tsconfig.json index 10982002f7..d3cc5418c6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,8 @@ "moduleResolution": "node", "outDir": "build", "typeRoots": ["node_modules/@types"], + "experimentalDecorators": true, + "emitDecoratorMetadata": true, "skipLibCheck": true, "lib": [ "es2017"