Move to redux-epic

This commit is contained in:
Berkeley Martinez
2016-05-04 16:46:19 -07:00
parent d511be3332
commit b6f9cfdf71
14 changed files with 10 additions and 430 deletions

View File

@ -4,6 +4,7 @@ import React from 'react';
import debug from 'debug';
import { Router } from 'react-router';
import { routeReducer as routing, syncHistory } from 'react-router-redux';
import { render } from 'redux-epic';
import { createHistory } from 'history';
import createApp from '../common/app';
@ -12,8 +13,6 @@ import provideStore from '../common/app/provide-store';
// client specific sagas
import sagas from './sagas';
// render to observable
import render from '../common/app/utils/render';
import {
isColdStored,
getColdStorage,

View File

@ -3,6 +3,7 @@ import { Row } from 'react-bootstrap';
import { ToastMessage, ToastContainer } from 'react-toastr';
import { compose } from 'redux';
import { connect } from 'react-redux';
import { contain } from 'redux-epic';
import { createSelector } from 'reselect';
import {
@ -10,7 +11,6 @@ import {
updateWindowHeight,
updateNavHeight
} from './redux/actions';
import contain from './utils/professor-x';
import getWindowHeight from './utils/get-window-height';
import Nav from './components/Nav';

View File

@ -8,7 +8,7 @@ import App from './App.jsx';
import childRoutes from './routes';
// redux
import createEpic from './utils/redux-epic';
import { createEpic } from 'redux-epic';
import createReducer from './create-reducer';
import middlewares from './middlewares';
import sagas from './sagas';

View File

@ -1,5 +1,6 @@
import React, { PropTypes } from 'react';
import { compose } from 'redux';
import { contain } from 'redux-epic';
import { connect } from 'react-redux';
import PureComponent from 'react-pure-render/component';
import { createSelector } from 'reselect';
@ -8,7 +9,6 @@ import { createSelector } from 'reselect';
import HikesMap from './Map.jsx';
import { fetchHikes } from '../redux/actions';
import contain from '../../../utils/professor-x';
// const log = debug('fcc:hikes');

View File

@ -1,5 +1,6 @@
import React, { cloneElement, PropTypes } from 'react';
import { compose } from 'redux';
import { contain } from 'redux-epic';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { LinkContainer } from 'react-router-bootstrap';
@ -7,7 +8,6 @@ import { LinkContainer } from 'react-router-bootstrap';
import PureComponent from 'react-pure-render/component';
import { Button, Row, Col } from 'react-bootstrap';
import contain from '../../../utils/professor-x';
import ListJobs from './List.jsx';
import {

View File

@ -1,11 +1,11 @@
import React, { PropTypes } from 'react';
import { compose } from 'redux';
import { contain } from 'redux-epic';
import { connect } from 'react-redux';
import { push } from 'react-router-redux';
import PureComponent from 'react-pure-render/component';
import { createSelector } from 'reselect';
import contain from '../../../utils/professor-x';
import { fetchJobs } from '../redux/actions';
import ShowJob from './ShowJob.jsx';

View File

@ -1,11 +1,11 @@
import React, { PropTypes } from 'react';
import { compose } from 'redux';
import { contain } from 'redux-epic';
import { connect } from 'react-redux';
import PureComponent from 'react-pure-render/component';
import { createSelector } from 'reselect';
import Map from './Map.jsx';
import contain from '../../../utils/professor-x';
import {
clearFilter,
fetchChallenges,

View File

@ -1,117 +0,0 @@
import { helpers } from 'rx';
import { createElement } from 'react';
import PureComponent from 'react-pure-render/component';
import debug from 'debug';
// interface contain {
// (options?: Object, Component: ReactComponent) => ReactComponent
// (options?: Object) => (Component: ReactComponent) => ReactComponent
// }
//
// Action: { type: String, payload: Any, ...meta }
//
// ActionCreator(...args) => Observable
//
// interface options {
// fetchAction?: ActionCreator,
// getActionArgs?(props: Object, context: Object) => [],
// isPrimed?(props: Object, context: Object) => Boolean,
// handleError?(err) => Void
// shouldRefetch?(
// props: Object,
// nextProps: Object,
// context: Object,
// nextContext: Object
// ) => Boolean,
// }
const log = debug('fcc:professerx');
const __DEV__ = process.env.NODE_ENV !== 'production';
const { isFunction } = helpers;
export default function contain(options = {}, Component) {
/* istanbul ignore else */
if (!Component) {
return contain.bind(null, options);
}
let action;
let isActionable = false;
let hasRefetcher = isFunction(options.shouldRefetch);
const getActionArgs = isFunction(options.getActionArgs) ?
options.getActionArgs :
(() => []);
const isPrimed = isFunction(options.isPrimed) ?
options.isPrimed :
(() => false);
const name = Component.displayName || 'Anon Component';
function runAction(props, context, action) {
const actionArgs = getActionArgs(props, context);
if (__DEV__ && !Array.isArray(actionArgs)) {
throw new TypeError(
`${name} getActionArgs should return an array but got ${actionArgs}`
);
}
return action.apply(null, actionArgs);
}
return class Container extends PureComponent {
static displayName = `Container(${name})`;
componentWillMount() {
const { props, context } = this;
if (!options.fetchAction) {
log(`${name} has no fetch action defined`);
return;
}
if (isPrimed(this.props, this.context)) {
log(`${name} container is primed`);
return;
}
action = props[options.fetchAction];
isActionable = typeof action === 'function';
if (__DEV__ && !isActionable) {
throw new Error(
`${options.fetchAction} should return a function but got ${action}.
Check the fetch options for ${name}.`
);
}
runAction(
props,
context,
action
);
}
componentWillReceiveProps(nextProps, nextContext) {
if (
!isActionable ||
!hasRefetcher ||
!options.shouldRefetch(this.props, nextProps, this.context, nextContext)
) {
return;
}
runAction(
nextProps,
nextContext,
action
);
}
render() {
return createElement(
Component,
this.props
);
}
};
}

View File

@ -1,57 +0,0 @@
import { CompositeDisposable, Observable, Subject } from 'rx';
export default (dependencies, ...sagas) => {
if (typeof dependencies === 'function') {
sagas.push(dependencies);
dependencies = {};
}
let action$;
let lifecycle;
let compositeDisposable;
let start;
function sagaMiddleware({ dispatch, getState }) {
start = () => {
compositeDisposable = new CompositeDisposable();
action$ = new Subject();
lifecycle = new Subject();
const sagaSubscription = Observable
.from(sagas)
.map(saga => saga(action$, getState, dependencies))
.doOnNext(result$ => {
if (!Observable.isObservable(result$)) {
throw new Error('saga should returned an observable');
}
if (result$ === action$) {
throw new Error('Saga returned original action stream!');
}
})
.mergeAll()
.filter(action => action && typeof action.type === 'string')
.subscribe(
action => dispatch(action),
err => { throw err; },
() => lifecycle.onCompleted()
);
compositeDisposable.add(sagaSubscription);
};
start();
return next => action => {
const result = next(action);
action$.onNext(action);
return result;
};
}
sagaMiddleware.subscribe =
(...args) => lifecycle.subscribe.apply(lifecycle, args);
sagaMiddleware.subscribeOnCompleted =
(...args) => lifecycle.subscribeOnCompleted.apply(lifecycle, args);
sagaMiddleware.end = () => action$.onCompleted();
sagaMiddleware.dispose = () => compositeDisposable.dispose();
sagaMiddleware.restart = () => {
sagaMiddleware.dispose();
action$.dispose();
start();
};
return sagaMiddleware;
};

View File

@ -1,203 +0,0 @@
import { Observable, Subject } from 'rx';
import test from 'tape';
import { spy } from 'sinon';
import { applyMiddleware, createStore } from 'redux';
import createSaga from './redux-epic';
const setup = (saga, spy) => {
const reducer = (state = 0) => state;
const sagaMiddleware = createSaga(
action$ => action$
.filter(({ type }) => type === 'foo')
.map(() => ({ type: 'bar' })),
action$ => action$
.filter(({ type }) => type === 'bar')
.map(({ type: 'baz' })),
saga ? saga : () => Observable.empty()
);
const store = applyMiddleware(sagaMiddleware)(createStore)(spy || reducer);
return {
reducer,
sagaMiddleware,
store
};
};
test('createSaga', t => {
const sagaMiddleware = createSaga(
action$ => action$.map({ type: 'foo' })
);
t.equal(
typeof sagaMiddleware,
'function',
'sagaMiddleware is not a function'
);
t.equal(
typeof sagaMiddleware.subscribe,
'function',
'sagaMiddleware does not have a subscription method'
);
t.equal(
typeof sagaMiddleware.subscribeOnCompleted,
'function',
'sagaMiddleware does not have a subscribeOnCompleted method'
);
t.equal(
typeof sagaMiddleware.end,
'function',
'sagaMiddleware does not have an end method'
);
t.equal(
typeof sagaMiddleware.restart,
'function',
'sagaMiddleware does not have an restart method'
);
t.equal(
typeof sagaMiddleware.dispose,
'function',
'sagaMiddleware does not have a dispose method'
);
t.end();
});
test('dispatching actions', t => {
const reducer = spy((state = 0) => state);
const { store } = setup(null, reducer);
store.dispatch({ type: 'foo' });
t.equal(reducer.callCount, 4, 'reducer is called four times');
t.assert(
reducer.getCall(1).calledWith(0, { type: 'foo' }),
'reducer called with initial action'
);
t.assert(
reducer.getCall(2).calledWith(0, { type: 'bar' }),
'reducer was not called with saga action'
);
t.assert(
reducer.getCall(3).calledWith(0, { type: 'baz' }),
'second saga responded to action from first saga'
);
t.end();
});
test('lifecycle', t => {
t.test('subscribe', t => {
const { sagaMiddleware } = setup();
const subscription = sagaMiddleware.subscribeOnCompleted(() => {});
t.assert(
subscription,
'subscribe did not return a disposable'
);
t.isEqual(
typeof subscription.dispose,
'function',
'disposable does not have a dispose method'
);
t.doesNotThrow(
() => subscription.dispose(),
'disposable is not disposable'
);
t.end();
});
t.test('end', t => {
const result$ = new Subject();
const { sagaMiddleware } = setup(() => result$);
sagaMiddleware.subscribeOnCompleted(() => {
t.pass('all sagas completed');
t.end();
});
sagaMiddleware.end();
t.pass('saga still active');
result$.onCompleted();
});
t.test('disposable', t => {
const result$ = new Subject();
const { sagaMiddleware } = setup(() => result$);
t.plan(2);
sagaMiddleware.subscribeOnCompleted(() => {
t.fail('all sagas completed');
});
t.assert(
result$.hasObservers(),
'saga is observed by sagaMiddleware'
);
sagaMiddleware.dispose();
t.false(
result$.hasObservers(),
'watcher has no observers after sagaMiddleware is disposed'
);
});
});
test('restart', t => {
const reducer = spy((state = 0) => state);
const { sagaMiddleware, store } = setup(null, reducer);
store.dispatch({ type: 'foo' });
t.assert(
reducer.getCall(1).calledWith(0, { type: 'foo' }),
'reducer called with initial dispatch'
);
t.assert(
reducer.getCall(2).calledWith(0, { type: 'bar' }),
'reducer called with saga action'
);
t.assert(
reducer.getCall(3).calledWith(0, { type: 'baz' }),
'second saga responded to action from first saga'
);
sagaMiddleware.end();
t.equal(reducer.callCount, 4, 'saga produced correct amount of actions');
sagaMiddleware.restart();
store.dispatch({ type: 'foo' });
t.equal(
reducer.callCount,
7,
'saga restart and produced correct amount of actions'
);
t.assert(
reducer.getCall(4).calledWith(0, { type: 'foo' }),
'reducer called with second dispatch'
);
t.assert(
reducer.getCall(5).calledWith(0, { type: 'bar' }),
'reducer called with saga reaction'
);
t.assert(
reducer.getCall(6).calledWith(0, { type: 'baz' }),
'second saga responded to action from first saga'
);
t.end();
});
test('long lived saga', t => {
let count = 0;
const tickSaga = action$ => action$
.filter(({ type }) => type === 'start-tick')
.flatMap(() => Observable.interval(500))
// make sure long lived saga's do not persist after
// action$ has completed
.takeUntil(action$.last())
.map(({ type: 'tick' }));
const reducerSpy = spy((state = 0) => state);
const { store, sagaMiddleware } = setup(tickSaga, reducerSpy);
const unlisten = store.subscribe(() => {
count += 1;
if (count >= 5) {
sagaMiddleware.end();
}
});
sagaMiddleware.subscribeOnCompleted(() => {
t.equal(
count,
5,
'saga dispatched correct amount of ticks'
);
unlisten();
t.pass('long lived saga completed');
t.end();
});
store.dispatch({ type: 'start-tick' });
});

View File

@ -1,24 +0,0 @@
import { Observable } from 'rx';
import ReactDOM from 'react-dom/server';
import debug from 'debug';
const log = debug('fcc:professor');
export default function renderToString(Component, sagaMiddleware) {
try {
log('initial render');
ReactDOM.renderToStaticMarkup(Component);
log('initial render completed');
} catch (e) {
return Observable.throw(e);
}
sagaMiddleware.end();
return Observable.merge(sagaMiddleware)
.last({ defaultValue: null })
.delay(0)
.map(() => {
sagaMiddleware.restart();
const markup = ReactDOM.renderToString(Component);
return { markup };
});
}

View File

@ -1,18 +0,0 @@
import ReactDOM from 'react-dom';
import { Disposable, Observable } from 'rx';
export default function render(Component, DOMContainer) {
return Observable.create(observer => {
try {
ReactDOM.render(Component, DOMContainer, function() {
observer.onNext(this);
});
} catch (e) {
return observer.onError(e);
}
return Disposable.create(() => {
return ReactDOM.unmountComponentAtNode(DOMContainer);
});
});
}

View File

@ -81,9 +81,8 @@
"pmx": "~0.6.2",
"react": "^15.0.2",
"react-bootstrap": "~0.29.4",
"react-css-transition-replace": "^1.2.0-beta",
"react-dom": "^15.0.2",
"react-addons-css-transition-group": "^0.14.7",
"react-css-transition-replace": "^1.1.0",
"react-fontawesome": "^0.3.3",
"react-motion": "~0.4.2",
"react-no-ssr": "^1.0.1",
@ -96,6 +95,7 @@
"react-youtube": "^6.1.0",
"redux": "^3.0.5",
"redux-actions": "^0.9.1",
"redux-epic": "^0.1.1",
"redux-form": "^5.2.3",
"request": "^2.65.0",
"reselect": "^2.0.2",

View File

@ -2,7 +2,7 @@ import React from 'react';
import { RouterContext } from 'react-router';
import debug from 'debug';
import renderToString from '../../common/app/utils/render-to-string';
import { renderToString } from 'redux-epic';
import provideStore from '../../common/app/provide-store';
import createApp from '../../common/app';