diff --git a/.eslintrc b/.eslintrc index 0e95beca2c..073f6010bc 100644 --- a/.eslintrc +++ b/.eslintrc @@ -232,7 +232,7 @@ "react/jsx-uses-vars": 1, "react/no-did-mount-set-state": 2, "react/no-did-update-set-state": 2, - "react/no-multi-comp": 2, + "react/no-multi-comp": [2, { "ignoreStateless": true } ], "react/prop-types": 2, "react/react-in-jsx-scope": 1, "react/self-closing-comp": 1, diff --git a/client/err-saga.js b/client/err-saga.js deleted file mode 100644 index d681e4cfcc..0000000000 --- a/client/err-saga.js +++ /dev/null @@ -1,9 +0,0 @@ -export default function toastSaga(err$, toast) { - err$ - .doOnNext(() => toast({ - type: 'error', - title: 'Oops, something went wrong', - message: `Something went wrong, please try again later` - })) - .subscribe(err => console.error(err)); -} diff --git a/client/history-saga.js b/client/history-saga.js deleted file mode 100644 index f73576d394..0000000000 --- a/client/history-saga.js +++ /dev/null @@ -1,69 +0,0 @@ -import { Disposable, Observable } from 'rx'; - -export function location$(history) { - return Observable.create(function(observer) { - const dispose = history.listen(function(location) { - observer.onNext(location); - }); - - return Disposable.create(() => { - dispose(); - }); - }); -} - -const emptyLocation = { - pathname: '', - search: '', - hash: '' -}; - -let prevKey; -let isSyncing = false; -export default function historySaga( - history, - updateLocation, - goTo, - goBack, - routerState$ -) { - routerState$.subscribe( - location => { - - if (!location) { - return null; - } - - // store location has changed, update history - if (!location.key || location.key !== prevKey) { - isSyncing = true; - history.transitionTo({ ...emptyLocation, ...location }); - isSyncing = false; - } - } - ); - - location$(history) - .doOnNext(location => { - prevKey = location.key; - - if (isSyncing) { - return null; - } - - return updateLocation(location); - }) - .subscribe(() => {}); - - goTo - .doOnNext((route = '/') => { - history.push(route); - }) - .subscribe(() => {}); - - goBack - .doOnNext(() => { - history.goBack(); - }) - .subscribe(() => {}); -} diff --git a/client/index.js b/client/index.js index 7f14635285..92d4d9c972 100644 --- a/client/index.js +++ b/client/index.js @@ -1,99 +1,71 @@ -import unused from './es6-shims'; // eslint-disable-line +import './es6-shims'; import Rx from 'rx'; import React from 'react'; -import Fetchr from 'fetchr'; -import debugFactory from 'debug'; +import debug from 'debug'; import { Router } from 'react-router'; +import { routeReducer as routing, syncHistory } from 'react-router-redux'; import { createLocation, createHistory } from 'history'; -import { hydrate } from 'thundercats'; -import { render$ } from 'thundercats-react'; import app$ from '../common/app'; -import historySaga from './history-saga'; -import errSaga from './err-saga'; +import provideStore from '../common/app/provide-store'; -const debug = debugFactory('fcc:client'); +// client specific sagas +import sagas from './sagas'; + +// render to observable +import render from '../common/app/utils/render'; + +const log = debug('fcc:client'); const DOMContianer = document.getElementById('fcc'); -const catState = window.__fcc__.data || {}; -const services = new Fetchr({ - xhrPath: '/services' -}); +const initialState = window.__fcc__.data; + +const serviceOptions = { xhrPath: '/services' }; Rx.config.longStackSupport = !!debug.enabled; const history = createHistory(); const appLocation = createLocation( location.pathname + location.search ); +const routingMiddleware = syncHistory(history); + +const devTools = window.devToolsExtension ? window.devToolsExtension() : f => f; +const shouldRouterListenForReplays = !!window.devToolsExtension; + +const clientSagaOptions = { doc: document }; // returns an observable -app$({ history, location: appLocation }) - .flatMap( - ({ AppCat }) => { - // instantiate the cat with service - const appCat = AppCat(null, services, history); - // hydrate the stores - return hydrate(appCat, catState).map(() => appCat); - }, - // not using nextLocation at the moment but will be used for - // redirects in the future - ({ nextLocation, props }, appCat) => ({ nextLocation, props, appCat }) - ) - .doOnNext(({ appCat }) => { - const appStore$ = appCat.getStore('appStore'); +app$({ + location: appLocation, + history, + serviceOptions, + initialState, + middlewares: [ + routingMiddleware, + ...sagas.map(saga => saga(clientSagaOptions)) + ], + reducers: { routing }, + enhancers: [ devTools ] +}) + .flatMap(({ props, store }) => { - const { - toast, - updateLocation, - goTo, - goBack - } = appCat.getActions('appActions'); - - - const routerState$ = appStore$ - .map(({ location }) => location) - .filter(location => !!location); - - // set page title - appStore$ - .pluck('title') - .distinctUntilChanged() - .doOnNext(title => document.title = title) - .subscribe(() => {}); - - historySaga( - history, - updateLocation, - goTo, - goBack, - routerState$ - ); - - const err$ = appStore$ - .pluck('err') - .filter(err => !!err) - .distinctUntilChanged(); - - errSaga(err$, toast); - }) - // allow store subscribe to subscribe to actions - .delay(10) - .flatMap(({ props, appCat }) => { + // because of weirdness in react-routers match function + // we replace the wrapped returned in props with the first one + // we passed in. This might be fixed in react-router 2.0 props.history = history; - return render$( - appCat, - React.createElement(Router, props), + if (shouldRouterListenForReplays && store) { + log('routing middleware listening for replays'); + routingMiddleware.listenForReplays(store); + } + + log('rendering'); + return render( + provideStore(React.createElement(Router, props), store), DOMContianer ); }) .subscribe( - () => { - debug('react rendered'); - }, - err => { - throw err; - }, - () => { - debug('react closed subscription'); - } + () => debug('react rendered'), + err => { throw err; }, + () => debug('react closed subscription') ); diff --git a/client/sagas/README.md b/client/sagas/README.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/client/sagas/err-saga.js b/client/sagas/err-saga.js new file mode 100644 index 0000000000..72a80d4c5d --- /dev/null +++ b/client/sagas/err-saga.js @@ -0,0 +1,19 @@ +// () => +// (store: Store) => +// (next: (action: Action) => Object) => +// errSaga(action: Action) => Object|Void +export default () => ({ dispatch }) => next => { + return function errorSaga(action) { + if (!action.error) { return next(action); } + + console.error(action.error); + dispatch({ + type: 'app.makeToast', + payload: { + type: 'error', + title: 'Oops, something went wrong', + message: `Something went wrong, please try again later` + } + }); + }; +}; diff --git a/client/sagas/index.js b/client/sagas/index.js new file mode 100644 index 0000000000..fc72193446 --- /dev/null +++ b/client/sagas/index.js @@ -0,0 +1,4 @@ +import errSaga from './err-saga'; +import titleSaga from './title-saga'; + +export default [errSaga, titleSaga]; diff --git a/client/sagas/title-saga.js b/client/sagas/title-saga.js new file mode 100644 index 0000000000..59f357f274 --- /dev/null +++ b/client/sagas/title-saga.js @@ -0,0 +1,16 @@ +// (doc: Object) => +// () => +// (next: (action: Action) => Object) => +// titleSage(action: Action) => Object|Void +export default (doc) => () => next => { + return function titleSage(action) { + // get next state + const result = next(action); + if (action !== 'updateTitle') { + return result; + } + const newTitle = result.app.title; + doc.title = newTitle; + return result; + }; +}; diff --git a/common/app/App.jsx b/common/app/App.jsx index e3c3fa1f72..0afb196a5b 100644 --- a/common/app/App.jsx +++ b/common/app/App.jsx @@ -1,81 +1,89 @@ import React, { PropTypes } from 'react'; import { Row } from 'react-bootstrap'; import { ToastMessage, ToastContainer } from 'react-toastr'; -import { contain } from 'thundercats-react'; +import { compose } from 'redux'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; + +import { fetchUser } from './redux/actions'; +import contain from './utils/professor-x'; import Nav from './components/Nav'; const toastMessageFactory = React.createFactory(ToastMessage.animation); -export default contain( - { - actions: ['appActions'], - store: 'appStore', - fetchAction: 'appActions.getUser', - isPrimed({ username }) { - return !!username; - }, - map({ - username, - points, - picture, - toast - }) { - return { - username, - points, - picture, - toast - }; - }, - getPayload(props) { - return { - isPrimed: !!props.username - }; - } - }, - React.createClass({ - displayName: 'FreeCodeCamp', - - propTypes: { - appActions: PropTypes.object, - children: PropTypes.node, - username: PropTypes.string, - points: PropTypes.number, - picture: PropTypes.string, - toast: PropTypes.object - }, - - componentWillReceiveProps({ toast: nextToast = {} }) { - const { toast = {} } = this.props; - if (toast.id !== nextToast.id) { - this.refs.toaster[nextToast.type || 'success']( - nextToast.message, - nextToast.title, - { - closeButton: true, - timeOut: 10000 - } - ); - } - }, - - render() { - const { username, points, picture } = this.props; - const navProps = { username, points, picture }; - return ( -
-
- ); - } +const mapStateToProps = createSelector( + state => state.app, + ({ + username, + points, + picture, + toast + }) => ({ + username, + points, + picture, + toast }) ); + +const fetchContainerOptions = { + fetchAction: 'fetchUser', + isPrimed({ username }) { + return !!username; + } +}; + +// export plain class for testing +export class FreeCodeCamp extends React.Component { + static displayName = 'FreeCodeCamp'; + + static propTypes = { + children: PropTypes.node, + username: PropTypes.string, + points: PropTypes.number, + picture: PropTypes.string, + toast: PropTypes.object + }; + + componentWillReceiveProps({ toast: nextToast = {} }) { + const { toast = {} } = this.props; + if (toast.id !== nextToast.id) { + this.refs.toaster[nextToast.type || 'success']( + nextToast.message, + nextToast.title, + { + closeButton: true, + timeOut: 10000 + } + ); + } + } + + render() { + const { username, points, picture } = this.props; + const navProps = { username, points, picture }; + + return ( +
+
+ ); + } +} + +const wrapComponent = compose( + // connect Component to Redux Store + connect(mapStateToProps, { fetchUser }), + // handles prefetching data + contain(fetchContainerOptions) +); + +export default wrapComponent(FreeCodeCamp); diff --git a/common/app/app-stream.jsx b/common/app/app-stream.jsx deleted file mode 100644 index 82d5568c09..0000000000 --- a/common/app/app-stream.jsx +++ /dev/null @@ -1,17 +0,0 @@ -import Rx from 'rx'; -import { match } from 'react-router'; -import App from './App.jsx'; -import AppCat from './Cat'; - -import childRoutes from './routes'; - -const route$ = Rx.Observable.fromNodeCallback(match); - -const routes = Object.assign({ components: App }, childRoutes); - -export default function app$({ location, history }) { - return route$({ routes, location, history }) - .map(([nextLocation, props]) => { - return { nextLocation, props, AppCat }; - }); -} diff --git a/common/app/components/Footer/README.md b/common/app/components/Footer/README.md new file mode 100644 index 0000000000..a8f04d0a40 --- /dev/null +++ b/common/app/components/Footer/README.md @@ -0,0 +1 @@ +Currently not used diff --git a/common/app/components/Nav/Nav.jsx b/common/app/components/Nav/Nav.jsx index 205cb26bcf..922c05081b 100644 --- a/common/app/components/Nav/Nav.jsx +++ b/common/app/components/Nav/Nav.jsx @@ -23,20 +23,20 @@ const logoElement = ( ); const toggleButtonChild = ( - - Menu - + + Menu + ); -export default React.createClass({ - displayName: 'Nav', +export default class extends React.Component { + static displayName = 'Nav'; - propTypes: { + static propTypes = { points: PropTypes.number, picture: PropTypes.string, signedIn: PropTypes.bool, username: PropTypes.string - }, + }; renderLinks() { return navLinks.map(({ content, link, react, target }, index) => { @@ -63,7 +63,7 @@ export default React.createClass({ ); }); - }, + } renderPoints(username, points) { if (!username) { @@ -76,7 +76,7 @@ export default React.createClass({ [ { points } ] ); - }, + } renderSignin(username, picture) { if (username) { @@ -100,7 +100,7 @@ export default React.createClass({ ); } - }, + } render() { const { username, points, picture } = this.props; @@ -124,4 +124,4 @@ export default React.createClass({ ); } -}); +} diff --git a/common/app/components/NotFound/index.jsx b/common/app/components/NotFound/index.jsx index eb634a2f0d..0032277c7e 100644 --- a/common/app/components/NotFound/index.jsx +++ b/common/app/components/NotFound/index.jsx @@ -6,17 +6,21 @@ function goToServer(path) { win.location = '/' + path; } -export default React.createClass({ - displayName: 'NotFound', - propTypes: { +export default class extends React.Component { + static displayName = 'NotFound'; + + static propTypes = { params: PropTypes.object - }, + }; + componentWillMount() { goToServer(this.props.params.splat); - }, + } + componentDidMount() { - }, + } + render() { return ; } -}); +} diff --git a/common/app/create-app.jsx b/common/app/create-app.jsx new file mode 100644 index 0000000000..27a6cd23b0 --- /dev/null +++ b/common/app/create-app.jsx @@ -0,0 +1,79 @@ +import { Observable } from 'rx'; +import { match } from 'react-router'; +import { compose, createStore, applyMiddleware } from 'redux'; + +// main app +import App from './App.jsx'; +// app routes +import childRoutes from './routes'; + +// redux +import createReducer from './create-reducer'; +import middlewares from './middlewares'; +import sagas from './sagas'; + +// general utils +import servicesCreator from '../utils/services-creator'; + +const createRouteProps = Observable.fromNodeCallback(match); + +const routes = { components: App, ...childRoutes }; + +// +// createApp(settings: { +// location?: Location, +// history?: History, +// initialState?: Object|Void, +// serviceOptions?: Object, +// middlewares?: Function[], +// sideReducers?: Object +// enhancers?: Function[], +// sagas?: Function[], +// }) => Observable +// +// Either location or history must be defined +export default function createApp({ + location, + history, + initialState, + serviceOptions = {}, + middlewares: sideMiddlewares = [], + enhancers: sideEnhancers = [], + reducers: sideReducers = {}, + sagas: sideSagas = [] +}) { + const sagaOptions = { + services: servicesCreator(null, serviceOptions) + }; + + const enhancers = [ + applyMiddleware( + ...middlewares, + ...sideMiddlewares, + ...[ ...sagas, ...sideSagas].map(saga => saga(sagaOptions)), + ), + // enhancers must come after middlewares + // on client side these are things like Redux DevTools + ...sideEnhancers + ]; + const reducer = createReducer(sideReducers); + + // create composed store enhancer + // use store enhancer function to enhance `createStore` function + // call enhanced createStore function with reducer and initialState + // to create store + const store = compose(...enhancers)(createStore)(reducer, initialState); + + // createRouteProps({ + // location: LocationDescriptor, + // history: History, + // routes: Object + // }) => Observable + return createRouteProps({ routes, location, history }) + .map(([ nextLocation, props ]) => ({ + nextLocation, + props, + reducer, + store + })); +} diff --git a/common/app/create-reducer.js b/common/app/create-reducer.js new file mode 100644 index 0000000000..ee7478011e --- /dev/null +++ b/common/app/create-reducer.js @@ -0,0 +1,12 @@ +import { combineReducers } from 'redux'; + +import { reducer as app } from './redux'; +import { reducer as hikesApp } from './routes/Hikes/redux'; + +export default function createReducer(sideReducers = {}) { + return combineReducers({ + ...sideReducers, + app, + hikesApp + }); +} diff --git a/common/app/flux/Store.js b/common/app/flux/Store.js deleted file mode 100644 index 49f7cb342d..0000000000 --- a/common/app/flux/Store.js +++ /dev/null @@ -1,103 +0,0 @@ -import { Store } from 'thundercats'; - -const { createRegistrar, setter, fromMany } = Store; -const initValue = { - title: 'Learn To Code | Free Code Camp', - username: null, - picture: null, - points: 0, - hikesApp: { - hikes: [], - // lecture state - currentHike: {}, - showQuestions: false - }, - jobsApp: { - showModal: false - } -}; - -export default Store({ - refs: { - displayName: 'AppStore', - value: initValue - }, - init({ instance: store, args: [cat] }) { - const register = createRegistrar(store); - // app - const { - updateLocation, - getUser, - setTitle, - toast - } = cat.getActions('appActions'); - - register( - fromMany( - setter( - fromMany( - getUser, - setTitle - ) - ), - updateLocation, - toast - ) - ); - - // hikes - const { - toggleQuestions, - fetchHikes, - resetHike, - grabQuestion, - releaseQuestion, - moveQuestion, - answer - } = cat.getActions('hikesActions'); - - register( - fromMany( - toggleQuestions, - fetchHikes, - resetHike, - grabQuestion, - releaseQuestion, - moveQuestion, - answer - ) - ); - - - // jobs - const { - findJob, - saveJobToDb, - getJob, - getJobs, - openModal, - closeModal, - handleForm, - getSavedForm, - setPromoCode, - applyCode, - clearPromo - } = cat.getActions('JobActions'); - - register( - fromMany( - findJob, - saveJobToDb, - getJob, - getJobs, - openModal, - closeModal, - handleForm, - getSavedForm, - setPromoCode, - applyCode, - clearPromo - ) - ); - } -}); diff --git a/common/app/flux/index.js b/common/app/flux/index.js deleted file mode 100644 index a07e138ae6..0000000000 --- a/common/app/flux/index.js +++ /dev/null @@ -1,2 +0,0 @@ -export AppActions from './Actions'; -export AppStore from './Store'; diff --git a/common/app/index.js b/common/app/index.js index 99924acf83..1a3fead824 100644 --- a/common/app/index.js +++ b/common/app/index.js @@ -1 +1 @@ -export default from './app-stream.jsx'; +export default from './create-app.jsx'; diff --git a/common/app/middlewares.js b/common/app/middlewares.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/app/provide-Store.js b/common/app/provide-Store.js new file mode 100644 index 0000000000..a569f64a5f --- /dev/null +++ b/common/app/provide-Store.js @@ -0,0 +1,11 @@ +/* eslint-disable react/display-name */ +import React from 'react'; +import { Provider } from 'react-redux'; + +export default function provideStore(element, store) { + return React.createElement( + Provider, + { store }, + element + ); +} diff --git a/common/app/redux/actions.js b/common/app/redux/actions.js new file mode 100644 index 0000000000..cdc221a97b --- /dev/null +++ b/common/app/redux/actions.js @@ -0,0 +1,21 @@ +import { createAction } from 'redux-actions'; +import types from './types'; + +// updateTitle(title: String) => Action +export const updateTitle = createAction(types.updateTitle); + +// makeToast({ type?: String, message: String, title: String }) => Action +export const makeToast = createAction( + types.makeToast, + toast => toast.type ? toast : (toast.type = 'info', toast) +); + +// fetchUser() => Action +// used in combination with fetch-user-saga +export const fetchUser = createAction(types.fetchUser); + +// setUser(userInfo: Object) => Action +export const setUser = createAction(types.setUser); + +// updatePoints(points: Number) => Action +export const updatePoints = createAction(types.updatePoints); diff --git a/common/app/redux/fetch-user-saga.js b/common/app/redux/fetch-user-saga.js new file mode 100644 index 0000000000..4f01aea99c --- /dev/null +++ b/common/app/redux/fetch-user-saga.js @@ -0,0 +1,39 @@ +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(({ + username, + picture, + progressTimestamps = [], + isFrontEndCert, + isBackEndCert, + isFullStackCert + }) => { + return { + type: setUser, + payload: { + username, + picture, + points: progressTimestamps.length, + isFrontEndCert, + isBackEndCert, + isFullStackCert, + isSignedIn: true + } + }; + }) + .catch(error => Observable.just({ + type: handleError, + error + })) + .doOnNext(dispatch); + }; +}; + diff --git a/common/app/redux/index.js b/common/app/redux/index.js new file mode 100644 index 0000000000..d0e8381658 --- /dev/null +++ b/common/app/redux/index.js @@ -0,0 +1,6 @@ +export { default as reducer } from './reducer'; +export { default as actions } from './actions'; +export { default as types } from './types'; + +import fetchUserSaga from './fetch-user-saga'; +export const sagas = [ fetchUserSaga ]; diff --git a/common/app/flux/Actions.js b/common/app/redux/oldActions.js similarity index 100% rename from common/app/flux/Actions.js rename to common/app/redux/oldActions.js diff --git a/common/app/redux/reducer.js b/common/app/redux/reducer.js new file mode 100644 index 0000000000..1aaece38f7 --- /dev/null +++ b/common/app/redux/reducer.js @@ -0,0 +1,38 @@ +import { handleActions } from 'redux-actions'; +import types from './types'; + +export default handleActions( + { + [types.updateTitle]: (state, { payload = 'Learn To Code' }) => ({ + ...state, + title: payload + ' | Free Code Camp' + }), + + [types.makeToast]: (state, { payload: toast }) => ({ + ...state, + toast: { + ...toast, + id: state.toast && state.toast.id ? state.toast.id : 1 + } + }), + + [types.setUser]: (state, { payload: user }) => ({ ...state, ...user }), + + [types.challengeSaved]: (state, { payload: { points = 0 } }) => ({ + ...state, + points + }), + + [types.updatePoints]: (state, { payload: points }) => ({ + ...state, + points + }) + }, + { + title: 'Learn To Code | Free Code Camp', + username: null, + picture: null, + points: 0, + isSignedIn: false + } +); diff --git a/common/app/redux/types.js b/common/app/redux/types.js new file mode 100644 index 0000000000..16122457f5 --- /dev/null +++ b/common/app/redux/types.js @@ -0,0 +1,14 @@ +const types = [ + 'updateTitle', + + 'fetchUser', + 'setUser', + + 'makeToast', + 'updatePoints', + 'handleError' +]; + +export default types + // make into object with signature { type: nameSpace[type] }; + .reduce((types, type) => ({ ...types, [type]: `app.${type}` }), {}); diff --git a/common/app/routes/FAVS/README.md b/common/app/routes/FAVS/README.md deleted file mode 100644 index c02922da56..0000000000 --- a/common/app/routes/FAVS/README.md +++ /dev/null @@ -1 +0,0 @@ -future home of FAVS app diff --git a/common/app/routes/Hikes/components/Hike.jsx b/common/app/routes/Hikes/components/Hike.jsx index 85ebdb487f..6f75ae7f9b 100644 --- a/common/app/routes/Hikes/components/Hike.jsx +++ b/common/app/routes/Hikes/components/Hike.jsx @@ -1,63 +1,74 @@ import React, { PropTypes } from 'react'; -import { contain } from 'thundercats-react'; +import { connect } from 'react-redux'; import { Col, Row } from 'react-bootstrap'; +import { createSelector } from 'reselect'; import Lecture from './Lecture.jsx'; import Questions from './Questions.jsx'; +import { resetHike } from '../redux/actions'; -export default contain( - { - actions: ['hikesActions'] - }, - React.createClass({ - displayName: 'Hike', - - propTypes: { - currentHike: PropTypes.object, - hikesActions: PropTypes.object, - params: PropTypes.object, - showQuestions: PropTypes.bool - }, - - componentWillUnmount() { - this.props.hikesActions.resetHike(); - }, - - componentWillReceiveProps({ params: { dashedName } }) { - if (this.props.params.dashedName !== dashedName) { - this.props.hikesActions.resetHike(); - } - }, - - renderBody(showQuestions) { - if (showQuestions) { - return ; - } - return ; - }, - - render() { - const { - currentHike: { title } = {}, - showQuestions - } = this.props; - - return ( - - -
-

{ title }

-
-
-
-
- { this.renderBody(showQuestions) } -
- - - ); - } - }) +const mapStateToProps = createSelector( + state => state.hikesApp.hikes.entities, + state => state.hikesApp.currentHike, + (hikes, currentHikeDashedName) => { + const currentHike = hikes[currentHikeDashedName]; + return { + title: currentHike.title + }; + } ); +// export plain component for testing +export class Hike extends React.Component { + static displayName = 'Hike'; + + static propTypes = { + title: PropTypes.object, + params: PropTypes.object, + resetHike: PropTypes.func, + showQuestions: PropTypes.bool + }; + + componentWillUnmount() { + this.props.resetHike(); + } + + componentWillReceiveProps({ params: { dashedName } }) { + if (this.props.params.dashedName !== dashedName) { + this.props.resetHike(); + } + } + + renderBody(showQuestions) { + if (showQuestions) { + return ; + } + return ; + } + + render() { + const { + title, + showQuestions + } = this.props; + + return ( + + +
+

