feat: add utilities for data persistence

This commit is contained in:
vsavkin 2017-08-23 18:13:08 -04:00
parent a8cdc06eb1
commit 010a5185f7
9 changed files with 976 additions and 3 deletions

View File

@ -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<TodosState>, 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<TodosState>, 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<TodosState>, 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<TodosState>, 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.

59
karma.conf.js Normal file
View File

@ -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
});
};

View File

@ -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",

3
scripts/test.sh Executable file
View File

@ -0,0 +1,3 @@
#!/usr/bin/env bash
karma start --single-run

View File

@ -0,0 +1 @@
export { DataPersistence } from './utils/data-persistence';

View File

@ -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> | Action | void;
onError(a: Action, e: any): Observable<any> | any;
}
/**
* See DataPersistence.pessimisticUpdate for more information.
*/
export interface OptimisticUpdateOpts {
run(a: Action, state?: any): Observable<any> | any;
undoAction(a: Action, e: any): Observable<Action> | Action;
}
/**
* See DataPersistence.navigation for more information.
*/
export interface FetchOpts {
run(a: Action, state?: any): Observable<Action> | Action | void;
onError?(a: Action, e: any): Observable<any> | any;
}
/**
* See DataPersistence.navigation for more information.
*/
export interface HandleNavigationOpts {
run(a: ActivatedRouteSnapshot, state?: any): Observable<Action> | Action | void;
onError?(a: ActivatedRouteSnapshot, e: any): Observable<any> | 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<T> {
constructor(public store: Store<T>, 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<TodosState>, private backend: Backend) {}
* }
* ```
*
*/
pessimisticUpdate(actionType: string, opts: PessimisticUpdateOpts): Observable<any> {
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<TodosState>, private backend: Backend) {}
* }
* ```
*/
optimisticUpdate(actionType: string, opts: OptimisticUpdateOpts): Observable<any> {
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<TodosState>, private backend: Backend) {}
* }
* ```
*/
fetch(actionType: string, opts: FetchOpts): Observable<any> {
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<TodosState>, private backend: Backend) {}
* }
* ```
*
*/
navigation(component: Type<any>, opts: HandleNavigationOpts): Observable<any> {
const nav = filter.call(
map.call(this.actions.ofType(ROUTER_NAVIGATION),
(a: RouterNavigationAction<RouterStateSnapshot>) => 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<any>, 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<any> {
if (!!obj && typeof obj.subscribe === 'function') {
return obj;
} else if (!obj) {
return of();
} else {
return of(obj);
}
}

31
test/test.ts Normal file
View File

@ -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 = (<any>require).context('./', true, /\.spec\.js$/);
// And load the modules.
context.keys().map(context);
// Finally, start Karma to run the tests.
__karma__.start();

View File

@ -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[<router-outlet></router-outlet>]`
})
class RootCmp {}
@Component({
template: `
Todo [
<div *ngIf="(todo|async) as t">
ID {{t.id}}
User {{t.user}}
</div>
]
`
})
class TodoComponent {
todo = this.store.select('todos', 'selected');
constructor(private store: Store<TodosState>) {}
}
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<any>) {}
}
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<any>) {}
}
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<any>) {}
}
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<any>) {}
}
function userReducer() {
return 'bob';
}
let actions: Observable<any>;
beforeEach(() => {
actions = new Subject<any>();
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<any>) {}
}
function userReducer() {
return 'bob';
}
let actions: Observable<any>;
beforeEach(() => {
actions = new Subject<any>();
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<any>) {}
}
function userReducer() {
return 'bob';
}
let actions: Observable<any>;
beforeEach(() => {
actions = new Subject<any>();
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<any>) {}
}
function userReducer() {
return 'bob';
}
let actions: Observable<any>;
beforeEach(() => {
actions = new Subject<any>();
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<any>) {}
}
function userReducer() {
return 'bob';
}
let actions: Observable<any>;
beforeEach(() => {
actions = new Subject<any>();
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<any> {
return TestBed.createComponent(type);
}

View File

@ -5,6 +5,8 @@
"moduleResolution": "node",
"outDir": "build",
"typeRoots": ["node_modules/@types"],
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"skipLibCheck": true,
"lib": [
"es2017"