Add new rx saga

This commit is contained in:
Berkeley Martinez
2016-04-24 21:54:48 -07:00
parent 96f4f3413a
commit d511be3332
24 changed files with 820 additions and 531 deletions

View File

@ -39,17 +39,20 @@ const routingMiddleware = syncHistory(history);
const devTools = window.devToolsExtension ? window.devToolsExtension() : f => f; const devTools = window.devToolsExtension ? window.devToolsExtension() : f => f;
const shouldRouterListenForReplays = !!window.devToolsExtension; const shouldRouterListenForReplays = !!window.devToolsExtension;
const clientSagaOptions = { doc: document }; const sagaOptions = {
window,
document: window.document,
location: window.location
};
createApp({ createApp({
history, history,
serviceOptions, serviceOptions,
initialState, initialState,
middlewares: [ middlewares: [ routingMiddleware ],
routingMiddleware, sagas,
...sagas.map(saga => saga(clientSagaOptions)) sagaOptions,
],
reducers: { routing }, reducers: { routing },
enhancers: [ devTools ] enhancers: [ devTools ]
}) })

View File

@ -1,20 +1,14 @@
// () => export default function errorSaga(action$) {
// (store: Store) => return action$
// (next: (action: Action) => Object) => .filter(({ error }) => !!error)
// errSaga(action: Action) => Object|Void .map(({ error }) => error)
export default () => ({ dispatch }) => next => { .doOnNext(error => console.error(error))
return function errorSaga(action) { .map(() => ({
const result = next(action);
if (!action.error) { return result; }
console.error(action.error);
return dispatch({
type: 'app.makeToast', type: 'app.makeToast',
payload: { payload: {
type: 'error', type: 'error',
title: 'Oops, something went wrong', title: 'Oops, something went wrong',
message: 'Something went wrong, please try again later' message: 'Something went wrong, please try again later'
} }
}); }));
}; }
};

View File

@ -1,24 +1,16 @@
import { hardGoTo } from '../../common/app/redux/types'; import { hardGoTo } from '../../common/app/redux/types';
const loc = typeof window !== 'undefined' ? export default function hardGoToSaga(action$, getState, { location }) {
window.location : return action$
{}; .filter(({ type }) => type === hardGoTo)
.map(({ payload = '/map' }) => {
export default () => ({ dispatch }) => next => { if (!location.pathname) {
return function hardGoToSaga(action) { return {
const result = next(action); type: 'app.error',
if (action.type !== hardGoTo) { error: new Error('no location object found')
return result; };
} }
location.pathname = payload;
if (!loc.pathname) { return null;
dispatch({ });
type: 'app.error', }
error: new Error('no location object found')
});
}
loc.pathname = action.payload || '/map';
return null;
};
};

View File

@ -1,3 +1,4 @@
import store from 'store';
import { import {
saveForm, saveForm,
clearForm, clearForm,
@ -10,60 +11,33 @@ import {
} from '../../common/app/routes/Jobs/redux/actions'; } from '../../common/app/routes/Jobs/redux/actions';
const formKey = 'newJob'; const formKey = 'newJob';
let enabled = false;
let store = typeof window !== 'undefined' ?
window.localStorage :
false;
try { export default function localStorageSaga(action$) {
const testKey = '__testKey__'; return action$
store.setItem(testKey, testKey); .filter(action => {
enabled = store.getItem(testKey) === testKey; return action.type === saveForm ||
store.removeItem(testKey); action.type === clearForm ||
} catch (e) { action.type === loadSavedForm;
enabled = !e; })
} .map(action => {
if (action.type === saveForm) {
if (!enabled) { const form = action.payload;
console.error(new Error('No localStorage found')); try {
} store.setItem(formKey, form);
return saveCompleted(form);
export default () => ({ dispatch }) => next => { } catch (error) {
return function localStorageSaga(action) { return {
if (!enabled) { return next(action); } type: 'app.handleError',
error
if (action.type === saveForm) { };
const form = action.payload; }
try {
store.setItem(formKey, JSON.stringify(form));
next(action);
return dispatch(saveCompleted(form));
} catch (error) {
return dispatch({
type: 'app.handleError',
error
});
} }
}
if (action.type === clearForm) { if (action.type === clearForm) {
store.removeItem(formKey); store.removeItem(formKey);
return null; return null;
}
if (action.type === loadSavedForm) {
const formString = store.getItem(formKey);
try {
const form = JSON.parse(formString);
return dispatch(loadSavedFormCompleted(form));
} catch (error) {
return dispatch({
type: 'app.handleError',
error
});
} }
}
return next(action); return loadSavedFormCompleted(store.getItem(formKey));
}; });
}; }

View File

@ -1,17 +1,10 @@
// (doc: Object) => export default function titleSage(action$, getState, { document }) {
// () => return action$
// (next: (action: Action) => Object) => .filter(action => action.type === 'app.updateTitle')
// titleSage(action: Action) => Object|Void .map(() => {
export default ({ doc }) => ({ getState }) => next => { const state = getState();
return function titleSage(action) { const newTitle = state.app.title;
// get next state document.title = newTitle;
const result = next(action); return null;
if (action.type !== 'app.updateTitle') { });
return result; }
}
const state = getState();
const newTitle = state.app.title;
doc.title = newTitle;
return result;
};
};