{ title }

+
+
+
+
+ { this.renderBody(showQuestions) } +
+ + + ); + } +} + +// export redux aware component +export default connect(mapStateToProps, { resetHike }); diff --git a/common/app/routes/Hikes/components/Hikes.jsx b/common/app/routes/Hikes/components/Hikes.jsx index ba5a324447..17c027a4de 100644 --- a/common/app/routes/Hikes/components/Hikes.jsx +++ b/common/app/routes/Hikes/components/Hikes.jsx @@ -1,74 +1,83 @@ import React, { PropTypes } from 'react'; +import { compose } from 'redux'; +import { connect } from 'react-redux'; import { Row } from 'react-bootstrap'; -import { contain } from 'thundercats-react'; -// import debugFactory from 'debug'; +import shouldComponentUpdate from 'react-pure-render/function'; +import { createSelector } from 'reselect'; +// import debug from 'debug'; import HikesMap from './Map.jsx'; +import { updateTitle } from '../../../redux/actions'; +import { fetchHikes } from '../redux/actions'; -// const debug = debugFactory('freecc:hikes'); +import contain from '../../../utils/professor-x'; -export default contain( - { - store: 'appStore', - map(state) { - return state.hikesApp; - }, - actions: ['appActions'], - fetchAction: 'hikesActions.fetchHikes', - getPayload: ({ hikes, params }) => ({ - isPrimed: (hikes && !!hikes.length), - dashedName: params.dashedName - }), - shouldContainerFetch(props, nextProps) { - return props.params.dashedName !== nextProps.params.dashedName; +// const log = debug('fcc:hikes'); + +const mapStateToProps = createSelector( + state => state.hikesApp.hikes, + hikes => { + if (!hikes || !hikes.entities || !hikes.results) { + return { hikes: [] }; } - }, - React.createClass({ - displayName: 'Hikes', - - propTypes: { - appActions: PropTypes.object, - children: PropTypes.element, - currentHike: PropTypes.object, - hikes: PropTypes.array, - params: PropTypes.object, - showQuestions: PropTypes.bool - }, - - componentWillMount() { - const { appActions } = this.props; - appActions.setTitle('Videos'); - }, - - renderMap(hikes) { - return ( - - ); - }, - - renderChild({ children, ...props }) { - if (!children) { - return null; - } - return React.cloneElement(children, props); - }, - - render() { - const { hikes } = this.props; - const { dashedName } = this.props.params; - const preventOverflow = { overflow: 'hidden' }; - return ( -
- - { - // render sub-route - this.renderChild({ ...this.props, dashedName }) || - // if no sub-route render hikes map - this.renderMap(hikes) - } - -
- ); - } - }) + return { + hikes: hikes.results.map(dashedName => hikes.enitites[dashedName]) + }; + } ); +const fetchOptions = { + fetchAction: 'fetchHikes', + + isPrimed: ({ hikes }) => hikes && !!hikes.length, + getPayload: ({ params: { dashedName } }) => dashedName, + shouldContainerFetch(props, nextProps) { + return props.params.dashedName !== nextProps.params.dashedName; + } +}; + +export class Hikes extends React.Component { + static displayName = 'Hikes'; + + static propTypes = { + children: PropTypes.element, + hikes: PropTypes.array, + params: PropTypes.object, + updateTitle: PropTypes.func + }; + + componentWillMount() { + const { updateTitle } = this.props; + updateTitle('Hikes'); + } + + shouldComponentUpdate = shouldComponentUpdate; + + renderMap(hikes) { + return ( + + ); + } + + render() { + const { hikes } = this.props; + const preventOverflow = { overflow: 'hidden' }; + return ( +
+ + { + // render sub-route + this.props.children || + // if no sub-route render hikes map + this.renderMap(hikes) + } + +
+ ); + } +} + +// export redux and fetch aware component +export default compose( + connect(mapStateToProps, { fetchHikes, updateTitle }), + contain(fetchOptions) +)(Hikes); diff --git a/common/app/routes/Hikes/components/Lecture.jsx b/common/app/routes/Hikes/components/Lecture.jsx index 09cea7a998..1aac07f492 100644 --- a/common/app/routes/Hikes/components/Lecture.jsx +++ b/common/app/routes/Hikes/components/Lecture.jsx @@ -1,95 +1,93 @@ import React, { PropTypes } from 'react'; -import { contain } from 'thundercats-react'; +import { connect } from 'react-redux'; import { Button, Col, Row } from 'react-bootstrap'; -import { History } from 'react-router'; import Vimeo from 'react-vimeo'; -import debugFactory from 'debug'; +import { createSelector } from 'reselect'; +import debug from 'debug'; -const debug = debugFactory('freecc:hikes'); +const log = debug('fcc:hikes'); -export default contain( - { - actions: ['hikesActions'], - store: 'appStore', - map(state) { - const { - currentHike: { - dashedName, - description, - challengeSeed: [id] = [0] - } = {} - } = state.hikesApp; - - return { - dashedName, - description, - id - }; - } - }, - React.createClass({ - displayName: 'Lecture', - mixins: [History], - - propTypes: { - dashedName: PropTypes.string, - description: PropTypes.array, - id: PropTypes.string, - hikesActions: PropTypes.object - }, - - shouldComponentUpdate(nextProps) { - const { props } = this; - return nextProps.id !== props.id; - }, - - handleError: debug, - - handleFinish(hikesActions) { - debug('loading questions'); - hikesActions.toggleQuestions(); - }, - - renderTranscript(transcript, dashedName) { - return transcript.map((line, index) => ( -

- { line } -

- )); - }, - - render() { - const { - id = '1', - description = [], - hikesActions - } = this.props; - const dashedName = 'foo'; - - return ( - - - this.handleFinish(hikesActions) } - videoId={ id } /> - - -
- { this.renderTranscript(description, dashedName) } -
- -
- - ); - } - }) +const mapStateToProps = createSelector( + state => state.hikesApp.hikes.entities, + state => state.hikesApp.currentHike, + (hikes, currentHikeDashedName) => { + const currentHike = hikes[currentHikeDashedName]; + const { + dashedName, + description, + challengeSeed: [id] = [0] + } = currentHike || {}; + return { + id, + dashedName, + description + }; + } ); + +export class Lecture extends React.Component { + static displayName = 'Lecture'; + + static propTypes = { + dashedName: PropTypes.string, + description: PropTypes.array, + id: PropTypes.string, + hikesActions: PropTypes.object + }; + + shouldComponentUpdate(nextProps) { + const { props } = this; + return nextProps.id !== props.id; + } + + handleError: log; + + handleFinish(hikesActions) { + debug('loading questions'); + hikesActions.toggleQuestions(); + } + + renderTranscript(transcript, dashedName) { + return transcript.map((line, index) => ( +

+ { line } +

+ )); + } + + render() { + const { + id = '1', + description = [], + hikesActions + } = this.props; + const dashedName = 'foo'; + + return ( + + + this.handleFinish(hikesActions) } + videoId={ id } /> + + +
+ { this.renderTranscript(description, dashedName) } +
+ +
+ + ); + } +} + +export default connect(mapStateToProps, { })(Lecture); diff --git a/common/app/routes/Hikes/flux/index.js b/common/app/routes/Hikes/flux/index.js deleted file mode 100644 index 0936f320ae..0000000000 --- a/common/app/routes/Hikes/flux/index.js +++ /dev/null @@ -1 +0,0 @@ -export default from './Actions'; diff --git a/common/app/routes/Hikes/index.js b/common/app/routes/Hikes/index.js index fa8ba7863d..b759608fa9 100644 --- a/common/app/routes/Hikes/index.js +++ b/common/app/routes/Hikes/index.js @@ -1,11 +1,6 @@ import Hikes from './components/Hikes.jsx'; import Hike from './components/Hike.jsx'; -/* - * show video /hikes/someVideo - * show question /hikes/someVideo/question1 - */ - export default { path: 'videos', component: Hikes, diff --git a/common/app/routes/Hikes/redux/actions.js b/common/app/routes/Hikes/redux/actions.js new file mode 100644 index 0000000000..a2aec2358a --- /dev/null +++ b/common/app/routes/Hikes/redux/actions.js @@ -0,0 +1,54 @@ +import { createAction } from 'redux-actions'; + +import types from './types'; +import { getMouse } from './utils'; + + +// fetchHikes(dashedName?: String) => Action +// used with fetchHikesSaga +export const fetchHikes = createAction(types.fetchHikes); +// fetchHikesCompleted(hikes: Object) => Action +// hikes is a normalized response from server +// called within fetchHikesSaga +export const fetchHikesCompleted = createAction( + types.fetchHikesCompleted, + (hikes, currentHike) => ({ hikes, currentHike }) +); + +export const toggleQuestion = createAction(types.toggleQuestion); + +export const grabQuestions = createAction(types.grabQuestions, e => { + let { pageX, pageY, touches } = e; + if (touches) { + e.preventDefault(); + // these re-assigns the values of pageX, pageY from touches + ({ pageX, pageY } = touches[0]); + } + const delta = [pageX, pageY]; + const mouse = [0, 0]; + + return { delta, mouse }; +}); + +export const releaseQuestion = createAction(types.releaseQuestions); +export const moveQuestion = createAction( + types.moveQuestion, + ({ e, delta }) => getMouse(e, delta) +); + +// answer({ +// e: Event, +// answer: Boolean, +// userAnswer: Boolean, +// info: String, +// threshold: Number +// }) => Action +export const answer = createAction(types.answer); + +export const startShake = createAction(types.startShake); +export const endShake = createAction(types.primeNextQuestion); + +export const goToNextQuestion = createAction(types.goToNextQuestion); + +export const hikeCompleted = createAction(types.hikeCompleted); +export const goToNextHike = createAction(types.goToNextHike); diff --git a/common/app/routes/Hikes/redux/answer-saga.js b/common/app/routes/Hikes/redux/answer-saga.js new file mode 100644 index 0000000000..0c4cd39211 --- /dev/null +++ b/common/app/routes/Hikes/redux/answer-saga.js @@ -0,0 +1,128 @@ +import { Observable } from 'rx'; +// import { routeActions } from 'react-simple-router'; + +import types from './types'; +import { getMouse } from './utils'; + +import { makeToast, updatePoints } from '../../../redux/actions'; +import { hikeCompleted, goToNextHike } from './actions'; +import { postJSON$ } from '../../../../utils/ajax-stream'; + +export default () => ({ getState, dispatch }) => next => { + return function answerSaga(action) { + if (types.answer !== action.type) { + return next(action); + } + + const { + e, + answer, + userAnswer, + info, + threshold + } = action.payload; + + const { + app: { isSignedIn }, + hikesApp: { + currentQuestion, + currentHike: { id, name, challengeType }, + tests = [], + delta = [ 0, 0 ] + } + } = getState(); + + let finalAnswer; + // drag answer, compute response + if (typeof userAnswer === 'undefined') { + const [positionX] = getMouse(e, delta); + + // question released under threshold + if (Math.abs(positionX) < threshold) { + return next(action); + } + + if (positionX >= threshold) { + finalAnswer = true; + } + + if (positionX <= -threshold) { + finalAnswer = false; + } + } else { + finalAnswer = userAnswer; + } + + // incorrect question + if (answer !== finalAnswer) { + if (info) { + dispatch({ + type: 'makeToast', + payload: { + title: 'Hint', + message: info, + type: 'info' + } + }); + } + + return Observable + .just({ type: types.removeShake }) + .delay(500) + .startWith({ type: types.startShake }) + .doOnNext(dispatch); + } + + if (tests[currentQuestion]) { + return Observable + .just({ type: types.goToNextQuestion }) + .delay(300) + .startWith({ type: types.primeNextQuestion }); + } + + let updateUser$; + if (isSignedIn) { + const body = { id, name, challengeType }; + updateUser$ = postJSON$('/completed-challenge', body) + // if post fails, will retry once + .retry(3) + .flatMap(({ alreadyCompleted, points }) => { + return Observable.of( + makeToast({ + message: + 'Challenge saved.' + + (alreadyCompleted ? '' : ' First time Completed!'), + title: 'Saved', + type: 'info' + }), + updatePoints(points), + ); + }) + .catch(error => { + return Observable.just({ + type: 'error', + error + }); + }); + } else { + updateUser$ = Observable.empty(); + } + + const challengeCompleted$ = Observable.of( + goToNextHike(), + makeToast({ + title: 'Congratulations!', + message: 'Hike completed.' + (isSignedIn ? ' Saving...' : ''), + type: 'success' + }) + ); + + return Observable.merge(challengeCompleted$, updateUser$) + .delay(300) + .startWith(hikeCompleted(finalAnswer)) + .catch(error => Observable.just({ + type: 'error', + error + })); + }; +}; diff --git a/common/app/routes/Hikes/redux/fetch-hikes-saga.js b/common/app/routes/Hikes/redux/fetch-hikes-saga.js new file mode 100644 index 0000000000..07d482f358 --- /dev/null +++ b/common/app/routes/Hikes/redux/fetch-hikes-saga.js @@ -0,0 +1,46 @@ +import { Observable } from 'rx'; +import { normalize, Schema, arrayOf } from 'normalizr'; +// import debug from 'debug'; + +import types from './types'; +import { fetchHikesCompleted } from './actions'; +import { handleError } from '../../../redux/types'; + +import { getCurrentHike } 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) } + ); + + hikes = { + entities: entities.hike, + results: result.hikes + }; + + const currentHike = getCurrentHike(hikes, dashedName); + + console.log('foo', currentHike); + return fetchHikesCompleted(hikes, currentHike); + }) + .catch(error => { + return Observable.just({ + type: handleError, + error + }); + }) + .doOnNext(dispatch); + }; +}; diff --git a/common/app/routes/Hikes/redux/index.js b/common/app/routes/Hikes/redux/index.js new file mode 100644 index 0000000000..8d94299b34 --- /dev/null +++ b/common/app/routes/Hikes/redux/index.js @@ -0,0 +1,8 @@ +export actions from './actions'; +export reducer from './reducer'; +export types from './types'; + +import answerSaga from './answer-saga'; +import fetchHikesSaga from './fetch-hikes-saga'; + +export const sagas = [ answerSaga, fetchHikesSaga ]; diff --git a/common/app/routes/Hikes/flux/Actions.js b/common/app/routes/Hikes/redux/oldActions.js similarity index 99% rename from common/app/routes/Hikes/flux/Actions.js rename to common/app/routes/Hikes/redux/oldActions.js index 5395c23602..281306feec 100644 --- a/common/app/routes/Hikes/flux/Actions.js +++ b/common/app/routes/Hikes/redux/oldActions.js @@ -3,7 +3,7 @@ import { Observable } from 'rx'; import { Actions } from 'thundercats'; import debugFactory from 'debug'; -const debug = debugFactory('freecc:hikes:actions'); +const debug = debugFactory('fcc:hikes:actions'); const noOp = { transform: () => {} }; function getCurrentHike(hikes = [{}], dashedName, currentHike) { diff --git a/common/app/routes/Hikes/redux/reducer.js b/common/app/routes/Hikes/redux/reducer.js new file mode 100644 index 0000000000..e6a49f0964 --- /dev/null +++ b/common/app/routes/Hikes/redux/reducer.js @@ -0,0 +1,88 @@ +import { handleActions } from 'redux-actions'; +import types from './types'; +import { findNextHike } from './utils'; + +const initialState = { + hikes: { + results: [], + entities: {} + }, + // lecture state + currentHike: '', + showQuestions: false +}; + +export default handleActions( + { + [types.toggleQuestion]: state => ({ + ...state, + showQuestions: !state.showQuestions, + currentQuestion: 1 + }), + + [types.grabQuestion]: (state, { payload: { delta, mouse } }) => ({ + ...state, + isPressed: true, + delta, + mouse + }), + + [types.releaseQuestion]: state => ({ + ...state, + isPressed: false, + mouse: [ 0, 0 ] + }), + + [types.moveQuestion]: (state, { payload: mouse }) => ({ ...state, mouse }), + + [types.resetHike]: state => ({ + ...state, + currentQuestion: 1, + showQuestions: false, + mouse: [0, 0], + delta: [0, 0] + }), + + [types.startShake]: state => ({ ...state, shake: true }), + [types.endShake]: state => ({ ...state, shake: false }), + + [types.primeNextQuestion]: (state, { payload: userAnswer }) => ({ + ...state, + currentQuestion: state.currentQuestion + 1, + mouse: [ userAnswer ? 1000 : -1000, 0], + isPressed: false + }), + + [types.goToNextQuestion]: state => ({ + ...state, + mouse: [ 0, 0 ] + }), + + [types.hikeCompleted]: (state, { payload: userAnswer } ) => ({ + ...state, + isCorrect: true, + isPressed: false, + delta: [ 0, 0 ], + mouse: [ userAnswer ? 1000 : -1000, 0] + }), + + [types.goToNextHike]: state => ({ + ...state, + currentHike: findNextHike(state.hikes, state.currentHike.id), + showQuestions: false, + currentQuestion: 1, + mouse: [ 0, 0 ] + }), + + [types.fetchHikesCompleted]: (state, { payload }) => { + const { hikes, currentHike } = payload; + + return { + ...state, + hikes, + currentHike + }; + } + }, + initialState +); diff --git a/common/app/routes/Hikes/redux/types.js b/common/app/routes/Hikes/redux/types.js new file mode 100644 index 0000000000..c96ae0e8e5 --- /dev/null +++ b/common/app/routes/Hikes/redux/types.js @@ -0,0 +1,23 @@ +const types = [ + 'fetchHikes', + 'fetchHikesCompleted', + + 'toggleQuestionView', + 'grabQuestion', + 'releaseQuestion', + 'moveQuestion', + + 'answer', + + 'startShake', + 'endShake', + + 'primeNextQuestion', + 'goToNextQuestion', + + 'hikeCompleted', + 'goToNextHike' +]; + +export default types + .reduce((types, type) => ({ ...types, [type]: `videos.${type}` }), {}); diff --git a/common/app/routes/Hikes/redux/utils.js b/common/app/routes/Hikes/redux/utils.js new file mode 100644 index 0000000000..d03b33dc9a --- /dev/null +++ b/common/app/routes/Hikes/redux/utils.js @@ -0,0 +1,74 @@ +import debug from 'debug'; +import _ from 'lodash'; + +const log = debug('fcc:hikes:utils'); + +function getFirstHike(hikes) { + return hikes.results[0]; +} + +// interface Hikes { +// results: String[], +// entities: { +// hikeId: Challenge +// } +// } +// +// findCurrentHike({ +// hikes: Hikes, +// dashedName: String +// }) => String +export function findCurrentHike(hikes = {}, dashedName) { + if (!dashedName) { + return getFirstHike(hikes) || {}; + } + + const filterRegex = new RegExp(dashedName, 'i'); + + return hikes + .results + .filter(dashedName => { + return filterRegex.test(dashedName); + }) + .reduce((throwAway, hike) => { + return hike; + }, {}); +} + +export function getCurrentHike(hikes = {}, dashedName) { + if (!dashedName) { + return getFirstHike(hikes) || {}; + } + return hikes.entities[dashedName]; +} + +export function findNextHike({ entities, results }, dashedName) { + if (!dashedName) { + log('find next hike no id provided'); + return entities[results[0]]; + } + const currentIndex = _.findIndex( + results, + ({ dashedName: _dashedName }) => _dashedName === dashedName + ); + + if (currentIndex >= results.length) { + return ''; + } + + return entities[results[currentIndex + 1]]; +} + + +export function getMouse(e, [dx, dy]) { + let { pageX, pageY, touches, changedTouches } = e; + + // touches can be empty on touchend + if (touches || changedTouches) { + e.preventDefault(); + // these re-assigns the values of pageX, pageY from touches + ({ pageX, pageY } = touches[0] || changedTouches[0]); + } + + return [pageX - dx, pageY - dy]; +} diff --git a/common/app/routes/Jobs/components/NewJob.jsx b/common/app/routes/Jobs/components/NewJob.jsx index 83aba5ee5b..43f2dfb6ef 100644 --- a/common/app/routes/Jobs/components/NewJob.jsx +++ b/common/app/routes/Jobs/components/NewJob.jsx @@ -26,7 +26,7 @@ import { isURL } from 'validator'; -const debug = debugFactory('freecc:jobs:newForm'); +const debug = debugFactory('fcc:jobs:newForm'); const checkValidity = [ 'position', diff --git a/common/app/sagas.js b/common/app/sagas.js new file mode 100644 index 0000000000..fc40bc384c --- /dev/null +++ b/common/app/sagas.js @@ -0,0 +1,6 @@ +import { sagas as appSagas } from './redux'; +import { sagas as hikesSagas} from './routes/Hikes/redux'; +export default [ + ...appSagas, + ...hikesSagas +]; diff --git a/common/app/Cat.js b/common/app/temp.js similarity index 61% rename from common/app/Cat.js rename to common/app/temp.js index 8673cb2d8a..4ed6ed821d 100644 --- a/common/app/Cat.js +++ b/common/app/temp.js @@ -1,20 +1,6 @@ -import { Cat } from 'thundercats'; import stamp from 'stampit'; -import { Disposable, Observable } from 'rx'; - import { post$, postJSON$ } from '../utils/ajax-stream.js'; -import { AppActions, AppStore } from './flux'; -import HikesActions from './routes/Hikes/flux'; -import JobActions from './routes/Jobs/flux'; -const ajaxStamp = stamp({ - methods: { - postJSON$, - post$ - } -}); - -export default Cat().init(({ instance: cat, args: [services] }) => { const serviceStamp = stamp({ methods: { readService$(resource, params, config) { @@ -52,14 +38,3 @@ export default Cat().init(({ instance: cat, args: [services] }) => { } } }); - - cat.register(HikesActions.compose(serviceStamp, ajaxStamp), null, services); - cat.register(AppActions.compose(serviceStamp), null, services); - cat.register( - JobActions.compose(serviceStamp, ajaxStamp), - null, - cat, - services - ); - cat.register(AppStore, null, cat); -}); diff --git a/common/app/utils/Professor-Context.js b/common/app/utils/Professor-Context.js new file mode 100644 index 0000000000..0ba3133ff3 --- /dev/null +++ b/common/app/utils/Professor-Context.js @@ -0,0 +1,42 @@ +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 new file mode 100644 index 0000000000..ca1522ac0c --- /dev/null +++ b/common/app/utils/professor-x.js @@ -0,0 +1,196 @@ +import React, { PropTypes, createElement } from 'react'; +import { Observable, CompositeDisposable } from 'rx'; +import debug from 'debug'; + +// interface contain { +// (options?: Object, Component: ReactComponent) => ReactComponent +// (options?: Object) => (Component: ReactComponent) => ReactComponent +// } +// +// Action: { type: String, payload: Any, ...meta } +// +// ActionCreator(...args) => Observable +// +// interface options { +// fetchAction?: ActionCreator, +// getActionArgs?(props: Object, context: Object) => [], +// isPrimed?(props: Object, context: Object) => Boolean, +// handleError?(err) => Void +// shouldRefetch?( +// props: Object, +// nextProps: Object, +// context: Object, +// nextContext: Object +// ) => Boolean, +// } + + +const log = debug('fcc:professerx'); + +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'; + +export default function contain(options = {}, Component) { + /* istanbul ignore else */ + if (!Component) { + return contain.bind(null, options); + } + + let action; + let isActionable = false; + let hasRefetcher = typeof options.shouldRefetch === 'function'; + + const getActionArgs = typeof options.getActionArgs === 'function' ? + options.getActionArgs : + (() => []); + + const isPrimed = typeof typeof options.isPrimed === 'function' ? + options.isPrimed : + (() => false); + + + return class Container extends React.Component { + constructor(props, context) { + super(props, context); + this.__subscriptions = new CompositeDisposable(); + } + + static displayName = `Container(${Component.displayName})`; + static propTypes = Component.propTypes; + + static contextTypes = { + ...Component.contextTypes, + professor: PropTypes.object + }; + + componentWillMount() { + const { professor } = this.context; + const { props } = this; + if (!options.fetchAction) { + log(`${Component.displayName} has no fetch action defined`); + return null; + } + + action = props[options.fetchAction]; + isActionable = typeof action === 'function'; + + if (__DEV__ && typeof action !== 'function') { + throw new Error( + `${options.fetchAction} should return a function but got ${action}. + Check the fetch options for ${Component.displayName}.` + ); + } + + if ( + !professor || + !professor.fetchContext + ) { + log( + `${Component.displayName} did not have professor defined on context` + ); + return null; + } + + + const actionArgs = getActionArgs( + props, + getChildContext(Component.contextTypes, this.context) + ); + + 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 + ); + this.__subscriptions.add(subscription); + } + + componentWillReceiveProps(nextProps, nextContext) { + if ( + !isActionable || + !hasRefetcher || + !options.shouldRefetch( + this.props, + nextProps, + getChildContext(Component.contextTypes, this.context), + getChildContext(Component.contextTypes, 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' + ); + } + + const subscription = fetch$.subscribe( + () => {}, + options.errorHandler + ); + + this.__subscriptions.add(subscription); + } + + componentWillUnmount() { + if (this.__subscriptions) { + this.__subscriptions.dispose(); + } + } + + shouldComponentUpdate() { + // props should be immutable + return false; + } + + render() { + const { props } = this; + + return createElement( + Component, + props + ); + } + }; +} diff --git a/common/app/utils/render-to-string.js b/common/app/utils/render-to-string.js new file mode 100644 index 0000000000..b19b11bf55 --- /dev/null +++ b/common/app/utils/render-to-string.js @@ -0,0 +1,52 @@ +import { Observable, Scheduler } 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; + try { + ContextedComponent = ProfessorContext.wrap(Component, professor); + log('initiating fetcher registration'); + ReactDOM.renderToStaticMarkup(ContextedComponent); + log('fetcher registration completed'); + } catch (e) { + return Observable.throw(e); + } + return fetch(professor) + .last() + .delay(0) + .map(() => { + const markup = ReactDOM.renderToString(Component); + return { + markup, + fetchContext + }; + }); +} diff --git a/common/app/utils/render.js b/common/app/utils/render.js new file mode 100644 index 0000000000..c30c682740 --- /dev/null +++ b/common/app/utils/render.js @@ -0,0 +1,26 @@ +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() { + observer.onNext(this); + }); + } catch (e) { + return observer.onError(e); + } + + return Disposable.create(() => { + return ReactDOM.unmountComponentAtNode(DOMContainer); + }); + }); +} diff --git a/common/app/utils/shallow-equals.js b/common/app/utils/shallow-equals.js new file mode 100644 index 0000000000..3f8ba3912f --- /dev/null +++ b/common/app/utils/shallow-equals.js @@ -0,0 +1,37 @@ +// original sourc +// https://github.com/rackt/react-redux/blob/master/src/utils/shallowEqual.js +// MIT license +export default function shallowEqual(objA, objB) { + if (objA === objB) { + return true; + } + + if ( + typeof objA !== 'object' || + objA === null || + typeof objB !== 'object' || + objB === null + ) { + return false; + } + + var keysA = Object.keys(objA); + var keysB = Object.keys(objB); + + if (keysA.length !== keysB.length) { + return false; + } + + // Test for A's keys different from B. + var bHasOwnProperty = Object.prototype.hasOwnProperty.bind(objB); + for (var i = 0; i < keysA.length; i++) { + if ( + !bHasOwnProperty(keysA[i]) || + objA[keysA[i]] !== objB[keysA[i]] + ) { + return false; + } + } + + return true; +} diff --git a/common/models/User-Identity.js b/common/models/User-Identity.js index a6c5241043..2d1c357c35 100644 --- a/common/models/User-Identity.js +++ b/common/models/User-Identity.js @@ -10,7 +10,7 @@ import { const { defaultProfileImage } = require('../utils/constantStrings.json'); const githubRegex = (/github/i); -const debug = debugFactory('freecc:models:userIdent'); +const debug = debugFactory('fcc:models:userIdent'); function createAccessToken(user, ttl, cb) { if (arguments.length === 2 && typeof ttl === 'function') { diff --git a/common/models/promo.js b/common/models/promo.js index 9a423bdd6b..4dedb9a940 100644 --- a/common/models/promo.js +++ b/common/models/promo.js @@ -1,7 +1,7 @@ import { isAlphanumeric, isHexadecimal } from 'validator'; import debug from 'debug'; -const log = debug('freecc:models:promo'); +const log = debug('fcc:models:promo'); export default function promo(Promo) { Promo.getButton = function getButton(id, code, type = 'isNot') { diff --git a/common/models/user.js b/common/models/user.js index 6d45beb6c2..952e665fad 100644 --- a/common/models/user.js +++ b/common/models/user.js @@ -7,7 +7,7 @@ import debugFactory from 'debug'; import { saveUser, observeMethod } from '../../server/utils/rx'; import { blacklistedUsernames } from '../../server/utils/constants'; -const debug = debugFactory('freecc:user:remote'); +const debug = debugFactory('fcc:user:remote'); const BROWNIEPOINTS_TIMEOUT = [1, 'hour']; function getAboutProfile({ diff --git a/common/utils/ajax-stream.js b/common/utils/ajax-stream.js index 665e23348c..1a95784a53 100644 --- a/common/utils/ajax-stream.js +++ b/common/utils/ajax-stream.js @@ -19,7 +19,7 @@ import debugFactory from 'debug'; import { Observable, AnonymousObservable, helpers } from 'rx'; -const debug = debugFactory('freecc:ajax$'); +const debug = debugFactory('fcc:ajax$'); const root = typeof window !== 'undefined' ? window : {}; // Gets the proper XMLHttpRequest for support for older IE diff --git a/common/utils/services-creator.js b/common/utils/services-creator.js new file mode 100644 index 0000000000..aa7e8bfe97 --- /dev/null +++ b/common/utils/services-creator.js @@ -0,0 +1,48 @@ +import{ Observable, Disposable } from 'rx'; +import Fetchr from 'fetchr'; +import stampit from 'stampit'; + +function callbackObserver(observer) { + return (err, res) => { + if (err) { + return observer.onError(err); + } + + observer.onNext(res); + observer.onCompleted(); + }; +} + + +export default stampit({ + init({ args: [ options ] }) { + this.services = new Fetchr(options); + }, + methods: { + readService$({ service: resource, params, config }) { + return Observable.create(observer => { + this.services.read( + resource, + params, + config, + callbackObserver(observer) + ); + + return Disposable.create(() => observer.dispose()); + }); + }, + createService$({ service: resource, params, body, config }) { + return Observable.create(function(observer) { + this.services.create( + resource, + params, + body, + config, + callbackObserver(observer) + ); + + return Disposable.create(() => observer.dispose()); + }); + } + } +}); diff --git a/gulpfile.js b/gulpfile.js index 470c7add7e..86d1afc098 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -1,5 +1,5 @@ // enable debug for gulp -process.env.DEBUG = process.env.DEBUG || 'freecc:*'; +process.env.DEBUG = process.env.DEBUG || 'fcc:*'; require('babel-core/register'); var Rx = require('rx'), @@ -12,7 +12,7 @@ var Rx = require('rx'), gutil = require('gulp-util'), reduce = require('gulp-reduce-file'), sortKeys = require('sort-keys'), - debug = require('debug')('freecc:gulp'), + debug = require('debug')('fcc:gulp'), yargs = require('yargs'), concat = require('gulp-concat'), uglify = require('gulp-uglify'), @@ -98,7 +98,10 @@ var paths = { 'public/bower_components/bootstrap/dist/js/bootstrap.min.js', 'public/bower_components/d3/d3.min.js', 'public/bower_components/moment/min/moment.min.js', - 'public/bower_components/moment-timezone/builds/moment-timezone-with-data.min.js', + + 'public/bower_components/' + + 'moment-timezone/builds/moment-timezone-with-data.min.js', + 'public/bower_components/mousetrap/mousetrap.min.js', 'public/bower_components/lightbox2/dist/js/lightbox.min.js', 'public/bower_components/rxjs/dist/rx.all.min.js' @@ -194,7 +197,7 @@ gulp.task('serve', ['build-manifest'], function(cb) { exec: path.join(__dirname, 'node_modules/.bin/babel-node'), env: { 'NODE_ENV': process.env.NODE_ENV || 'development', - 'DEBUG': process.env.DEBUG || 'freecc:*' + 'DEBUG': process.env.DEBUG || 'fcc:*' } }) .on('start', function() { diff --git a/package.json b/package.json index 8efd96aab2..15e4d5a240 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,7 @@ "node-uuid": "^1.4.3", "nodemailer": "^2.1.0", "normalize-url": "^1.3.1", + "normalizr": "^2.0.0", "object.assign": "^4.0.3", "passport-facebook": "^2.0.0", "passport-github": "^1.0.0", @@ -105,11 +106,18 @@ "react-bootstrap": "~0.28.1", "react-dom": "~0.14.3", "react-motion": "~0.4.2", + "react-pure-render": "^1.0.2", + "react-redux": "^4.0.6", "react-router": "^1.0.0", "react-router-bootstrap": "https://github.com/FreeCodeCamp/react-router-bootstrap.git#freecodecamp", "react-toastr": "^2.4.0", + "react-router-redux": "^2.1.0", "react-vimeo": "~0.1.0", + "redux": "^3.0.5", + "redux-actions": "^0.9.1", + "redux-form": "^4.1.4", "request": "^2.65.0", + "reselect": "^2.0.2", "rev-del": "^1.0.5", "rx": "^4.0.0", "sanitize-html": "^1.11.1", diff --git a/server/boot/a-extendUser.js b/server/boot/a-extendUser.js index b212550a8a..d1f58cd319 100644 --- a/server/boot/a-extendUser.js +++ b/server/boot/a-extendUser.js @@ -1,7 +1,7 @@ import { Observable } from 'rx'; import debugFactory from 'debug'; -const debug = debugFactory('freecc:user:remote'); +const debug = debugFactory('fcc:user:remote'); function destroyAllRelated(id, Model) { return Observable.fromNodeCallback( diff --git a/server/boot/a-react.js b/server/boot/a-react.js index e3d7711839..009e585361 100644 --- a/server/boot/a-react.js +++ b/server/boot/a-react.js @@ -1,14 +1,14 @@ import React from 'react'; import { RoutingContext } from 'react-router'; -import Fetchr from 'fetchr'; import { createLocation } from 'history'; -import debugFactory from 'debug'; -import { dehydrate } from 'thundercats'; -import { renderToString$ } from 'thundercats-react'; +import debug from 'debug'; + +import renderToString from '../../common/app/utils/render-to-string'; +import provideStore from '../../common/app/provide-store'; import app$ from '../../common/app'; -const debug = debugFactory('freecc:react-server'); +const log = debug('fcc:react-server'); // add routes here as they slowly get reactified // remove their individual controllers @@ -38,52 +38,43 @@ export default function reactSubRouter(app) { app.use(router); function serveReactApp(req, res, next) { - const services = new Fetchr({ req }); + const serviceOptions = { req }; const location = createLocation(req.path); // returns a router wrapped app - app$({ location }) + app$({ + location, + serviceOptions + }) // if react-router does not find a route send down the chain - .filter(function({ props }) { + .filter(({ props }) => { if (!props) { - debug('react tried to find %s but got 404', location.pathname); + log(`react tried to find ${location.pathname} but got 404`); return next(); } return !!props; }) - .flatMap(function({ props, AppCat }) { - const cat = AppCat(null, services); - debug('render react markup and pre-fetch data'); - const store = cat.getStore('appStore'); + .flatMap(({ props, store }) => { + log('render react markup and pre-fetch data'); - // primes store to observe action changes - // cleaned up by cat.dispose further down - store.subscribe(() => {}); - - return renderToString$( - cat, - React.createElement(RoutingContext, props) + return renderToString( + provideStore(React.createElement(RoutingContext, props), store) ) - .flatMap( - dehydrate(cat), - ({ markup }, data) => ({ markup, data, cat }) - ); + .map(({ markup }) => ({ markup, store })); }) - .flatMap(function({ data, markup, cat }) { - debug('react markup rendered, data fetched'); - cat.dispose(); - const { title } = data.AppStore; - res.expose(data, 'data'); + .flatMap(function({ markup, store }) { + log('react markup rendered, data fetched'); + const state = store.getState(); + const { title } = state.app.title; + res.expose(state, 'data'); return res.render$( 'layout-react', { markup, title } ); }) + .doOnNext(markup => res.send(markup)) .subscribe( - function(markup) { - debug('html rendered and ready to send'); - res.send(markup); - }, + () => log('html rendered and ready to send'), next ); } diff --git a/server/boot/certificate.js b/server/boot/certificate.js index e4082e5f74..6775edd3a0 100644 --- a/server/boot/certificate.js +++ b/server/boot/certificate.js @@ -22,7 +22,7 @@ import { import certTypes from '../utils/certTypes.json'; -const log = debug('freecc:certification'); +const log = debug('fcc:certification'); const sendMessageToNonUser = ifNoUserSend( 'must be logged in to complete.' ); diff --git a/server/boot/challenge.js b/server/boot/challenge.js index 957483f279..1555566dc0 100644 --- a/server/boot/challenge.js +++ b/server/boot/challenge.js @@ -26,7 +26,7 @@ import badIdMap from '../utils/bad-id-map'; const isDev = process.env.NODE_ENV !== 'production'; const isBeta = !!process.env.BETA; -const log = debug('freecc:challenges'); +const log = debug('fcc:challenges'); const challengesRegex = /^(bonfire|waypoint|zipline|basejump|checkpoint)/i; const challengeView = { 0: 'challenges/showHTML', diff --git a/server/boot/commit.js b/server/boot/commit.js index 8d5805ee2a..4df3af2f3d 100644 --- a/server/boot/commit.js +++ b/server/boot/commit.js @@ -34,7 +34,7 @@ const sendNonUserToCommit = ifNoUserRedirectTo( 'info' ); -const debug = debugFactory('freecc:commit'); +const debug = debugFactory('fcc:commit'); function findNonprofit(name) { let nonprofit; diff --git a/server/boot/randomAPIs.js b/server/boot/randomAPIs.js index 8daa663b3a..ed8e886f08 100644 --- a/server/boot/randomAPIs.js +++ b/server/boot/randomAPIs.js @@ -2,7 +2,7 @@ var Rx = require('rx'), async = require('async'), moment = require('moment'), request = require('request'), - debug = require('debug')('freecc:cntr:resources'), + debug = require('debug')('fcc:cntr:resources'), constantStrings = require('../utils/constantStrings.json'), labs = require('../resources/labs.json'), testimonials = require('../resources/testimonials.json'), diff --git a/server/boot/story.js b/server/boot/story.js index 516ce9e268..0f43aef34e 100755 --- a/server/boot/story.js +++ b/server/boot/story.js @@ -2,7 +2,7 @@ var Rx = require('rx'), assign = require('object.assign'), sanitizeHtml = require('sanitize-html'), moment = require('moment'), - debug = require('debug')('freecc:cntr:story'), + debug = require('debug')('fcc:cntr:story'), utils = require('../utils'), observeMethod = require('../utils/rx').observeMethod, saveUser = require('../utils/rx').saveUser, diff --git a/server/boot/user.js b/server/boot/user.js index bfb0ca6afa..5eebc4dcb8 100644 --- a/server/boot/user.js +++ b/server/boot/user.js @@ -19,7 +19,7 @@ import { calcLongestStreak } from '../utils/user-stats'; -const debug = debugFactory('freecc:boot:user'); +const debug = debugFactory('fcc:boot:user'); const sendNonUserToMap = ifNoUserRedirectTo('/map'); const certIds = { [certTypes.frontEnd]: frontEndChallengeId, diff --git a/server/services/hikes.js b/server/services/hikes.js index bd4a6810e1..e672d857f3 100644 --- a/server/services/hikes.js +++ b/server/services/hikes.js @@ -1,23 +1,23 @@ import debugFactory from 'debug'; import assign from 'object.assign'; -const debug = debugFactory('freecc:services:hikes'); +const debug = debugFactory('fcc:services:hikes'); export default function hikesService(app) { const Challenge = app.models.Challenge; return { name: 'hikes', - read: (req, resource, params, config, cb) => { + read: (req, resource, { dashedName } = {}, config, cb) => { const query = { where: { challengeType: '6' }, order: ['order ASC', 'suborder ASC' ] }; - debug('params', params); - if (params) { + debug('dashedName', dashedName); + if (dashedName) { assign(query.where, { - dashedName: { like: params.dashedName, options: 'i' } + dashedName: { like: dashedName, options: 'i' } }); } debug('query', query); diff --git a/server/services/user.js b/server/services/user.js index 3fa4dc40c1..a2816e818c 100644 --- a/server/services/user.js +++ b/server/services/user.js @@ -2,7 +2,7 @@ import debugFactory from 'debug'; import assign from 'object.assign'; const censor = '**********************:P********'; -const debug = debugFactory('freecc:services:user'); +const debug = debugFactory('fcc:services:user'); const protectedUserFields = { id: censor, password: censor, diff --git a/server/utils/commit.js b/server/utils/commit.js index 02ce7ab5dc..0a9474c40a 100644 --- a/server/utils/commit.js +++ b/server/utils/commit.js @@ -3,7 +3,7 @@ import debugFactory from 'debug'; import { Observable } from 'rx'; import commitGoals from './commit-goals.json'; -const debug = debugFactory('freecc:utils/commit'); +const debug = debugFactory('fcc:utils/commit'); export { commitGoals }; diff --git a/server/utils/rx.js b/server/utils/rx.js index 68086d2563..0900cc7bfc 100644 --- a/server/utils/rx.js +++ b/server/utils/rx.js @@ -1,7 +1,7 @@ import Rx from 'rx'; import debugFactory from 'debug'; -const debug = debugFactory('freecc:rxUtils'); +const debug = debugFactory('fcc:rxUtils'); export function saveInstance(instance) { return new Rx.Observable.create(function(observer) {