diff --git a/client/index.js b/client/index.js index f00e2c74b8..f373787262 100644 --- a/client/index.js +++ b/client/index.js @@ -39,17 +39,20 @@ const routingMiddleware = syncHistory(history); const devTools = window.devToolsExtension ? window.devToolsExtension() : f => f; const shouldRouterListenForReplays = !!window.devToolsExtension; -const clientSagaOptions = { doc: document }; +const sagaOptions = { + window, + document: window.document, + location: window.location +}; createApp({ history, serviceOptions, initialState, - middlewares: [ - routingMiddleware, - ...sagas.map(saga => saga(clientSagaOptions)) - ], + middlewares: [ routingMiddleware ], + sagas, + sagaOptions, reducers: { routing }, enhancers: [ devTools ] }) diff --git a/client/sagas/err-saga.js b/client/sagas/err-saga.js index 968aa2b9e0..d048f34332 100644 --- a/client/sagas/err-saga.js +++ b/client/sagas/err-saga.js @@ -1,20 +1,14 @@ -// () => -// (store: Store) => -// (next: (action: Action) => Object) => -// errSaga(action: Action) => Object|Void -export default () => ({ dispatch }) => next => { - return function errorSaga(action) { - const result = next(action); - if (!action.error) { return result; } - - console.error(action.error); - return dispatch({ +export default function errorSaga(action$) { + return action$ + .filter(({ error }) => !!error) + .map(({ error }) => error) + .doOnNext(error => console.error(error)) + .map(() => ({ type: 'app.makeToast', payload: { type: 'error', title: 'Oops, something went wrong', message: 'Something went wrong, please try again later' } - }); - }; -}; + })); +} diff --git a/client/sagas/hard-go-to-saga.js b/client/sagas/hard-go-to-saga.js index 2fa37cc6fc..d02cd69d6a 100644 --- a/client/sagas/hard-go-to-saga.js +++ b/client/sagas/hard-go-to-saga.js @@ -1,24 +1,16 @@ import { hardGoTo } from '../../common/app/redux/types'; -const loc = typeof window !== 'undefined' ? - window.location : - {}; - -export default () => ({ dispatch }) => next => { - return function hardGoToSaga(action) { - const result = next(action); - if (action.type !== hardGoTo) { - return result; - } - - if (!loc.pathname) { - dispatch({ - type: 'app.error', - error: new Error('no location object found') - }); - } - - loc.pathname = action.payload || '/map'; - return null; - }; -}; +export default function hardGoToSaga(action$, getState, { location }) { + return action$ + .filter(({ type }) => type === hardGoTo) + .map(({ payload = '/map' }) => { + if (!location.pathname) { + return { + type: 'app.error', + error: new Error('no location object found') + }; + } + location.pathname = payload; + return null; + }); +} diff --git a/client/sagas/local-storage-saga.js b/client/sagas/local-storage-saga.js index ecf69bcf31..d6e60dc20e 100644 --- a/client/sagas/local-storage-saga.js +++ b/client/sagas/local-storage-saga.js @@ -1,3 +1,4 @@ +import store from 'store'; import { saveForm, clearForm, @@ -10,60 +11,33 @@ import { } from '../../common/app/routes/Jobs/redux/actions'; const formKey = 'newJob'; -let enabled = false; -let store = typeof window !== 'undefined' ? - window.localStorage : - false; -try { - const testKey = '__testKey__'; - store.setItem(testKey, testKey); - enabled = store.getItem(testKey) === testKey; - store.removeItem(testKey); -} catch (e) { - enabled = !e; -} - -if (!enabled) { - console.error(new Error('No localStorage found')); -} - -export default () => ({ dispatch }) => next => { - return function localStorageSaga(action) { - if (!enabled) { return next(action); } - - 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 - }); +export default function localStorageSaga(action$) { + return action$ + .filter(action => { + return action.type === saveForm || + action.type === clearForm || + action.type === loadSavedForm; + }) + .map(action => { + if (action.type === saveForm) { + const form = action.payload; + try { + store.setItem(formKey, form); + return saveCompleted(form); + } catch (error) { + return { + type: 'app.handleError', + error + }; + } } - } - if (action.type === clearForm) { - store.removeItem(formKey); - 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 - }); + if (action.type === clearForm) { + store.removeItem(formKey); + return null; } - } - return next(action); - }; -}; + return loadSavedFormCompleted(store.getItem(formKey)); + }); +} diff --git a/client/sagas/title-saga.js b/client/sagas/title-saga.js index c4a2a09eca..9ee326f7f7 100644 --- a/client/sagas/title-saga.js +++ b/client/sagas/title-saga.js @@ -1,17 +1,10 @@ -// (doc: Object) => -// () => -// (next: (action: Action) => Object) => -// titleSage(action: Action) => Object|Void -export default ({ doc }) => ({ getState }) => next => { - return function titleSage(action) { - // get next state - const result = next(action); - if (action.type !== 'app.updateTitle') { - return result; - } - const state = getState(); - const newTitle = state.app.title; - doc.title = newTitle; - return result; - }; -}; +export default function titleSage(action$, getState, { document }) { + return action$ + .filter(action => action.type === 'app.updateTitle') + .map(() => { + const state = getState(); + const newTitle = state.app.title; + document.title = newTitle; + return null; + }); +} diff --git a/common/app/create-app.jsx b/common/app/create-app.jsx index f07a830ab2..27f8c30547 100644 --- a/common/app/create-app.jsx +++ b/common/app/create-app.jsx @@ -8,6 +8,7 @@ import App from './App.jsx'; import childRoutes from './routes'; // redux +import createEpic from './utils/redux-epic'; import createReducer from './create-reducer'; import middlewares from './middlewares'; import sagas from './sagas'; @@ -40,17 +41,24 @@ export default function createApp({ middlewares: sideMiddlewares = [], enhancers: sideEnhancers = [], reducers: sideReducers = {}, - sagas: sideSagas = [] + sagas: sideSagas = [], + sagaOptions: sideSagaOptions = {} }) { const sagaOptions = { + ...sideSagaOptions, services: servicesCreator(null, serviceOptions) }; + const sagaMiddleware = createEpic( + sagaOptions, + ...sagas, + ...sideSagas + ); const enhancers = [ applyMiddleware( ...middlewares, ...sideMiddlewares, - ...[ ...sagas, ...sideSagas].map(saga => saga(sagaOptions)), + sagaMiddleware ), // enhancers must come after middlewares // on client side these are things like Redux DevTools @@ -74,6 +82,7 @@ export default function createApp({ redirect, props, reducer, - store + store, + epic: sagaMiddleware })); } diff --git a/common/app/redux/actions.js b/common/app/redux/actions.js index f1af64754f..b89a2494af 100644 --- a/common/app/redux/actions.js +++ b/common/app/redux/actions.js @@ -1,3 +1,4 @@ +import { Observable } from 'rx'; import { createAction } from 'redux-actions'; import types from './types'; @@ -39,3 +40,8 @@ export const updateNavHeight = createAction(types.updateNavHeight); export const updateChallengesData = createAction(types.updateChallengesData); export const updateJobsData = createAction(types.updateJobsData); export const updateHikesData = createAction(types.updateHikesData); + +export const createErrorObserable = error => Observable.just({ + type: types.handleError, + error +}); diff --git a/common/app/redux/fetch-user-saga.js b/common/app/redux/fetch-user-saga.js index 63060eb844..ac85e0bb87 100644 --- a/common/app/redux/fetch-user-saga.js +++ b/common/app/redux/fetch-user-saga.js @@ -1,24 +1,20 @@ import { Observable } from 'rx'; import { handleError, setUser, fetchUser } from './types'; -export default ({ services }) => ({ dispatch }) => next => { - return function getUserSaga(action) { - if (action.type !== fetchUser) { - return next(action); - } - - return services.readService$({ service: 'user' }) - .map((user) => { - return { - type: setUser, - payload: user - }; - }) - .catch(error => Observable.just({ - type: handleError, - error - })) - .doOnNext(dispatch); - }; -}; - +export default function getUserSaga(action$, getState, { services }) { + return action$ + .filter(action => action.type === fetchUser) + .flatMap(() => { + return services.readService$({ service: 'user' }) + .map(user => { + return { + type: setUser, + payload: user + }; + }) + .catch(error => Observable.just({ + type: handleError, + error + })); + }); +} diff --git a/common/app/routes/Hikes/components/Questions.jsx b/common/app/routes/Hikes/components/Questions.jsx index 5bf14a3e83..55911b6d02 100644 --- a/common/app/routes/Hikes/components/Questions.jsx +++ b/common/app/routes/Hikes/components/Questions.jsx @@ -2,7 +2,6 @@ import React, { PropTypes } from 'react'; import { spring, Motion } from 'react-motion'; import { connect } from 'react-redux'; import { Button, Col, Row } from 'react-bootstrap'; -import { CompositeDisposable } from 'rx'; import { createSelector } from 'reselect'; import { @@ -54,11 +53,6 @@ const mapStateToProps = createSelector( ); class Question extends React.Component { - constructor(...args) { - super(...args); - this._subscriptions = new CompositeDisposable(); - } - static displayName = 'Questions'; static propTypes = { @@ -78,10 +72,6 @@ class Question extends React.Component { shouldShakeQuestion: PropTypes.bool }; - componentWillUnmount() { - this._subscriptions.dispose(); - } - handleMouseUp(e, answer, info) { e.stopPropagation(); if (!this.props.isPressed) { @@ -94,16 +84,12 @@ class Question extends React.Component { } = this.props; releaseQuestion(); - const subscription = answerQuestion({ + return answerQuestion({ e, answer, info, threshold: answerThreshold - }) - .subscribe(); - - this._subscriptions.add(subscription); - return null; + }); } handleMouseMove(isPressed, { delta, moveQuestion }) { @@ -115,21 +101,17 @@ class Question extends React.Component { onAnswer(answer, userAnswer, info) { const { isSignedIn, answerQuestion } = this.props; - const subscriptions = this._subscriptions; return e => { if (e && e.preventDefault) { e.preventDefault(); } - const subscription = answerQuestion({ + answerQuestion({ answer, userAnswer, info, isSignedIn - }) - .subscribe(); - - subscriptions.add(subscription); + }); }; } diff --git a/common/app/routes/Hikes/redux/answer-saga.js b/common/app/routes/Hikes/redux/answer-saga.js index 8189f74009..be179f5e2c 100644 --- a/common/app/routes/Hikes/redux/answer-saga.js +++ b/common/app/routes/Hikes/redux/answer-saga.js @@ -9,7 +9,7 @@ import { hikeCompleted, goToNextHike } from './actions'; import { postJSON$ } from '../../../../utils/ajax-stream'; import { getCurrentHike } from './selectors'; -function handleAnswer(getState, dispatch, next, action) { +function handleAnswer(action, getState) { const { e, answer, @@ -35,7 +35,7 @@ function handleAnswer(getState, dispatch, next, action) { // question released under threshold if (Math.abs(positionX) < threshold) { - return next(action); + return Observable.just(null); } if (positionX >= threshold) { @@ -51,27 +51,26 @@ function handleAnswer(getState, dispatch, next, action) { // incorrect question if (answer !== finalAnswer) { + let infoAction; if (info) { - dispatch(makeToast({ + infoAction = makeToast({ title: 'Hint', message: info, type: 'info' - })); + }); } return Observable .just({ type: types.endShake }) .delay(500) - .startWith({ type: types.startShake }) - .doOnNext(dispatch); + .startWith(infoAction, { type: types.startShake }); } if (tests[currentQuestion]) { return Observable .just({ type: types.goToNextQuestion }) .delay(300) - .startWith({ type: types.primeNextQuestion }) - .doOnNext(dispatch); + .startWith({ type: types.primeNextQuestion }); } let updateUser$; @@ -119,28 +118,25 @@ function handleAnswer(getState, dispatch, next, action) { error })) // end with action so we know it is ok to transition - .doOnCompleted(() => dispatch({ type: types.transitionHike })) - .doOnNext(dispatch); + .concat(Observable.just({ type: types.transitionHike })); } -export default () => ({ getState, dispatch }) => next => { - return function answerSaga(action) { - if (action.type === types.answerQuestion) { - return handleAnswer(getState, dispatch, next, action); - } +export default function answerSaga(action$, getState) { + return action$ + .filter(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(); // if no next hike currentHike will equal '' which is falsy if (currentHike) { - dispatch(push(`/videos/${currentHike}`)); - } else { - dispatch(push('/map')); + return Observable.just(push(`/videos/${currentHike}`)); } - } - - return result; - }; -}; + return Observable.just(push('/map')); + }); +} diff --git a/common/app/routes/Hikes/redux/fetch-hikes-saga.js b/common/app/routes/Hikes/redux/fetch-hikes-saga.js index e8da7de051..f60f440b33 100644 --- a/common/app/routes/Hikes/redux/fetch-hikes-saga.js +++ b/common/app/routes/Hikes/redux/fetch-hikes-saga.js @@ -11,30 +11,25 @@ import { findCurrentHike } from './utils'; // const log = debug('fcc:fetch-hikes-saga'); const hike = new Schema('hike', { idAttribute: 'dashedName' }); -export default ({ services }) => ({ dispatch }) => next => { - return function fetchHikesSaga(action) { - if (action.type !== types.fetchHikes) { - return next(action); - } - - const dashedName = action.payload; - return services.readService$({ service: 'hikes' }) - .map(hikes => { - const { entities, result } = normalize( - { hikes }, - { hikes: arrayOf(hike) } - ); - - const currentHike = findCurrentHike(result.hikes, dashedName); - - return fetchHikesCompleted(entities, result.hikes, currentHike); - }) - .catch(error => { - return Observable.just({ - type: handleError, - error +export default function fetchHikesSaga(action$, getState, { services }) { + return action$ + .filter(action => action.type === types.fetchHikes) + .flatMap(action => { + const dashedName = action.payload; + return services.readService$({ service: 'hikes' }) + .map(hikes => { + const { entities, result } = normalize( + { hikes }, + { hikes: arrayOf(hike) } + ); + const currentHike = findCurrentHike(result.hikes, dashedName); + return fetchHikesCompleted(entities, result.hikes, currentHike); + }) + .catch(error => { + return Observable.just({ + type: handleError, + error + }); }); - }) - .doOnNext(dispatch); - }; -}; + }); +} diff --git a/common/app/routes/Jobs/redux/apply-promo-saga.js b/common/app/routes/Jobs/redux/apply-promo-saga.js index 4268383e35..102dff7bcc 100644 --- a/common/app/routes/Jobs/redux/apply-promo-saga.js +++ b/common/app/routes/Jobs/redux/apply-promo-saga.js @@ -4,36 +4,30 @@ import { applyPromo } from './types'; import { applyPromoCompleted } from './actions'; import { postJSON$ } from '../../../../utils/ajax-stream'; -export default () => ({ dispatch }) => next => { - return function applyPromoSaga(action) { - if (action.type !== applyPromo) { - return next(action); - } +export default function applyPromoSaga(action$) { + return action$ + .filter(action => action.type === applyPromo) + .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; - - 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'); - } - - return applyPromoCompleted(promo); - }) - .catch(error => Observable.just({ - type: 'app.handleError', - error - })) - .doOnNext(dispatch); - }; -}; + return applyPromoCompleted(promo); + }) + .catch(error => Observable.just({ + type: 'app.handleError', + error + })); + }); +} diff --git a/common/app/routes/Jobs/redux/fetch-jobs-saga.js b/common/app/routes/Jobs/redux/fetch-jobs-saga.js index d843b18be9..1d8c4244dc 100644 --- a/common/app/routes/Jobs/redux/fetch-jobs-saga.js +++ b/common/app/routes/Jobs/redux/fetch-jobs-saga.js @@ -7,42 +7,30 @@ import { handleError } from '../../../redux/types'; const job = new Schema('job', { idAttribute: 'id' }); -export default ({ services }) => ({ dispatch }) => next => { - return function fetchJobsSaga(action) { - if (action.type !== fetchJobs) { - return next(action); - } - - const { payload: id } = action; - const data = { service: 'jobs' }; - if (id) { - data.params = { id }; - } - - return services.readService$(data) - .map(jobs => { - if (!Array.isArray(jobs)) { - jobs = [jobs]; - } - - const { entities, result } = normalize( - { jobs }, - { jobs: arrayOf(job) } - ); - - - return fetchJobsCompleted( - entities, - result.jobs[0], - result.jobs - ); - }) - .catch(error => { - return Observable.just({ - type: handleError, - error - }); - }) - .doOnNext(dispatch); - }; -}; +export default function fetchJobsSaga(action$, getState, { services }) { + return action$ + .filter(action => action.type === fetchJobs) + .flatMap(action => { + const { payload: id } = action; + const data = { service: 'jobs' }; + if (id) { + data.params = { id }; + } + return services.readService$(data) + .map(jobs => { + if (!Array.isArray(jobs)) { + jobs = [jobs]; + } + const { entities, result } = normalize( + { jobs }, + { jobs: arrayOf(job) } + ); + return fetchJobsCompleted( + entities, + result.jobs[0], + result.jobs + ); + }) + .catch(error => Observable.just({ type: handleError, error })); + }); +} diff --git a/common/app/routes/Jobs/redux/save-job-saga.js b/common/app/routes/Jobs/redux/save-job-saga.js index 0bb3c2c562..f9158b1245 100644 --- a/common/app/routes/Jobs/redux/save-job-saga.js +++ b/common/app/routes/Jobs/redux/save-job-saga.js @@ -6,27 +6,20 @@ import { saveJob } from './types'; import { handleError } from '../../../redux/types'; -export default ({ services }) => ({ dispatch }) => next => { - return function saveJobSaga(action) { - const result = next(action); - if (action.type !== saveJob) { - return result; - } - const { payload: job } = action; - - return services.createService$({ - service: 'jobs', - params: { job } - }) - .retry(3) - .flatMap(job => Observable.of( - saveCompleted(job), - push('/jobs/new/check-out') - )) - .catch(error => Observable.just({ - type: handleError, - error - })) - .doOnNext(dispatch); - }; -}; +export default function saveJobSaga(action$, getState, { services }) { + return action$ + .filter(action => action.type === saveJob) + .flatMap(action => { + const { payload: job } = action; + return services.createService$({ service: 'jobs', params: { job } }) + .retry(3) + .flatMap(job => Observable.of( + saveCompleted(job), + push('/jobs/new/check-out') + )) + .catch(error => Observable.just({ + type: handleError, + error + })); + }); +} diff --git a/common/app/routes/challenges/step/Step.jsx b/common/app/routes/challenges/step/Step.jsx new file mode 100644 index 0000000000..3d22bf6960 --- /dev/null +++ b/common/app/routes/challenges/step/Step.jsx @@ -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.", + "Learning to code is hard. 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 ( +
+ +
+
+ ); + } + + renderBackButton(index) { + const { goToStep } = this.props; + if (index === 0) { + return ( + + { ' ' } + + ); + } + return ( + + ); + } + + 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 ( + + ); + } + + renderStep(step, currentStep, numOfSteps) { + if (!Array.isArray(step)) { + return null; + } + const [imgUrl, imgAlt, info, action] = step; + return ( +
+ + { + + +
+ +

+ + +

+
+ { this.renderActionButton(action) } + { this.renderBackButton(currentStep) } + + ( { currentStep + 1 } / { numOfSteps }) + + { + this.renderNextButton( + !!action, + currentStep, + numOfSteps, + true + ) + } +
+
+
+ ); + } + + renderImages(steps) { + // will preload all the image + if (!Array.isArray(steps)) { + return null; + } + return steps.map(([imgUrl, imgAlt]) => ( +
+ { +
+ )); + } + + 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 ( + + + { this.renderStep(step, currentStep, numOfSteps) } + +
+ { this.renderImages(challenge.description) } +
+
+ + ); + } +} + +export default connect(mapStateToProps, dispatchActions)(StepChallenge); diff --git a/common/app/routes/map/redux/fetch-challenges-saga.js b/common/app/routes/map/redux/fetch-challenges-saga.js index 067f788b0b..7091033c28 100644 --- a/common/app/routes/map/redux/fetch-challenges-saga.js +++ b/common/app/routes/map/redux/fetch-challenges-saga.js @@ -4,23 +4,14 @@ import { fetchChallengesCompleted } from './actions'; import { handleError } from '../../../redux/types'; -export default ({ services }) => ({ dispatch }) => next => { - return function fetchChallengesSaga(action) { - const result = next(action); - if (action.type !== fetchChallenges) { - return result; - } - - return services.readService$({ service: 'map' }) - .map(({ entities, result } = {}) => { - return fetchChallengesCompleted(entities, result); - }) - .catch(error => { - return Observable.just({ - type: handleError, - error - }); - }) - .doOnNext(dispatch); - }; -}; +export default function fetchChallengesSaga(action$, getState, { services }) { + return action$ + .filter(action => action.type === fetchChallenges) + .flatMap(() => { + return services.readService$({ service: 'map' }) + .map(({ entities, result } = {}) => { + return fetchChallengesCompleted(entities, result); + }) + .catch(error => Observable.just({ type: handleError, error })); + }); +} diff --git a/common/app/utils/Professor-Context.js b/common/app/utils/Professor-Context.js deleted file mode 100644 index 0ba3133ff3..0000000000 --- a/common/app/utils/Professor-Context.js +++ /dev/null @@ -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; diff --git a/common/app/utils/professor-x.js b/common/app/utils/professor-x.js index d4b3e5f1a9..890ee8532d 100644 --- a/common/app/utils/professor-x.js +++ b/common/app/utils/professor-x.js @@ -1,6 +1,6 @@ -import React, { PropTypes, createElement } from 'react'; -import { Observable, CompositeDisposable } from 'rx'; -import shouldComponentUpdate from 'react-pure-render/function'; +import { helpers } from 'rx'; +import { createElement } from 'react'; +import PureComponent from 'react-pure-render/component'; import debug from 'debug'; // interface contain { @@ -28,17 +28,8 @@ import debug from 'debug'; 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 { isFunction } = helpers; export default function contain(options = {}, Component) { /* istanbul ignore else */ @@ -48,144 +39,78 @@ export default function contain(options = {}, Component) { let action; let isActionable = false; - let hasRefetcher = typeof options.shouldRefetch === 'function'; - - const getActionArgs = typeof options.getActionArgs === 'function' ? + let hasRefetcher = isFunction(options.shouldRefetch); + const getActionArgs = isFunction(options.getActionArgs) ? options.getActionArgs : (() => []); - const isPrimed = typeof options.isPrimed === 'function' ? + const isPrimed = isFunction(options.isPrimed) ? options.isPrimed : (() => false); + const name = Component.displayName || 'Anon Component'; - return class Container extends React.Component { - constructor(props, context) { - super(props, context); - this.__subscriptions = new CompositeDisposable(); + 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); + } - static displayName = `Container(${Component.displayName})`; - static contextTypes = { - ...Component.contextTypes, - professor: PropTypes.object - }; + + return class Container extends PureComponent { + static displayName = `Container(${name})`; componentWillMount() { - const { professor } = this.context; - const { props } = this; + const { props, context } = this; if (!options.fetchAction) { - log(`${Component.displayName} has no fetch action defined`); - return null; + 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__ && typeof action !== 'function') { + if (__DEV__ && !isActionable) { throw new Error( `${options.fetchAction} should return a function but got ${action}. - Check the fetch options for ${Component.displayName}.` + Check the fetch options for ${name}.` ); } - if ( - !professor || - !professor.fetchContext - ) { - log( - `${Component.displayName} did not have professor defined on context` - ); - return null; - } - - - const actionArgs = getActionArgs( + runAction( 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) { if ( !isActionable || !hasRefetcher || - !options.shouldRefetch( - this.props, - nextProps, - getChildContext(Component.contextTypes, this.context), - getChildContext(Component.contextTypes, nextContext) - ) + !options.shouldRefetch(this.props, nextProps, this.context, nextContext) ) { return; } - const actionArgs = getActionArgs( - this.props, - getChildContext(Component.contextTypes, this.context) + + runAction( + 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() { - const { props } = this; - return createElement( Component, - props + this.props ); } }; diff --git a/common/app/utils/redux-epic.js b/common/app/utils/redux-epic.js new file mode 100644 index 0000000000..0f8fbc4018 --- /dev/null +++ b/common/app/utils/redux-epic.js @@ -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; +}; diff --git a/common/app/utils/redux-epic.test.js b/common/app/utils/redux-epic.test.js new file mode 100644 index 0000000000..603a039b6d --- /dev/null +++ b/common/app/utils/redux-epic.test.js @@ -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' }); +}); diff --git a/common/app/utils/render-to-string.js b/common/app/utils/render-to-string.js index 70560a722c..a7fc41f64e 100644 --- a/common/app/utils/render-to-string.js +++ b/common/app/utils/render-to-string.js @@ -1,52 +1,24 @@ -import { Observable, Scheduler } from 'rx'; +import { Observable } from 'rx'; import ReactDOM from 'react-dom/server'; import debug from 'debug'; -import ProfessorContext from './Professor-Context'; - const log = debug('fcc:professor'); -export function fetch({ fetchContext = [] }) { - 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; +export default function renderToString(Component, sagaMiddleware) { try { - ContextedComponent = ProfessorContext.wrap(Component, professor); - log('initiating fetcher registration'); - ReactDOM.renderToStaticMarkup(ContextedComponent); - log('fetcher registration completed'); + log('initial render'); + ReactDOM.renderToStaticMarkup(Component); + log('initial render completed'); } catch (e) { return Observable.throw(e); } - return fetch(professor) - .last() + sagaMiddleware.end(); + return Observable.merge(sagaMiddleware) + .last({ defaultValue: null }) .delay(0) .map(() => { + sagaMiddleware.restart(); const markup = ReactDOM.renderToString(Component); - return { - markup, - fetchContext - }; + return { markup }; }); } diff --git a/common/app/utils/render.js b/common/app/utils/render.js index c30c682740..3bfda77633 100644 --- a/common/app/utils/render.js +++ b/common/app/utils/render.js @@ -1,18 +1,10 @@ import ReactDOM from 'react-dom'; import { Disposable, Observable } from 'rx'; -import ProfessorContext from './Professor-Context'; export default function render(Component, DOMContainer) { - let ContextedComponent; - try { - ContextedComponent = ProfessorContext.wrap(Component); - } catch (e) { - return Observable.throw(e); - } - return Observable.create(observer => { try { - ReactDOM.render(ContextedComponent, DOMContainer, function() { + ReactDOM.render(Component, DOMContainer, function() { observer.onNext(this); }); } catch (e) { diff --git a/gulpfile.js b/gulpfile.js index a98ed1b5b7..9b9fe3b29a 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -199,8 +199,8 @@ gulp.task('serve', function(cb) { ignore: paths.serverIgnore, exec: path.join(__dirname, 'node_modules/.bin/babel-node'), env: { - 'NODE_ENV': process.env.NODE_ENV || 'development', - 'DEBUG': process.env.DEBUG || 'fcc:*' + NODE_ENV: process.env.NODE_ENV || 'development', + DEBUG: process.env.DEBUG || 'fcc:*' } }) .on('start', function() { diff --git a/server/boot/a-react.js b/server/boot/a-react.js index 730d891da3..0e1259a933 100644 --- a/server/boot/a-react.js +++ b/server/boot/a-react.js @@ -55,18 +55,20 @@ export default function reactSubRouter(app) { } return !!props; }) - .flatMap(({ props, store }) => { + .flatMap(({ props, store, epic }) => { log('render react markup and pre-fetch data'); 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'); const state = store.getState(); const { title } = state.app.title; + epic.dispose(); res.expose(state, 'data'); return res.render$( 'layout-react',