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.",
+ "