Skip to content

Commit

Permalink
feat(middleware): Rewrote library to include testing utilities and to…
Browse files Browse the repository at this point in the history
… dynamically run effects
  • Loading branch information
MikeRyanDev committed Mar 9, 2016
1 parent b2c1a04 commit 489a896
Show file tree
Hide file tree
Showing 12 changed files with 627 additions and 120 deletions.
53 changes: 17 additions & 36 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# store-saga
An Rx implementation of redux-saga for @ngrx/store and Angular 2.
An Rx implementation of redux-saga for @ngrx/store and Angular 2.

Based on [redux-saga-rxjs](https://github.com/salsita/redux-saga-rxjs) by Salsita, with inspiration from [redux-saga](https://github.com/yelouafi/redux-saga) by Yelouafi.

Expand All @@ -12,48 +12,29 @@ npm install store-saga --save

Write a saga:
```ts
import {Saga} from 'store-saga';

export const Increment: Saga = iterable => iterable
.filter(({ state, action }) => action.type === 'DECREMENT')
.map(() => ({ type: 'INCREMENT' }));
import {createSaga} from 'store-saga';

export const increment = createSaga(function(){
return saga$ => saga$
.filter(saga => saga.action.type === 'DECREMENT')
.map(() => {
return { type: 'INCREMENT'}
});
});
```

Bootstrap your app using the saga middleware provider and your saga:
Install the store-saga middleware in the same place you provide your ngrx/store:

```ts
import sagaMiddlewareProvider, { useSaga } from 'store-saga';
import {installSagaMiddleware} from 'store-saga';

bootstrap(App, [
provideStore(reducer, initialState),
sagaMiddlewareProvider,
useSaga(increment)
installSagaMiddleware(increment)
]);
```

## Saga Factories
To run your saga in the context of the injector, you can write saga factories instead:

```ts
import {Saga} from 'store-saga';
import {Http} from 'angular2/http';

export function authenticate(http: Http): Saga<State>{
return iterable => iterable
.filter(({ action }) => action.type === 'GET_USER')
.flatMap(() => http.get('/user'))
.map(res => res.json())
.map(user => ({ type: 'USER_RETRIEVED', user }));
}
```

Then create a provider for the saga with `useSagaFactory`:
```ts
import sagaMiddlewareProvider, {useSagaFactory} from 'store-saga';

bootstrap(App, [
provideStore(reducer, initialState),
sagaMiddlewareProvider,
useSagaFactory(authenticate, [ Http ])
]);
```
## Documentation
* [Utilities](docs/utilities.md) - Information on various utility functions
* [SagaRunner](docs/saga-runner.md) - Use the `SagaRunner` service to run, stop, and pause saga effects dynamically
* [Testing](docs/testing.md) - Learn how to test saga effects using the provided `SagaTester` service
75 changes: 75 additions & 0 deletions docs/saga-runner.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# SagaRunner
The `SagaRunner` service allows you to run, pause, and stop saga effects dynamically:

```ts
const authEffect = createSaga(...);

@Component({
selector: 'profile-route',
template: '...'
})
class ProfileRoute{
constructor(private runner: SagaRunner){ }

routerOnActivate(){
this.runner.run(incrementEffect);
}

routerOnDeactivate(){
this.runner.stop(incrementEffect);
}
}
```

## Important Note About Injection
Sagas that are started during bootstrap use the root injector. However, a dynamically started saga may want to use a different injector to resolve the saga effect. This is especially the case when you are working on code splitting.

The `SagaRunner` service is designed to work in tandem with Angular 2's hierarchical injector. To use a different injector, simply re-provide the `SagaRunner` service in your component's providers array:

```ts
@Component({
selector: 'admin-route',
template: '...',
providers: [ SagaRunner ]
})
class AdminRoute{
constructor(runner: SagaRunner){
// runner uses this component's injector to resolve
// any saga effects, storing the resolved effect
// and subscription with the root SagaRunner
}
}
```

## SagaRunner API

### Methods
#### run(saga)
Resolves and starts a new saga, or resumes a previously paused saga.

__Params__
* `saga` __SagaProvider__ Saga to begin running

```ts
sagaRunner.run(saga);
```

#### stop(saga)
Stops a saga from running and discards the resolved saga.

__Params__
* `saga` __SagaProvider__ Saga to stop

```ts
sagaRunner.stop(saga)
```

#### pause(saga)
Pauses a saga but retains the resolved saga. In _most_ cases you should `stop` sagas. Only pause a saga if you are certain you will be restarting it.

__Params__
* `saga` __SagaProvider__ Saga to pause

```ts
sagaRunner.pause(saga)
```
92 changes: 92 additions & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Testing Sagas
store-saga ships with a testing helper called `SagaTester` that extends `SagaRunner`. It exposes some helpful methods to interact with a saga effect and make assertions about the effect's resultant stream.

Extending from our `authEffect` example written in the _Getting Started_ guide, let's write a test that ensures the effect is responding correctly to dispatched actions:

_Note: for this example I am using respond-ng, a simple Http mocking library I wrote to make unit testing Http calls easier_

```ts
import {Injector} from 'angular2/core';
import RESPOND_PROVIDERS, {Respond} from 'respond-ng';
import {SagaTester} from 'store-saga/testing';

import {authEffect} from './auth-effect';

describe('Authentication Effect', () => {
let sagaTester: SagaTester;
let respond: Respond;

beforeEach(() => {
const injector = Injector.resolveAndCreate([
SagaTester,
RESPOND_PROVIDERS
]);

sagaTester = injector.get(SagaTester);
respond = injector.get(Respond);

sagaTester.run(authEffect);
});

it('should auth a user when an auth action is dispatched', () => {
const payload = { username: 'Mike', password: 'test' };
respond.ok().when.post('/auth', payload)

sagaTester.sendAction({ type: 'AUTH', payload });

expect(sagaTester.last).toEqual({ type: 'AUTH_SUCCESS' });
});
});
```

## SagaTester API
### Parameters
#### output
Output stream of all running sagas.

_Type_ __BehaviorSubject__

```ts
sagaTester.output.subscribe(action => { });
```

#### last
Most recent action dispatched from a saga

_Type_ __Action__

```ts
expect(sagaTester.last).toEqual({ type: 'INCREMENT' });
```

### Methods
#### sendAction(action)
Sends an action and an empty state object to all running sagas.

__Params__
* `action` __Action__ Action object to dispatch to sagas

```ts
sagaTester.sendAction({ type: 'INCREMENT' });
```

#### sendState(state)
Sends state and an empty action object to all running sagas.

__Params__
* `state` __any__ State object to dispatch to sagas

```ts
sagaTester.sendState({ counter: 3 });
```

#### send(state, action)
Sends both state and action objects to all running sagas.

__Params__
* `state` __any__ State object to dispatch to sagas
* `action` __Action__ Action object to dispatch to sagas

```ts
sagaTester.send({ counter: 3 }, { type: 'INCREMENT' });
```
68 changes: 68 additions & 0 deletions docs/utilities.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Utilities API

#### createSaga(factory: SagaFactory, dependencies: any[])
Creates a saga in the context of the injector.

__Params__
* `factory` __SagaFactory__ Factory function called in the context of the injector. Must return a function that implements the `Saga` interface.
* `dependencies` __any[]__ Array of dependencies the factory function needs

_Returns_ `Provider`

```ts
const authEffect = createSaga(function(http: Http) {
return saga$ => saga$
.filter(saga => saga.action.type === 'AUTH')
.map(saga => saga.action.payload)
.flatMap(payload => {
return http.post('/auth', JSON.stringify(payload))
.map(res => {
return {
type: 'AUTH_SUCESS',
payload: res.json()
}
})
.catch(error => Observable.of({
type: 'AUTH_FAILED',
payload: error.json()
}));
});
}, [ Http ]);
```

#### installSagaMiddleware(...sagas: Provider[])
Installs the saga middleware and initializes it to immediately begin running the provided sagas.

__Params__
* `...sagas` __Provider[]__ Sagas you want to begin running immediately.

_Returns_ `Provider[]`

```ts
boostrap(App, [
provideStore(reducer),
installSagaMiddleware(authEffect, signoutEffect)
]);
```

#### whenAction(actionType: string)
Filters a stream of `SagaIteration`s to only include iterations with an action of the provided type.

__Params__
* `actionType` __string__ Action type to filter for

_Returns_ `(iteration: SagaIteration) => boolean`

```ts
return saga$ => saga$
.filter(whenAction('AUTH'))
```

#### toPayload
Function you can pass in to map a saga iteration to the payload of that iteration's action

```ts
return saga$ => saga$
.map(toPayload)
.do(payload => { ... });
```
45 changes: 3 additions & 42 deletions lib/index.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,3 @@
import 'rxjs/add/operator/map';
import 'rxjs/add/operator/withLatestFrom';
import 'rxjs/add/observable/merge';
import {Observable} from 'rxjs/Observable';
import {Subject} from 'rxjs/Subject';
import {OpaqueToken, provide, Provider} from 'angular2/core';
import {Action, Store, Dispatcher, POST_MIDDLEWARE} from '@ngrx/store'

export const SAGA_FUNCTIONS = new OpaqueToken('store-saga/saga-functions');

export interface Saga<State>{
(iterable: Observable<{ state: State, action: Action }>): Observable<any>;
}

export function useSaga(saga: Saga<any>){
return provide(SAGA_FUNCTIONS, { useValue: saga, multi: true });
}

export function useSagaFactory(useFactory: (...deps: any[]) => Saga<any>, deps: any[]){
return provide(SAGA_FUNCTIONS, { deps, useFactory, multi: true });
}

export default provide(POST_MIDDLEWARE, {
multi: true,
deps: [ Dispatcher, SAGA_FUNCTIONS ],
useFactory(dispatcher: Dispatcher<any>, sagas: Saga<any>[]){
return function(state$: Observable<any>){
const iterable$ = new Subject();
const resolvedSagas = sagas.map(saga => saga(iterable$));

Observable.merge(...resolvedSagas).subscribe(dispatcher);

return state$
.withLatestFrom(dispatcher)
.map(([ state, action ]) => {
iterable$.next({ state, action });

return state;
});
}
}
});
export * from './interfaces';
export * from './runner';
export * from './util';
15 changes: 15 additions & 0 deletions lib/interfaces.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import {Observable} from 'rxjs/Observable';
import {Action} from '@ngrx/store';

export interface SagaIteration<State>{
state: State;
action: Action;
}

export interface Saga<State>{
(iterable: Observable<SagaIteration<State>>): Observable<any>;
}

export interface SagaFactory<State>{
(...deps: any[]): Saga<State>
}
Loading

0 comments on commit 489a896

Please sign in to comment.