View File

@ -8,6 +8,7 @@ import App from './App.jsx';
import childRoutes from './routes'; import childRoutes from './routes';
// redux // redux
import createEpic from './utils/redux-epic';
import createReducer from './create-reducer'; import createReducer from './create-reducer';
import middlewares from './middlewares'; import middlewares from './middlewares';
import sagas from './sagas'; import sagas from './sagas';
@ -40,17 +41,24 @@ export default function createApp({
middlewares: sideMiddlewares = [], middlewares: sideMiddlewares = [],
enhancers: sideEnhancers = [], enhancers: sideEnhancers = [],
reducers: sideReducers = {}, reducers: sideReducers = {},
sagas: sideSagas = [] sagas: sideSagas = [],
sagaOptions: sideSagaOptions = {}
}) { }) {
const sagaOptions = { const sagaOptions = {
...sideSagaOptions,
services: servicesCreator(null, serviceOptions) services: servicesCreator(null, serviceOptions)
}; };
const sagaMiddleware = createEpic(
sagaOptions,
...sagas,
...sideSagas
);
const enhancers = [ const enhancers = [
applyMiddleware( applyMiddleware(
...middlewares, ...middlewares,
...sideMiddlewares, ...sideMiddlewares,
...[ ...sagas, ...sideSagas].map(saga => saga(sagaOptions)), sagaMiddleware
), ),
// enhancers must come after middlewares // enhancers must come after middlewares
// on client side these are things like Redux DevTools // on client side these are things like Redux DevTools
@ -74,6 +82,7 @@ export default function createApp({
redirect, redirect,
props, props,
reducer, reducer,
store store,
epic: sagaMiddleware
})); }));
} }

View File

@ -1,3 +1,4 @@
import { Observable } from 'rx';
import { createAction } from 'redux-actions'; import { createAction } from 'redux-actions';
import types from './types'; import types from './types';
@ -39,3 +40,8 @@ export const updateNavHeight = createAction(types.updateNavHeight);
export const updateChallengesData = createAction(types.updateChallengesData); export const updateChallengesData = createAction(types.updateChallengesData);
export const updateJobsData = createAction(types.updateJobsData); export const updateJobsData = createAction(types.updateJobsData);
export const updateHikesData = createAction(types.updateHikesData); export const updateHikesData = createAction(types.updateHikesData);
export const createErrorObserable = error => Observable.just({
type: types.handleError,
error
});

View File

@ -1,24 +1,20 @@
import { Observable } from 'rx'; import { Observable } from 'rx';
import { handleError, setUser, fetchUser } from './types'; import { handleError, setUser, fetchUser } from './types';
export default ({ services }) => ({ dispatch }) => next => { export default function getUserSaga(action$, getState, { services }) {
return function getUserSaga(action) { return action$
if (action.type !== fetchUser) { .filter(action => action.type === fetchUser)
return next(action); .flatMap(() => {
} return services.readService$({ service: 'user' })
.map(user => {
return services.readService$({ service: 'user' }) return {
.map((user) => { type: setUser,
return { payload: user
type: setUser, };
payload: user })
}; .catch(error => Observable.just({
}) type: handleError,
.catch(error => Observable.just({ error
type: handleError, }));
error });
})) }
.doOnNext(dispatch);
};
};

View File

