feat: add utilities for data persistence
This commit is contained in:
parent
a8cdc06eb1
commit
010a5185f7
99
README.md
99
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<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
59
karma.conf.js
Normal 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
|
||||
});
|
||||
};
|
||||
29
package.json
29
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",
|
||||
|
||||
3
scripts/test.sh
Executable file
3
scripts/test.sh
Executable file
@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
karma start --single-run
|
||||
@ -0,0 +1 @@
|
||||
export { DataPersistence } from './utils/data-persistence';
|
||||
232
src/utils/data-persistence.ts
Normal file
232
src/utils/data-persistence.ts
Normal 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
31
test/test.ts
Normal 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();
|
||||
523
test/utils/data-persistence.spec.ts
Normal file
523
test/utils/data-persistence.spec.ts
Normal 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);
|
||||
}
|
||||
@ -5,6 +5,8 @@
|
||||
"moduleResolution": "node",
|
||||
"outDir": "build",
|
||||
"typeRoots": ["node_modules/@types"],
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"skipLibCheck": true,
|
||||
"lib": [
|
||||
"es2017"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user