Add new rx saga
This commit is contained in:
@ -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 ]
|
||||
})
|
||||
|
@ -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'
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
@ -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({
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
loc.pathname = action.payload || '/map';
|
||||
return null;
|
||||
};
|
||||
};
|
||||
|
@ -1,3 +1,4 @@
|
||||
import store from 'store';
|
||||
import {
|
||||
saveForm,
|
||||
clearForm,
|
||||
@ -10,39 +11,25 @@ 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); }
|
||||
|
||||
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, JSON.stringify(form));
|
||||
next(action);
|
||||
return dispatch(saveCompleted(form));
|
||||
store.setItem(formKey, form);
|
||||
return saveCompleted(form);
|
||||
} catch (error) {
|
||||
return dispatch({
|
||||
return {
|
||||
type: 'app.handleError',
|
||||
error
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -51,19 +38,6 @@ export default () => ({ dispatch }) => next => {
|
||||
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 loadSavedFormCompleted(store.getItem(formKey));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return next(action);
|
||||
};
|
||||
};
|
||||
|
@ -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;
|
||||
}
|
||||
export default function titleSage(action$, getState, { document }) {
|
||||
return action$
|
||||
.filter(action => action.type === 'app.updateTitle')
|
||||
.map(() => {
|
||||
const state = getState();
|
||||
const newTitle = state.app.title;
|
||||
doc.title = newTitle;
|
||||
return result;
|
||||
};
|
||||
};
|
||||
document.title = newTitle;
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
@ -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
|
||||
}));
|
||||
}
|
||||
|
@ -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
|
||||
});
|
||||
|
@ -1,14 +1,12 @@
|
||||
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);
|
||||
}
|
||||
|
||||
export default function getUserSaga(action$, getState, { services }) {
|
||||
return action$
|
||||
.filter(action => action.type === fetchUser)
|
||||
.flatMap(() => {
|
||||
return services.readService$({ service: 'user' })
|
||||
.map((user) => {
|
||||
.map(user => {
|
||||
return {
|
||||
type: setUser,
|
||||
payload: user
|
||||
@ -17,8 +15,6 @@ export default ({ services }) => ({ dispatch }) => next => {
|
||||
.catch(error => Observable.just({
|
||||
type: handleError,
|
||||
error
|
||||
}))
|
||||
.doOnNext(dispatch);
|
||||
};
|
||||
};
|
||||
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
@ -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);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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) {
|
||||
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(getState, dispatch, next, action);
|
||||
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 Observable.just(push('/map'));
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
};
|
||||
|
@ -11,12 +11,10 @@ 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);
|
||||
}
|
||||
|
||||
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 => {
|
||||
@ -24,9 +22,7 @@ export default ({ services }) => ({ dispatch }) => next => {
|
||||
{ hikes },
|
||||
{ hikes: arrayOf(hike) }
|
||||
);
|
||||
|
||||
const currentHike = findCurrentHike(result.hikes, dashedName);
|
||||
|
||||
return fetchHikesCompleted(entities, result.hikes, currentHike);
|
||||
})
|
||||
.catch(error => {
|
||||
@ -34,7 +30,6 @@ export default ({ services }) => ({ dispatch }) => next => {
|
||||
type: handleError,
|
||||
error
|
||||
});
|
||||
})
|
||||
.doOnNext(dispatch);
|
||||
};
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -4,23 +4,18 @@ 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 }) => {
|
||||
@ -33,7 +28,6 @@ export default () => ({ dispatch }) => next => {
|
||||
.catch(error => Observable.just({
|
||||
type: 'app.handleError',
|
||||
error
|
||||
}))
|
||||
.doOnNext(dispatch);
|
||||
};
|
||||
};
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
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 => {
|
||||
return Observable.just({
|
||||
type: handleError,
|
||||
error
|
||||
.catch(error => Observable.just({ type: handleError, error }));
|
||||
});
|
||||
})
|
||||
.doOnNext(dispatch);
|
||||
};
|
||||
};
|
||||
}
|
||||
|
@ -6,18 +6,12 @@ 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;
|
||||
}
|
||||
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 }
|
||||
})
|
||||
return services.createService$({ service: 'jobs', params: { job } })
|
||||
.retry(3)
|
||||
.flatMap(job => Observable.of(
|
||||
saveCompleted(job),
|
||||
@ -26,7 +20,6 @@ export default ({ services }) => ({ dispatch }) => next => {
|
||||
.catch(error => Observable.just({
|
||||
type: handleError,
|
||||
error
|
||||
}))
|
||||
.doOnNext(dispatch);
|
||||
};
|
||||
};
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
274
common/app/routes/challenges/step/Step.jsx
Normal file
274
common/app/routes/challenges/step/Step.jsx
Normal 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);
|
@ -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;
|
||||
}
|
||||
|
||||
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 => {
|
||||
return Observable.just({
|
||||
type: handleError,
|
||||
error
|
||||
.catch(error => Observable.just({ type: handleError, error }));
|
||||
});
|
||||
})
|
||||
.doOnNext(dispatch);
|
||||
};
|
||||
};
|
||||
}
|
||||
|
@ -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;
|
@ -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)
|
||||
);
|
||||
|
||||
const fetch$ = action.apply(null, actionArgs);
|
||||
if (__DEV__ && !Observable.isObservable(fetch$)) {
|
||||
throw new Error(
|
||||
'fetch action should return observable'
|
||||
runAction(
|
||||
nextProps,
|
||||
nextContext,
|
||||
action
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
};
|
||||
|
57
common/app/utils/redux-epic.js
Normal file
57
common/app/utils/redux-epic.js
Normal 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;
|
||||
};
|
203
common/app/utils/redux-epic.test.js
Normal file
203
common/app/utils/redux-epic.test.js
Normal 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' });
|
||||
});
|
@ -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 };
|
||||
});
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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() {
|
||||
|
@ -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',
|
||||
|
Reference in New Issue
Block a user