@ -2,7 +2,6 @@ import React, { PropTypes } from 'react';
import { spring, Motion } from 'react-motion'; import { spring, Motion } from 'react-motion';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { Button, Col, Row } from 'react-bootstrap'; import { Button, Col, Row } from 'react-bootstrap';
import { CompositeDisposable } from 'rx';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { import {
@ -54,11 +53,6 @@ const mapStateToProps = createSelector(
); );
class Question extends React.Component { class Question extends React.Component {
constructor(...args) {
super(...args);
this._subscriptions = new CompositeDisposable();
}
static displayName = 'Questions'; static displayName = 'Questions';
static propTypes = { static propTypes = {
@ -78,10 +72,6 @@ class Question extends React.Component {
shouldShakeQuestion: PropTypes.bool shouldShakeQuestion: PropTypes.bool
}; };
componentWillUnmount() {
this._subscriptions.dispose();
}
handleMouseUp(e, answer, info) { handleMouseUp(e, answer, info) {
e.stopPropagation(); e.stopPropagation();
if (!this.props.isPressed) { if (!this.props.isPressed) {
@ -94,16 +84,12 @@ class Question extends React.Component {
} = this.props; } = this.props;
releaseQuestion(); releaseQuestion();
const subscription = answerQuestion({ return answerQuestion({
e, e,
answer, answer,
info, info,
threshold: answerThreshold threshold: answerThreshold
}) });
.subscribe();
this._subscriptions.add(subscription);
return null;
} }
handleMouseMove(isPressed, { delta, moveQuestion }) { handleMouseMove(isPressed, { delta, moveQuestion }) {
@ -115,21 +101,17 @@ class Question extends React.Component {
onAnswer(answer, userAnswer, info) { onAnswer(answer, userAnswer, info) {
const { isSignedIn, answerQuestion } = this.props; const { isSignedIn, answerQuestion } = this.props;
const subscriptions = this._subscriptions;
return e => { return e => {
if (e && e.preventDefault) { if (e && e.preventDefault) {
e.preventDefault(); e.preventDefault();
} }
const subscription = answerQuestion({ answerQuestion({
answer, answer,
userAnswer, userAnswer,
info, info,
isSignedIn isSignedIn
}) });
.subscribe();
subscriptions.add(subscription);
}; };
} }

View File

@ -9,7 +9,7 @@ import { hikeCompleted, goToNextHike } from './actions';
import { postJSON$ } from '../../../../utils/ajax-stream'; import { postJSON$ } from '../../../../utils/ajax-stream';
import { getCurrentHike } from './selectors'; import { getCurrentHike } from './selectors';
function handleAnswer(getState, dispatch, next, action) { function handleAnswer(action, getState) {
const { const {
e, e,
answer, answer,
@ -35,7 +35,7 @@ function handleAnswer(getState, dispatch, next, action) {
// question released under threshold // question released under threshold
if (Math.abs(positionX) < threshold) { if (Math.abs(positionX) < threshold) {
return next(action); return Observable.just(null);
} }
if (positionX >= threshold) { if (positionX >= threshold) {
@ -51,27 +51,26 @@ function handleAnswer(getState, dispatch, next, action) {
// incorrect question // incorrect question
if (answer !== finalAnswer) { if (answer !== finalAnswer) {
let infoAction;
if (info) { if (info) {
dispatch(makeToast({ infoAction = makeToast({
title: 'Hint', title: 'Hint',
message: info, message: info,
type: 'info' type: 'info'
})); });
} }
return Observable return Observable
.just({ type: types.endShake }) .just({ type: types.endShake })
.delay(500) .delay(500)
.startWith({ type: types.startShake }) .startWith(infoAction, { type: types.startShake });
.doOnNext(dispatch);
} }
if (tests[currentQuestion]) { if (tests[currentQuestion]) {
return Observable return Observable
.just({ type: types.goToNextQuestion }) .just({ type: types.goToNextQuestion })
.delay(300) .delay(300)
.startWith({ type: types.primeNextQuestion }) .startWith({ type: types.primeNextQuestion });
.doOnNext(dispatch);
} }
let updateUser$; let updateUser$;
@ -119,28 +118,25 @@ function handleAnswer(getState, dispatch, next, action) {
error error
})) }))
// end with action so we know it is ok to transition // end with action so we know it is ok to transition
.doOnCompleted(() => dispatch({ type: types.transitionHike })) .concat(Observable.just({ type: types.transitionHike }));
.doOnNext(dispatch);
} }
export default () => ({ getState, dispatch }) => next => { export default function answerSaga(action$, getState) {
return function answerSaga(action) { return action$
if (action.type === types.answerQuestion) { .filter(action => {
return handleAnswer(getState, dispatch, next, action); return action.type === types.answerQuestion ||
} action.type === types.transitionHike;
})
.flatMap(action => {
if (action.type === types.answerQuestion) {
return handleAnswer(action, getState);
}
// let goToNextQuestion hit reducers first
const result = next(action);
if (action.type === types.transitionHike) {
const { hikesApp: { currentHike } } = getState(); const { hikesApp: { currentHike } } = getState();
// if no next hike currentHike will equal '' which is falsy // if no next hike currentHike will equal '' which is falsy
if (currentHike) { if (currentHike) {
dispatch(push(`/videos/${currentHike}`)); return Observable.just(push(`/videos/${currentHike}`));
} else {
dispatch(push('/map'));
} }
} return Observable.just(push('/map'));
});
return result; }
};
};

View File

@ -11,30 +11,25 @@ import { findCurrentHike } from './utils';
// const log = debug('fcc:fetch-hikes-saga'); // const log = debug('fcc:fetch-hikes-saga');
const hike = new Schema('hike', { idAttribute: 'dashedName' }); const hike = new Schema('hike', { idAttribute: 'dashedName' });
export default ({ services }) => ({ dispatch }) => next => { export default function fetchHikesSaga(action$, getState, { services }) {
return function fetchHikesSaga(action) { return action$
if (action.type !== types.fetchHikes) { .filter(action => action.type === types.fetchHikes)
return next(action); .flatMap(action => {
} const dashedName = action.payload;
return services.readService$({ service: 'hikes' })
const dashedName = action.payload; .map(hikes => {
return services.readService$({ service: 'hikes' }) const { entities, result } = normalize(
.map(hikes => { { hikes },
const { entities, result } = normalize( { hikes: arrayOf(hike) }
{ hikes }, );
{ hikes: arrayOf(hike) } const currentHike = findCurrentHike(result.hikes, dashedName);
); return fetchHikesCompleted(entities, result.hikes, currentHike);
})
const currentHike = findCurrentHike(result.hikes, dashedName); .catch(error => {
return Observable.just({
return fetchHikesCompleted(entities, result.hikes, currentHike); type: handleError,
}) error
.catch(error => { });
return Observable.just({
type: handleError,
error
}); });
}) });
.doOnNext(dispatch); }
};
};

View File

@ -4,36 +4,30 @@ import { applyPromo } from './types';
import { applyPromoCompleted } from './actions'; import { applyPromoCompleted } from './actions';
import { postJSON$ } from '../../../../utils/ajax-stream'; import { postJSON$ } from '../../../../utils/ajax-stream';
export default () => ({ dispatch }) => next => { export default function applyPromoSaga(action$) {
return function applyPromoSaga(action) { return action$
if (action.type !== applyPromo) { .filter(action => action.type === applyPromo)
return next(action); .flatMap(action => {
} const { id, code = '', type = null } = action.payload;
const body = {
id,
code: code.replace(/[^\d\w\s]/, '')
};
if (type) {
body.type = type;
}
return postJSON$('/api/promos/getButton', body)
.retry(3)
.map(({ promo }) => {
if (!promo || !promo.buttonId) {
throw new Error('No promo returned by server');
}
const { id, code = '', type = null } = action.payload; return applyPromoCompleted(promo);
})
const body = { .catch(error => Observable.just({
id, type: 'app.handleError',
code: code.replace(/[^\d\w\s]/, '') error
}; }));
});
if (type) { }
body.type = type;
}
return postJSON$('/api/promos/getButton', body)
.retry(3)
.map(({ promo }) => {
if (!promo || !promo.buttonId) {
throw new Error('No promo returned by server');
}
return applyPromoCompleted(promo);
})
.catch(error => Observable.just({
type: 'app.handleError',
error
}))
.doOnNext(dispatch);
};
};

View File

@ -7,42 +7,30 @@ import { handleError } from '../../../redux/types';
const job = new Schema('job', { idAttribute: 'id' }); const job = new Schema('job', { idAttribute: 'id' });
export default ({ services }) => ({ dispatch }) => next => { export default function fetchJobsSaga(action$, getState, { services }) {
return function fetchJobsSaga(action) { return action$
if (action.type !== fetchJobs) { .filter(action => action.type === fetchJobs)
return next(action); .flatMap(action => {
} const { payload: id } = action;
const data = { service: 'jobs' };
const { payload: id } = action; if (id) {
const data = { service: 'jobs' }; data.params = { id };
if (id) { }
data.params = { id }; return services.readService$(data)
} .map(jobs => {
if (!Array.isArray(jobs)) {
return services.readService$(data) jobs = [jobs];
.map(jobs => { }
if (!Array.isArray(jobs)) { const { entities, result } = normalize(
jobs = [jobs]; { jobs },
} { jobs: arrayOf(job) }
);
const { entities, result } = normalize( return fetchJobsCompleted(
{ jobs }, entities,
{ jobs: arrayOf(job) } result.jobs[0],
); result.jobs
);
})
return fetchJobsCompleted( .catch(error => Observable.just({ type: handleError, error }));
entities, });
result.jobs[0], }
result.jobs
);
})
.catch(error => {
return Observable.just({
type: handleError,
error
});
})
.doOnNext(dispatch);
};
};

View File

@ -6,27 +6,20 @@ import { saveJob } from './types';
import { handleError } from '../../../redux/types'; import { handleError } from '../../../redux/types';
export default ({ services }) => ({ dispatch }) => next => { export default function saveJobSaga(action$, getState, { services }) {
return function saveJobSaga(action) { return action$
const result = next(action); .filter(action => action.type === saveJob)
if (action.type !== saveJob) { .flatMap(action => {
return result; const { payload: job } = action;
} return services.createService$({ service: 'jobs', params: { job } })
const { payload: job } = action; .retry(3)
.flatMap(job => Observable.of(
return services.createService$({ saveCompleted(job),
service: 'jobs', push('/jobs/new/check-out')
params: { job } ))
}) .catch(error => Observable.just({
.retry(3) type: handleError,
.flatMap(job => Observable.of( error
saveCompleted(job), }));
push('/jobs/new/check-out') });
)) }
.catch(error => Observable.just({
type: handleError,
error
}))
.doOnNext(dispatch);
};
};

View File

@ -0,0 +1,274 @@
import React, { PropTypes } from 'react';
import classnames from 'classnames';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { goToStep } from '../../redux/actions';
import PureComponent from 'react-pure-render/component';
import ReactTransitionReplace from 'react-css-transition-replace';
import { Button, Col, Image, Row } from 'react-bootstrap';
const mapStateToProps = createSelector(
state => state.challengesApp.currentStep,
state => state.challengesApp.previousStep,
(currentStep, previousStep) => ({
currentStep,
previousStep,
isGoingForward: currentStep > previousStep
})
);
const dispatchActions = {
goToStep
};
const transitionTimeout = 1000;
/* eslint-disable max-len, quotes */
const challenge = {
title: "Learn how Free Code Camp Works",
description: [
[
"http://i.imgur.com/6ibIavQ.jpg",
"A picture of Free Code Camp's 4 benefits: Get connected, Learn JavaScript, Build your Portfolio, Help nonprofits",
"Welcome to Free Code Camp. We're an open source community of busy people who learn to code and help nonprofits.",
"http://www.foo.com"
],
[
"http://i.imgur.com/Elb3dfj.jpg",
"A screenshot of some of our campers coding together in Toronto.",
"<bold>Learning to code is hard.</bold> To succeed, you'll need lots of practice and support. That's why we've created a rigorous curriculum and supportive community.",
""
],
[
"http://i.imgur.com/D7Y5luw.jpg",
"A graph of the rate of job growth against growth in computer science degree graduates. There are 1.4 million jobs and only 400 thousand people to fill them.",
"There are thousands of coding jobs currently going unfilled, and the demand for coders grows every year.",
""
],
[
"http://i.imgur.com/WD3STY6.jpg",
"Photos of three campers who've gotten jobs after learning to code at Free Code Camp.",
"Free Code Camp is a proven path to your first coding job. In fact, no one has actually completed our entire program, because campers get jobs before they're able to.",
""
],
[
"http://i.imgur.com/vLNso6h.jpg",
"An illustration showing that you will learn HTML5, CSS3, JavaScript, Databases, Git, Node.js, React and D3.",
"We have hundreds of optional coding challenges that will teach you fundamental web development technologies like HTML5, Node.js and databases.",
""
],
[
"http://i.imgur.com/UVB9hxp.jpg",
"An image of a camper at a cafe building projects on Free Code Camp.",
"We believe humans learn best by doing. So you'll spend most of your time actually building projects. We'll give you a list of specifications (agile user stories), and you'll figure out how to build apps that fulfill those specifications.",
""
],
[
"http://i.imgur.com/pbW7K5S.jpg",
"An image of showing our front end, back end, and data visualization certifications (400 hours each), our nonprofit projects (800 hours), and interview prep (80 hours) for a total of 2,080 hours of coding experience.",
"Our curriculum is divided into 4 certifications. These certifications are standardized, and instantly verifiable by your freelance clients and future employers. Like everything else at Free Code Camp, these certifications are free. We recommend doing them in order, but you are free to jump around. The first three certifications take 400 hours each, and the final certification takes 800 hours, and involves building real-life projects for nonprofits.",
""
],
[
"http://i.imgur.com/k8btNUB.jpg",
"A screenshot of our Front End Development Certificate",
"To earn our verified Front End Development Certification, you'll build 10 projects using HTML, CSS, jQuery, and JavaScript.",
""
],
[
"http://i.imgur.com/Et3iD74.jpg",
"A screenshot of our Data Visualization Certificate",
"To earn our Data Visualization Certification, you'll build 10 projects using React, Sass and D3.js.",
""
],
[
"http://i.imgur.com/8v3t84p.jpg",
"A screenshot of our Back End Development Certificate",
"To earn our Back End Development Certification, you'll build 10 projects using Node.js, Express, and MongoDB. You'll use Git and Heroku to deploy them to the cloud.",
""
],
[
"http://i.imgur.com/yXyxbDd.jpg",
"A screen shot of our nonprofit project directory.",
"After you complete all three of these certificates, you'll team up with another camper and use agile software development methodologies to build two real-life projects for nonprofits. You'll also add functionality to two legacy code nonprofit projects. By the time you finish, you'll have a portfolio of real apps that people use every day.",
""
],
[
"http://i.imgur.com/PDGQ9ZM.jpg",
"An image of campers building projects together in a cafe in Seoul.",
"If you complete all 2,080 hours worth of challenges and projects, you'll earn our Full Stack Development Certification. We'll offer you free coding interview practice. We even offer a job board where employers specifically hire campers who've earned Free Code Camp certifications.",
"http://foo.com"
]
]
};
/* eslint-disable max-len, quotes */
export class StepChallenge extends PureComponent {
static displayName = 'StepChallenge';
static defaultProps = {
currentStep: 0,
previousStep: -1
};
static propTypes = {
currentStep: PropTypes.number,
previousStep: PropTypes.number,
isGoingForward: PropTypes.bool,
goToStep: PropTypes.func
};
renderActionButton(action) {
if (!action) {
return null;
}
return (
<div>
<Button
block={ true }
bsSize='large'
bsStyle='primary'
href={ action }
target='_blank'>
Open link in new tab (this unlocks the next step)
</Button>
<div className='spacer' />
</div>
);
}
renderBackButton(index) {
const { goToStep } = this.props;
if (index === 0) {
return (
<Col
className='hidden-xs'
md={ 4 }>
{ ' ' }
</Col>
);
}
return (
<Button
bsSize='large'
bsStyle='primary'
className='col-sm-4 col-xs-12'
onClick={ () => goToStep(index - 1) }>
Go to my previous step
</Button>
);
}
renderNextButton(hasAction, index, numOfSteps, isCompleted) {
const { goToStep } = this.props;
const isLastStep = index + 1 >= numOfSteps;
const btnClass = classnames({
'col-sm-4 col-xs-12': true,
disabled: hasAction && !isCompleted
});
return (
<Button
bsSize='large'
bsStyle='primary'
className={ btnClass }
onClick={ () => !isLastStep ? goToStep(index + 1) : () => {} }>
{ isLastStep ? 'Finish challenge' : 'Go to my next step'}
</Button>
);
}
renderStep(step, currentStep, numOfSteps) {
if (!Array.isArray(step)) {
return null;
}
const [imgUrl, imgAlt, info, action] = step;
return (
<div
className=''
key={ imgUrl }>
<a href={ imgUrl }>
<Image
alt={ imgAlt }
responsive={ true }
src={ imgUrl } />
</a>
<Row>
<div className='spacer' />
<Col
md={ 8 }
mdOffset={ 2 }
sm={ 10 }
smOffset={ 1 }
xs={ 12 }>
<p
className='challenge-step-description'
dangerouslySetInnerHTML={{ __html: info }} />
</Col>
</Row>
<div className='spacer' />
<div className='challenge-button-block'>
{ this.renderActionButton(action) }
{ this.renderBackButton(currentStep) }
<Col
className='challenge-step-counter large-p text-center'
sm={ 4 }
xs={ 12 }>
( { currentStep + 1 } / { numOfSteps })
</Col>
{
this.renderNextButton(
!!action,
currentStep,
numOfSteps,
true
)
}
</div>
<div className='clearfix' />
</div>
);
}
renderImages(steps) {
// will preload all the image
if (!Array.isArray(steps)) {
return null;
}
return steps.map(([imgUrl, imgAlt]) => (
<div key={ imgUrl }>
<Image
alt={ imgAlt }
responsive={ true }
src={ imgUrl } />
</div>
));
}
render() {
const { currentStep, isGoingForward } = this.props;
const numOfSteps = Array.isArray(challenge.description) ?
challenge.description.length :
0;
const step = challenge.description[currentStep];
const transitionName = 'challenge-step-' +
(isGoingForward ? 'forward' : 'backward');
return (
<Col
md={ 8 }
mdOffset={ 2 }>
<ReactTransitionReplace
transitionEnterTimeout={ transitionTimeout }
transitionLeaveTimeout={ transitionTimeout }
transitionName={ transitionName }>
{ this.renderStep(step, currentStep, numOfSteps) }
</ReactTransitionReplace>
<div className='hidden'>
{ this.renderImages(challenge.description) }
</div>
<div className='spacer' />
</Col>
);
}
}
export default connect(mapStateToProps, dispatchActions)(StepChallenge);

View File

@ -4,23 +4,14 @@ import { fetchChallengesCompleted } from './actions';
import { handleError } from '../../../redux/types'; import { handleError } from '../../../redux/types';
export default ({ services }) => ({ dispatch }) => next => { export default function fetchChallengesSaga(action$, getState, { services }) {
return function fetchChallengesSaga(action) { return action$
const result = next(action); .filter(action => action.type === fetchChallenges)
if (action.type !== fetchChallenges) { .flatMap(() => {
return result; return services.readService$({ service: 'map' })
} .map(({ entities, result } = {}) => {
return fetchChallengesCompleted(entities, result);
return services.readService$({ service: 'map' }) })
.map(({ entities, result } = {}) => { .catch(error => Observable.just({ type: handleError, error }));
return fetchChallengesCompleted(entities, result); });
}) }
.catch(error => {
return Observable.just({
type: handleError,
error
});
})
.doOnNext(dispatch);
};
};

View File

@ -1,42 +0,0 @@
import React, { Children, PropTypes } from 'react';
class ProfessorContext extends React.Component {
constructor(props) {
super(props);
this.professor = props.professor;
}
static displayName = 'ProfessorContext';
static propTypes = {
professor: PropTypes.object,
children: PropTypes.element.isRequired
};
static childContextTypes = {
professor: PropTypes.object
};
getChildContext() {
return { professor: this.professor };
}
render() {
return Children.only(this.props.children);
}
}
/* eslint-disable react/display-name, react/prop-types */
ProfessorContext.wrap = function wrap(Component, professor) {
const props = {};
if (professor) {
props.professor = professor;
}
return React.createElement(
ProfessorContext,
props,
Component
);
};
export default ProfessorContext;

View File

@ -1,6 +1,6 @@
import React, { PropTypes, createElement } from 'react'; import { helpers } from 'rx';
import { Observable, CompositeDisposable } from 'rx'; import { createElement } from 'react';
import shouldComponentUpdate from 'react-pure-render/function'; import PureComponent from 'react-pure-render/component';
import debug from 'debug'; import debug from 'debug';
// interface contain { // interface contain {
@ -28,17 +28,8 @@ import debug from 'debug';
const log = debug('fcc:professerx'); const log = debug('fcc:professerx');
function getChildContext(childContextTypes, currentContext) {
const compContext = { ...currentContext };
// istanbul ignore else
if (!childContextTypes || !childContextTypes.professor) {
delete compContext.professor;
}
return compContext;
}
const __DEV__ = process.env.NODE_ENV !== 'production'; const __DEV__ = process.env.NODE_ENV !== 'production';
const { isFunction } = helpers;
export default function contain(options = {}, Component) { export default function contain(options = {}, Component) {
/* istanbul ignore else */ /* istanbul ignore else */
@ -48,144 +39,78 @@ export default function contain(options = {}, Component) {
let action; let action;
let isActionable = false; let isActionable = false;
let hasRefetcher = typeof options.shouldRefetch === 'function'; let hasRefetcher = isFunction(options.shouldRefetch);
const getActionArgs = isFunction(options.getActionArgs) ?
const getActionArgs = typeof options.getActionArgs === 'function' ?
options.getActionArgs : options.getActionArgs :
(() => []); (() => []);
const isPrimed = typeof options.isPrimed === 'function' ? const isPrimed = isFunction(options.isPrimed) ?
options.isPrimed : options.isPrimed :
(() => false); (() => false);
const name = Component.displayName || 'Anon Component';
return class Container extends React.Component { function runAction(props, context, action) {
constructor(props, context) { const actionArgs = getActionArgs(props, context);
super(props, context); if (__DEV__ && !Array.isArray(actionArgs)) {
this.__subscriptions = new CompositeDisposable(); throw new TypeError(
`${name} getActionArgs should return an array but got ${actionArgs}`
);
} }
return action.apply(null, actionArgs);
}
static displayName = `Container(${Component.displayName})`;
static contextTypes = { return class Container extends PureComponent {
...Component.contextTypes, static displayName = `Container(${name})`;
professor: PropTypes.object
};
componentWillMount() { componentWillMount() {
const { professor } = this.context; const { props, context } = this;
const { props } = this;
if (!options.fetchAction) { if (!options.fetchAction) {
log(`${Component.displayName} has no fetch action defined`); log(`${name} has no fetch action defined`);
return null; return;
}
if (isPrimed(this.props, this.context)) {
log(`${name} container is primed`);
return;
} }
action = props[options.fetchAction]; action = props[options.fetchAction];
isActionable = typeof action === 'function'; isActionable = typeof action === 'function';
if (__DEV__ && typeof action !== 'function') { if (__DEV__ && !isActionable) {
throw new Error( throw new Error(
`${options.fetchAction} should return a function but got ${action}. `${options.fetchAction} should return a function but got ${action}.
Check the fetch options for ${Component.displayName}.` Check the fetch options for ${name}.`
); );
} }
if ( runAction(
!professor ||
!professor.fetchContext
) {
log(
`${Component.displayName} did not have professor defined on context`
);
return null;
}
const actionArgs = getActionArgs(
props, props,
getChildContext(Component.contextTypes, this.context) context,
action
); );
return professor.fetchContext.push({
name: options.fetchAction,
action,
actionArgs,
component: Component.displayName || 'Anon'
});
}
componentDidMount() {
if (isPrimed(this.props, this.context)) {
log('container is primed');
return null;
}
if (!isActionable) {
log(`${Component.displayName} container is not actionable`);
return null;
}
const actionArgs = getActionArgs(this.props, this.context);
const fetch$ = action.apply(null, actionArgs);
if (__DEV__ && !Observable.isObservable(fetch$)) {
console.log(fetch$);
throw new Error(
`Action creator should return an Observable but got ${fetch$}.
Check the action creator for fetch action ${options.fetchAction}`
);
}
const subscription = fetch$.subscribe(
() => {},
options.handleError
);
return this.__subscriptions.add(subscription);
} }
componentWillReceiveProps(nextProps, nextContext) { componentWillReceiveProps(nextProps, nextContext) {
if ( if (
!isActionable || !isActionable ||
!hasRefetcher || !hasRefetcher ||
!options.shouldRefetch( !options.shouldRefetch(this.props, nextProps, this.context, nextContext)
this.props,
nextProps,
getChildContext(Component.contextTypes, this.context),
getChildContext(Component.contextTypes, nextContext)
)
) { ) {
return; return;
} }
const actionArgs = getActionArgs(
this.props, runAction(
getChildContext(Component.contextTypes, this.context) nextProps,
nextContext,
action
); );
const fetch$ = action.apply(null, actionArgs);
if (__DEV__ && !Observable.isObservable(fetch$)) {
throw new Error(
'fetch action should return observable'
);
}
const subscription = fetch$.subscribe(
() => {},
options.errorHandler
);
this.__subscriptions.add(subscription);
} }
componentWillUnmount() {
if (this.__subscriptions) {
this.__subscriptions.dispose();
}
}
shouldComponentUpdate = shouldComponentUpdate;
render() { render() {
const { props } = this;
return createElement( return createElement(
Component, Component,
props this.props
); );
} }
}; };

View File

@ -0,0 +1,57 @@
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

@ -0,0 +1,203 @@
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,52 +1,24 @@
import { Observable, Scheduler } from 'rx'; import { Observable } from 'rx';
import ReactDOM from 'react-dom/server'; import ReactDOM from 'react-dom/server';
import debug from 'debug'; import debug from 'debug';
import ProfessorContext from './Professor-Context';
const log = debug('fcc:professor'); const log = debug('fcc:professor');
export function fetch({ fetchContext = [] }) { export default function renderToString(Component, sagaMiddleware) {
if (fetchContext.length === 0) {
log('empty fetch context found');
return Observable.just(fetchContext);
}
return Observable.from(fetchContext, null, null, Scheduler.default)
.doOnNext(({ name }) => log(`calling ${name} action creator`))
.map(({ action, actionArgs }) => action.apply(null, actionArgs))
.doOnNext(fetch$ => {
if (!Observable.isObservable(fetch$)) {
throw new Error(
'action creator should return an observable'
);
}
})
.map(fetch$ => fetch$.doOnNext(action => log('action', action.type)))
.mergeAll()
.doOnCompleted(() => log('all fetch observables completed'));
}
export default function renderToString(Component) {
const fetchContext = [];
const professor = { fetchContext };
let ContextedComponent;
try { try {
ContextedComponent = ProfessorContext.wrap(Component, professor); log('initial render');
log('initiating fetcher registration'); ReactDOM.renderToStaticMarkup(Component);
ReactDOM.renderToStaticMarkup(ContextedComponent); log('initial render completed');
log('fetcher registration completed');
} catch (e) { } catch (e) {
return Observable.throw(e); return Observable.throw(e);
} }
return fetch(professor) sagaMiddleware.end();
.last() return Observable.merge(sagaMiddleware)
.last({ defaultValue: null })
.delay(0) .delay(0)
.map(() => { .map(() => {
sagaMiddleware.restart();
const markup = ReactDOM.renderToString(Component); const markup = ReactDOM.renderToString(Component);
return { return { markup };
markup,
fetchContext
};
}); });
} }

View File

@ -1,18 +1,10 @@
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import { Disposable, Observable } from 'rx'; import { Disposable, Observable } from 'rx';
import ProfessorContext from './Professor-Context';
export default function render(Component, DOMContainer) { export default function render(Component, DOMContainer) {
let ContextedComponent;
try {
ContextedComponent = ProfessorContext.wrap(Component);
} catch (e) {
return Observable.throw(e);
}
return Observable.create(observer => { return Observable.create(observer => {
try { try {
ReactDOM.render(ContextedComponent, DOMContainer, function() { ReactDOM.render(Component, DOMContainer, function() {
observer.onNext(this); observer.onNext(this);
}); });
} catch (e) { } catch (e) {

View File

@ -199,8 +199,8 @@ gulp.task('serve', function(cb) {
ignore: paths.serverIgnore, ignore: paths.serverIgnore,
exec: path.join(__dirname, 'node_modules/.bin/babel-node'), exec: path.join(__dirname, 'node_modules/.bin/babel-node'),
env: { env: {
'NODE_ENV': process.env.NODE_ENV || 'development', NODE_ENV: process.env.NODE_ENV || 'development',
'DEBUG': process.env.DEBUG || 'fcc:*' DEBUG: process.env.DEBUG || 'fcc:*'
} }
}) })
.on('start', function() { .on('start', function() {

View File

@ -55,18 +55,20 @@ export default function reactSubRouter(app) {
} }
return !!props; return !!props;
}) })
.flatMap(({ props, store }) => { .flatMap(({ props, store, epic }) => {
log('render react markup and pre-fetch data'); log('render react markup and pre-fetch data');
return renderToString( return renderToString(
provideStore(React.createElement(RouterContext, props), store) provideStore(React.createElement(RouterContext, props), store),
epic
) )
.map(({ markup }) => ({ markup, store })); .map(({ markup }) => ({ markup, store, epic }));
}) })
.flatMap(function({ markup, store }) { .flatMap(function({ markup, store, epic }) {
log('react markup rendered, data fetched'); log('react markup rendered, data fetched');
const state = store.getState(); const state = store.getState();
const { title } = state.app.title; const { title } = state.app.title;
epic.dispose();
res.expose(state, 'data'); res.expose(state, 'data');
return res.render$( return res.render$(
'layout-react', 'layout-react',