From 8ef3fdb6a0ad2db5f2496de60ddd5162044f007e Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Wed, 27 Jan 2016 11:34:44 -0800 Subject: [PATCH 01/27] Initial move to redux --- .eslintrc | 2 +- client/err-saga.js | 9 - client/history-saga.js | 69 ------ client/index.js | 122 +++++------ client/sagas/README.md | 0 client/sagas/err-saga.js | 19 ++ client/sagas/index.js | 4 + client/sagas/title-saga.js | 16 ++ common/app/App.jsx | 150 +++++++------- common/app/app-stream.jsx | 17 -- common/app/components/Footer/README.md | 1 + common/app/components/Nav/Nav.jsx | 22 +- common/app/components/NotFound/index.jsx | 18 +- common/app/create-app.jsx | 79 +++++++ common/app/create-reducer.js | 12 ++ common/app/flux/Store.js | 103 --------- common/app/flux/index.js | 2 - common/app/index.js | 2 +- common/app/middlewares.js | 0 common/app/provide-Store.js | 11 + common/app/redux/actions.js | 21 ++ common/app/redux/fetch-user-saga.js | 39 ++++ common/app/redux/index.js | 6 + .../{flux/Actions.js => redux/oldActions.js} | 0 common/app/redux/reducer.js | 38 ++++ common/app/redux/types.js | 14 ++ common/app/routes/FAVS/README.md | 1 - common/app/routes/Hikes/components/Hike.jsx | 123 ++++++----- common/app/routes/Hikes/components/Hikes.jsx | 141 +++++++------ .../app/routes/Hikes/components/Lecture.jsx | 176 ++++++++-------- common/app/routes/Hikes/flux/index.js | 1 - common/app/routes/Hikes/index.js | 5 - common/app/routes/Hikes/redux/actions.js | 54 +++++ common/app/routes/Hikes/redux/answer-saga.js | 128 ++++++++++++ .../routes/Hikes/redux/fetch-hikes-saga.js | 46 ++++ common/app/routes/Hikes/redux/index.js | 8 + .../{flux/Actions.js => redux/oldActions.js} | 2 +- common/app/routes/Hikes/redux/reducer.js | 88 ++++++++ common/app/routes/Hikes/redux/types.js | 23 ++ common/app/routes/Hikes/redux/utils.js | 74 +++++++ common/app/routes/Jobs/components/NewJob.jsx | 2 +- common/app/sagas.js | 6 + common/app/{Cat.js => temp.js} | 25 --- common/app/utils/Professor-Context.js | 42 ++++ common/app/utils/professor-x.js | 196 ++++++++++++++++++ common/app/utils/render-to-string.js | 52 +++++ common/app/utils/render.js | 26 +++ common/app/utils/shallow-equals.js | 37 ++++ common/models/User-Identity.js | 2 +- common/models/promo.js | 2 +- common/models/user.js | 2 +- common/utils/ajax-stream.js | 2 +- common/utils/services-creator.js | 48 +++++ gulpfile.js | 11 +- package.json | 8 + server/boot/a-extendUser.js | 2 +- server/boot/a-react.js | 57 +++-- server/boot/certificate.js | 2 +- server/boot/challenge.js | 2 +- server/boot/commit.js | 2 +- server/boot/randomAPIs.js | 2 +- server/boot/story.js | 2 +- server/boot/user.js | 2 +- server/services/hikes.js | 10 +- server/services/user.js | 2 +- server/utils/commit.js | 2 +- server/utils/rx.js | 2 +- 67 files changed, 1527 insertions(+), 667 deletions(-) delete mode 100644 client/err-saga.js delete mode 100644 client/history-saga.js create mode 100644 client/sagas/README.md create mode 100644 client/sagas/err-saga.js create mode 100644 client/sagas/index.js create mode 100644 client/sagas/title-saga.js delete mode 100644 common/app/app-stream.jsx create mode 100644 common/app/components/Footer/README.md create mode 100644 common/app/create-app.jsx create mode 100644 common/app/create-reducer.js delete mode 100644 common/app/flux/Store.js delete mode 100644 common/app/flux/index.js create mode 100644 common/app/middlewares.js create mode 100644 common/app/provide-Store.js create mode 100644 common/app/redux/actions.js create mode 100644 common/app/redux/fetch-user-saga.js create mode 100644 common/app/redux/index.js rename common/app/{flux/Actions.js => redux/oldActions.js} (100%) create mode 100644 common/app/redux/reducer.js create mode 100644 common/app/redux/types.js delete mode 100644 common/app/routes/FAVS/README.md delete mode 100644 common/app/routes/Hikes/flux/index.js create mode 100644 common/app/routes/Hikes/redux/actions.js create mode 100644 common/app/routes/Hikes/redux/answer-saga.js create mode 100644 common/app/routes/Hikes/redux/fetch-hikes-saga.js create mode 100644 common/app/routes/Hikes/redux/index.js rename common/app/routes/Hikes/{flux/Actions.js => redux/oldActions.js} (99%) create mode 100644 common/app/routes/Hikes/redux/reducer.js create mode 100644 common/app/routes/Hikes/redux/types.js create mode 100644 common/app/routes/Hikes/redux/utils.js create mode 100644 common/app/sagas.js rename common/app/{Cat.js => temp.js} (61%) create mode 100644 common/app/utils/Professor-Context.js create mode 100644 common/app/utils/professor-x.js create mode 100644 common/app/utils/render-to-string.js create mode 100644 common/app/utils/render.js create mode 100644 common/app/utils/shallow-equals.js create mode 100644 common/utils/services-creator.js 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) { From 00187628a4e4868b8240736e14652a4d37a4c560 Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Thu, 4 Feb 2016 12:40:49 -0800 Subject: [PATCH 02/27] Video's and video challenge renders --- common/app/App.jsx | 1 + common/app/routes/Hikes/components/Hike.jsx | 3 +- common/app/routes/Hikes/components/Hikes.jsx | 5 +- .../app/routes/Hikes/components/Lecture.jsx | 2 +- common/app/routes/Hikes/components/Map.jsx | 2 +- .../app/routes/Hikes/components/Questions.jsx | 349 +++++++++--------- common/app/routes/Hikes/redux/actions.js | 2 +- common/app/routes/Hikes/redux/answer-saga.js | 17 +- .../routes/Hikes/redux/fetch-hikes-saga.js | 1 - common/app/routes/Hikes/redux/reducer.js | 25 +- common/app/routes/Hikes/redux/types.js | 2 +- common/app/utils/professor-x.js | 6 +- server/services/hikes.js | 2 +- 13 files changed, 217 insertions(+), 200 deletions(-) diff --git a/common/app/App.jsx b/common/app/App.jsx index 0afb196a5b..2041bcf47d 100644 --- a/common/app/App.jsx +++ b/common/app/App.jsx @@ -63,6 +63,7 @@ export class FreeCodeCamp extends React.Component { render() { const { username, points, picture } = this.props; const navProps = { username, points, picture }; + console.log('app', this.props.children); return (
diff --git a/common/app/routes/Hikes/components/Hike.jsx b/common/app/routes/Hikes/components/Hike.jsx index 6f75ae7f9b..55c3ac623f 100644 --- a/common/app/routes/Hikes/components/Hike.jsx +++ b/common/app/routes/Hikes/components/Hike.jsx @@ -17,6 +17,7 @@ const mapStateToProps = createSelector( }; } ); + // export plain component for testing export class Hike extends React.Component { static displayName = 'Hike'; @@ -71,4 +72,4 @@ export class Hike extends React.Component { } // export redux aware component -export default connect(mapStateToProps, { resetHike }); +export default connect(mapStateToProps, { resetHike })(Hike); diff --git a/common/app/routes/Hikes/components/Hikes.jsx b/common/app/routes/Hikes/components/Hikes.jsx index 17c027a4de..7c3aa04d4a 100644 --- a/common/app/routes/Hikes/components/Hikes.jsx +++ b/common/app/routes/Hikes/components/Hikes.jsx @@ -21,10 +21,11 @@ const mapStateToProps = createSelector( return { hikes: [] }; } return { - hikes: hikes.results.map(dashedName => hikes.enitites[dashedName]) + hikes: hikes.results.map(dashedName => hikes.entities[dashedName]) }; } ); + const fetchOptions = { fetchAction: 'fetchHikes', @@ -50,8 +51,6 @@ export class Hikes extends React.Component { updateTitle('Hikes'); } - shouldComponentUpdate = shouldComponentUpdate; - renderMap(hikes) { return ( diff --git a/common/app/routes/Hikes/components/Lecture.jsx b/common/app/routes/Hikes/components/Lecture.jsx index 1aac07f492..688cdc4b08 100644 --- a/common/app/routes/Hikes/components/Lecture.jsx +++ b/common/app/routes/Hikes/components/Lecture.jsx @@ -90,4 +90,4 @@ export class Lecture extends React.Component { } } -export default connect(mapStateToProps, { })(Lecture); +export default connect(mapStateToProps)(Lecture); diff --git a/common/app/routes/Hikes/components/Map.jsx b/common/app/routes/Hikes/components/Map.jsx index 5d4fc98dfe..81d77432c0 100644 --- a/common/app/routes/Hikes/components/Map.jsx +++ b/common/app/routes/Hikes/components/Map.jsx @@ -17,7 +17,7 @@ export default React.createClass({ const vidElements = hikes.map(({ title, dashedName}) => { return ( - +

{ title }

diff --git a/common/app/routes/Hikes/components/Questions.jsx b/common/app/routes/Hikes/components/Questions.jsx index 1982391d92..7404abd5ed 100644 --- a/common/app/routes/Hikes/components/Questions.jsx +++ b/common/app/routes/Hikes/components/Questions.jsx @@ -1,177 +1,188 @@ import React, { PropTypes } from 'react'; import { spring, Motion } from 'react-motion'; -import { contain } from 'thundercats-react'; +import { connect } from 'react-redux'; import { Button, Col, Row } from 'react-bootstrap'; +import { createSelector } from 'reselect'; + +import { + answerQuestion, + moveQuestion, + releaseQuestion, + grabQuestion +} from '../redux/actions'; const answerThreshold = 100; +const actionsToBind = { + answerQuestion, + moveQuestion, + releaseQuestion, + grabQuestion +}; -export default contain( - { - store: 'appStore', - actions: ['hikesActions'], - map({ hikesApp, username }) { - const { - currentHike, - currentQuestion = 1, - mouse = [0, 0], - isCorrect = false, - delta = [0, 0], - isPressed = false, - shake = false - } = hikesApp; - return { - hike: currentHike, - currentQuestion, - mouse, - isCorrect, - delta, - isPressed, - shake, - isSignedIn: !!username - }; - } - }, - React.createClass({ - displayName: 'Questions', +const mapStateToProps = createSelector( + state => state.hikesApp.hikes.entities, + state => state.hikesApp.hikes.results, + state => state.hikesApp.ui, + state => state.app.isSignedIn, + (hikesMap, hikesByDashname, ui, isSignedIn) => { + const { + currentQuestion = 1, + mouse = [ 0, 0 ], + delta = [ 0, 0 ], + isCorrect = false, + isPressed = false, + shouldShakeQuestion = false + } = ui; - propTypes: { - hike: PropTypes.object, - currentQuestion: PropTypes.number, - mouse: PropTypes.array, - isCorrect: PropTypes.bool, - delta: PropTypes.array, - isPressed: PropTypes.bool, - shake: PropTypes.bool, - isSignedIn: PropTypes.bool, - hikesActions: PropTypes.object - }, - - handleMouseUp(e, answer, info) { - e.stopPropagation(); - if (!this.props.isPressed) { - return null; - } - - const { - hike, - currentQuestion, - isSignedIn, - delta - } = this.props; - - this.props.hikesActions.releaseQuestion(); - this.props.hikesActions.answer({ - e, - answer, - hike, - delta, - currentQuestion, - isSignedIn, - info, - threshold: answerThreshold - }); - }, - - handleMouseMove(e) { - if (!this.props.isPressed) { - return null; - } - const { delta, hikesActions } = this.props; - - hikesActions.moveQuestion({ e, delta }); - }, - - onAnswer(answer, userAnswer, info) { - const { isSignedIn, hike, currentQuestion, hikesActions } = this.props; - return (e) => { - if (e && e.preventDefault) { - e.preventDefault(); - } - - return hikesActions.answer({ - answer, - userAnswer, - currentQuestion, - hike, - info, - isSignedIn - }); - }; - }, - - renderQuestion(number, question, answer, shake, info) { - const { hikesActions } = this.props; - const mouseUp = e => this.handleMouseUp(e, answer, info); - return ({ x }) => { - const style = { - WebkitTransform: `translate3d(${ x }px, 0, 0)`, - transform: `translate3d(${ x }px, 0, 0)` - }; - return ( -
-

Question { number }

-

{ question }

-
- ); - }; - }, - - render() { - const { - hike: { tests = [] } = {}, - mouse: [x], - currentQuestion, - shake - } = this.props; - - const [ question, answer, info ] = tests[currentQuestion - 1] || []; - const questionElement = this.renderQuestion( - currentQuestion, - question, - answer, - shake, - info - ); - - return ( - this.handleMouseUp(e, answer, info) } - xs={ 8 } - xsOffset={ 2 }> - - - { questionElement } - -
-
-
- - -
- - - ); - } - }) + return { + currentQuestion, + isCorrect, + mouse, + delta, + isPressed, + shouldShakeQuestion, + isSignedIn + }; + } ); + +class Question extends React.Component { + static displayName = 'Questions'; + + static propTypes = { + // actions + answerQuestion: PropTypes.func, + releaseQuestion: PropTypes.func, + moveQuestion: PropTypes.func, + grabQuestion: PropTypes.func, + // ui state + tests: PropTypes.array, + mouse: PropTypes.array, + delta: PropTypes.array, + isCorrect: PropTypes.bool, + isPressed: PropTypes.bool, + isSignedIn: PropTypes.bool, + currentQuestion: PropTypes.number, + shouldShakeQuestion: PropTypes.bool + }; + + handleMouseUp(e, answer, info) { + e.stopPropagation(); + if (!this.props.isPressed) { + return null; + } + + const { + releaseQuestion, + answerQuestion + } = this.props; + + releaseQuestion(); + answerQuestion({ + e, + answer, + info, + threshold: answerThreshold + }); + } + + handleMouseMove(isPressed, { delta, moveQuestion }) { + if (!isPressed) { + return null; + } + return e => moveQuestion({ e, delta }); + } + + onAnswer(answer, userAnswer, info) { + const { isSignedIn, answerQuestion } = this.props; + return e => { + if (e && e.preventDefault) { + e.preventDefault(); + } + + return answerQuestion({ + answer, + userAnswer, + info, + isSignedIn + }); + }; + } + + renderQuestion(number, question, answer, shouldShakeQuestion, info) { + const { grabQuestion, isPressed } = this.props; + const mouseUp = e => this.handleMouseUp(e, answer, info); + return ({ x }) => { + const style = { + WebkitTransform: `translate3d(${ x }px, 0, 0)`, + transform: `translate3d(${ x }px, 0, 0)` + }; + return ( +
+

Question { number }

+

{ question }

+
+ ); + }; + } + + render() { + const { + tests = [], + mouse: [x], + currentQuestion, + shouldShakeQuestion + } = this.props; + + const [ question, answer, info ] = tests[currentQuestion - 1] || []; + const questionElement = this.renderQuestion( + currentQuestion, + question, + answer, + shouldShakeQuestion, + info + ); + + return ( + this.handleMouseUp(e, answer, info) } + xs={ 8 } + xsOffset={ 2 }> + + + { questionElement } + +
+
+
+ + +
+ + + ); + } +} + +export default connect(mapStateToProps, actionsToBind)(Question); diff --git a/common/app/routes/Hikes/redux/actions.js b/common/app/routes/Hikes/redux/actions.js index a2aec2358a..51eabf90ca 100644 --- a/common/app/routes/Hikes/redux/actions.js +++ b/common/app/routes/Hikes/redux/actions.js @@ -43,7 +43,7 @@ export const moveQuestion = createAction( // info: String, // threshold: Number // }) => Action -export const answer = createAction(types.answer); +export const answerQuestion = createAction(types.answerQuestion); export const startShake = createAction(types.startShake); export const endShake = createAction(types.primeNextQuestion); diff --git a/common/app/routes/Hikes/redux/answer-saga.js b/common/app/routes/Hikes/redux/answer-saga.js index 0c4cd39211..c9c2500899 100644 --- a/common/app/routes/Hikes/redux/answer-saga.js +++ b/common/app/routes/Hikes/redux/answer-saga.js @@ -10,7 +10,7 @@ import { postJSON$ } from '../../../../utils/ajax-stream'; export default () => ({ getState, dispatch }) => next => { return function answerSaga(action) { - if (types.answer !== action.type) { + if (types.answerQuestion !== action.type) { return next(action); } @@ -56,14 +56,11 @@ export default () => ({ getState, dispatch }) => next => { // incorrect question if (answer !== finalAnswer) { if (info) { - dispatch({ - type: 'makeToast', - payload: { - title: 'Hint', - message: info, - type: 'info' - } - }); + dispatch(makeToast({ + title: 'Hint', + message: info, + type: 'info' + })); } return Observable @@ -100,7 +97,7 @@ export default () => ({ getState, dispatch }) => next => { }) .catch(error => { return Observable.just({ - type: 'error', + type: 'app.error', error }); }); diff --git a/common/app/routes/Hikes/redux/fetch-hikes-saga.js b/common/app/routes/Hikes/redux/fetch-hikes-saga.js index 07d482f358..5315a38ae8 100644 --- a/common/app/routes/Hikes/redux/fetch-hikes-saga.js +++ b/common/app/routes/Hikes/redux/fetch-hikes-saga.js @@ -32,7 +32,6 @@ export default ({ services }) => ({ dispatch }) => next => { const currentHike = getCurrentHike(hikes, dashedName); - console.log('foo', currentHike); return fetchHikesCompleted(hikes, currentHike); }) .catch(error => { diff --git a/common/app/routes/Hikes/redux/reducer.js b/common/app/routes/Hikes/redux/reducer.js index e6a49f0964..d0a95d7e15 100644 --- a/common/app/routes/Hikes/redux/reducer.js +++ b/common/app/routes/Hikes/redux/reducer.js @@ -7,16 +7,27 @@ const initialState = { results: [], entities: {} }, - // lecture state + // ui + // hike dashedName currentHike: '', - showQuestions: false + // 1 indexed + currentQuestion: 1, + // [ xPosition, yPosition ] + mouse: [ 0, 0 ], + // change in mouse position since pressed + // [ xDelta, yDelta ] + delta: [ 0, 0 ], + isPressed: false, + isCorrect: false, + shouldShakeQuestion: false, + shouldShowQuestions: false }; export default handleActions( { [types.toggleQuestion]: state => ({ ...state, - showQuestions: !state.showQuestions, + shouldShowQuestions: !state.shouldShowQuestions, currentQuestion: 1 }), @@ -38,13 +49,13 @@ export default handleActions( [types.resetHike]: state => ({ ...state, currentQuestion: 1, - showQuestions: false, + shouldShowQuestions: false, mouse: [0, 0], delta: [0, 0] }), - [types.startShake]: state => ({ ...state, shake: true }), - [types.endShake]: state => ({ ...state, shake: false }), + [types.startShake]: state => ({ ...state, shouldShakeQuestion: true }), + [types.endShake]: state => ({ ...state, shouldShakeQuestion: false }), [types.primeNextQuestion]: (state, { payload: userAnswer }) => ({ ...state, @@ -68,7 +79,7 @@ export default handleActions( [types.goToNextHike]: state => ({ ...state, - currentHike: findNextHike(state.hikes, state.currentHike.id), + currentHike: findNextHike(state.hikes, state.currentHike), showQuestions: false, currentQuestion: 1, mouse: [ 0, 0 ] diff --git a/common/app/routes/Hikes/redux/types.js b/common/app/routes/Hikes/redux/types.js index c96ae0e8e5..d04ab1c359 100644 --- a/common/app/routes/Hikes/redux/types.js +++ b/common/app/routes/Hikes/redux/types.js @@ -7,7 +7,7 @@ const types = [ 'releaseQuestion', 'moveQuestion', - 'answer', + 'answerQuestion', 'startShake', 'endShake', diff --git a/common/app/utils/professor-x.js b/common/app/utils/professor-x.js index ca1522ac0c..d1b2512858 100644 --- a/common/app/utils/professor-x.js +++ b/common/app/utils/professor-x.js @@ -1,5 +1,6 @@ import React, { PropTypes, createElement } from 'react'; import { Observable, CompositeDisposable } from 'rx'; +import shouldComponentUpdate from 'react-pure-render/function'; import debug from 'debug'; // interface contain { @@ -179,10 +180,7 @@ export default function contain(options = {}, Component) { } } - shouldComponentUpdate() { - // props should be immutable - return false; - } + shouldComponentUpdate = shouldComponentUpdate; render() { const { props } = this; diff --git a/server/services/hikes.js b/server/services/hikes.js index e672d857f3..456cd80f4a 100644 --- a/server/services/hikes.js +++ b/server/services/hikes.js @@ -25,7 +25,7 @@ export default function hikesService(app) { if (err) { return cb(err); } - cb(null, hikes); + cb(null, hikes.map(hike => hike.toJSON())); }); } }; From ac32912cd58fd7e8e8a82e5f15770f73fadc0f64 Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Thu, 4 Feb 2016 14:13:13 -0800 Subject: [PATCH 03/27] Video question now loads --- common/app/App.jsx | 1 - common/app/routes/Hikes/components/Hike.jsx | 18 ++++++++------ .../app/routes/Hikes/components/Lecture.jsx | 24 +++++++++---------- .../app/routes/Hikes/components/Questions.jsx | 2 +- common/app/routes/Hikes/redux/actions.js | 3 ++- common/app/routes/Hikes/redux/reducer.js | 2 +- common/app/routes/Hikes/redux/types.js | 1 + 7 files changed, 28 insertions(+), 23 deletions(-) diff --git a/common/app/App.jsx b/common/app/App.jsx index 2041bcf47d..0afb196a5b 100644 --- a/common/app/App.jsx +++ b/common/app/App.jsx @@ -63,7 +63,6 @@ export class FreeCodeCamp extends React.Component { render() { const { username, points, picture } = this.props; const navProps = { username, points, picture }; - console.log('app', this.props.children); return (
diff --git a/common/app/routes/Hikes/components/Hike.jsx b/common/app/routes/Hikes/components/Hike.jsx index 55c3ac623f..a18c3e3181 100644 --- a/common/app/routes/Hikes/components/Hike.jsx +++ b/common/app/routes/Hikes/components/Hike.jsx @@ -10,10 +10,12 @@ import { resetHike } from '../redux/actions'; const mapStateToProps = createSelector( state => state.hikesApp.hikes.entities, state => state.hikesApp.currentHike, - (hikes, currentHikeDashedName) => { + state => state.hikesApp, + (hikes, currentHikeDashedName, { shouldShowQuestions }) => { const currentHike = hikes[currentHikeDashedName]; return { - title: currentHike.title + title: currentHike ? currentHike.title : '', + shouldShowQuestions }; } ); @@ -23,10 +25,12 @@ export class Hike extends React.Component { static displayName = 'Hike'; static propTypes = { - title: PropTypes.object, - params: PropTypes.object, + // actions resetHike: PropTypes.func, - showQuestions: PropTypes.bool + // ui + title: PropTypes.string, + params: PropTypes.object, + shouldShowQuestions: PropTypes.bool }; componentWillUnmount() { @@ -49,7 +53,7 @@ export class Hike extends React.Component { render() { const { title, - showQuestions + shouldShowQuestions } = this.props; return ( @@ -63,7 +67,7 @@ export class Hike extends React.Component {
- { this.renderBody(showQuestions) } + { this.renderBody(shouldShowQuestions) }
diff --git a/common/app/routes/Hikes/components/Lecture.jsx b/common/app/routes/Hikes/components/Lecture.jsx index 688cdc4b08..d0546febf8 100644 --- a/common/app/routes/Hikes/components/Lecture.jsx +++ b/common/app/routes/Hikes/components/Lecture.jsx @@ -5,6 +5,8 @@ import Vimeo from 'react-vimeo'; import { createSelector } from 'reselect'; import debug from 'debug'; +import { toggleQuestionView } from '../redux/actions'; + const log = debug('fcc:hikes'); const mapStateToProps = createSelector( @@ -29,10 +31,12 @@ export class Lecture extends React.Component { static displayName = 'Lecture'; static propTypes = { - dashedName: PropTypes.string, - description: PropTypes.array, + // actions + toggleQuestionView: PropTypes.func, + // ui id: PropTypes.string, - hikesActions: PropTypes.object + description: PropTypes.array, + dashedName: PropTypes.string }; shouldComponentUpdate(nextProps) { @@ -42,11 +46,6 @@ export class Lecture extends React.Component { handleError: log; - handleFinish(hikesActions) { - debug('loading questions'); - hikesActions.toggleQuestions(); - } - renderTranscript(transcript, dashedName) { return transcript.map((line, index) => (

this.handleFinish(hikesActions) } + onFinish= { toggleQuestionView } videoId={ id } /> @@ -81,7 +81,7 @@ export class Lecture extends React.Component { block={ true } bsSize='large' bsStyle='primary' - onClick={ () => this.handleFinish(hikesActions) }> + onClick={ toggleQuestionView }> Take me to the Questions @@ -90,4 +90,4 @@ export class Lecture extends React.Component { } } -export default connect(mapStateToProps)(Lecture); +export default connect(mapStateToProps, { toggleQuestionView })(Lecture); diff --git a/common/app/routes/Hikes/components/Questions.jsx b/common/app/routes/Hikes/components/Questions.jsx index 7404abd5ed..71d492923b 100644 --- a/common/app/routes/Hikes/components/Questions.jsx +++ b/common/app/routes/Hikes/components/Questions.jsx @@ -22,7 +22,7 @@ const actionsToBind = { const mapStateToProps = createSelector( state => state.hikesApp.hikes.entities, state => state.hikesApp.hikes.results, - state => state.hikesApp.ui, + state => state.hikesApp, state => state.app.isSignedIn, (hikesMap, hikesByDashname, ui, isSignedIn) => { const { diff --git a/common/app/routes/Hikes/redux/actions.js b/common/app/routes/Hikes/redux/actions.js index 51eabf90ca..85ca4e6769 100644 --- a/common/app/routes/Hikes/redux/actions.js +++ b/common/app/routes/Hikes/redux/actions.js @@ -14,8 +14,9 @@ export const fetchHikesCompleted = createAction( types.fetchHikesCompleted, (hikes, currentHike) => ({ hikes, currentHike }) ); +export const resetHike = createAction(types.resetHike); -export const toggleQuestion = createAction(types.toggleQuestion); +export const toggleQuestionView = createAction(types.toggleQuestionView); export const grabQuestions = createAction(types.grabQuestions, e => { let { pageX, pageY, touches } = e; diff --git a/common/app/routes/Hikes/redux/reducer.js b/common/app/routes/Hikes/redux/reducer.js index d0a95d7e15..3cc84b0c38 100644 --- a/common/app/routes/Hikes/redux/reducer.js +++ b/common/app/routes/Hikes/redux/reducer.js @@ -25,7 +25,7 @@ const initialState = { export default handleActions( { - [types.toggleQuestion]: state => ({ + [types.toggleQuestionView]: state => ({ ...state, shouldShowQuestions: !state.shouldShowQuestions, currentQuestion: 1 diff --git a/common/app/routes/Hikes/redux/types.js b/common/app/routes/Hikes/redux/types.js index d04ab1c359..d867430dad 100644 --- a/common/app/routes/Hikes/redux/types.js +++ b/common/app/routes/Hikes/redux/types.js @@ -1,6 +1,7 @@ const types = [ 'fetchHikes', 'fetchHikesCompleted', + 'resetHike', 'toggleQuestionView', 'grabQuestion', From f150b31c24df97cab2dcbe90607c17b3dceef8f8 Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Thu, 4 Feb 2016 15:03:05 -0800 Subject: [PATCH 04/27] Render Hikes questions --- common/app/redux/actions.js | 10 +++++++++- common/app/redux/reducer.js | 5 +---- common/app/routes/Hikes/components/Hike.jsx | 17 +++++++---------- common/app/routes/Hikes/components/Hikes.jsx | 13 +++++++------ common/app/routes/Hikes/components/Lecture.jsx | 9 ++++----- common/app/routes/Hikes/components/Map.jsx | 2 +- .../app/routes/Hikes/components/Questions.jsx | 16 +++++++++++----- common/app/routes/Hikes/redux/selectors.js | 8 ++++++++ 8 files changed, 48 insertions(+), 32 deletions(-) create mode 100644 common/app/routes/Hikes/redux/selectors.js diff --git a/common/app/redux/actions.js b/common/app/redux/actions.js index cdc221a97b..8bdfeeda6a 100644 --- a/common/app/redux/actions.js +++ b/common/app/redux/actions.js @@ -4,10 +4,18 @@ import types from './types'; // updateTitle(title: String) => Action export const updateTitle = createAction(types.updateTitle); +let id = 0; // makeToast({ type?: String, message: String, title: String }) => Action export const makeToast = createAction( types.makeToast, - toast => toast.type ? toast : (toast.type = 'info', toast) + toast => { + id += 1; + return { + ...toast, + id, + type: toast.type || 'info' + }; + } ); // fetchUser() => Action diff --git a/common/app/redux/reducer.js b/common/app/redux/reducer.js index 1aaece38f7..34520a7514 100644 --- a/common/app/redux/reducer.js +++ b/common/app/redux/reducer.js @@ -10,10 +10,7 @@ export default handleActions( [types.makeToast]: (state, { payload: toast }) => ({ ...state, - toast: { - ...toast, - id: state.toast && state.toast.id ? state.toast.id : 1 - } + toast }), [types.setUser]: (state, { payload: user }) => ({ ...state, ...user }), diff --git a/common/app/routes/Hikes/components/Hike.jsx b/common/app/routes/Hikes/components/Hike.jsx index a18c3e3181..5e147ea6e6 100644 --- a/common/app/routes/Hikes/components/Hike.jsx +++ b/common/app/routes/Hikes/components/Hike.jsx @@ -6,18 +6,15 @@ import { createSelector } from 'reselect'; import Lecture from './Lecture.jsx'; import Questions from './Questions.jsx'; import { resetHike } from '../redux/actions'; +import { getCurrentHike } from '../redux/selectors'; const mapStateToProps = createSelector( - state => state.hikesApp.hikes.entities, - state => state.hikesApp.currentHike, - state => state.hikesApp, - (hikes, currentHikeDashedName, { shouldShowQuestions }) => { - const currentHike = hikes[currentHikeDashedName]; - return { - title: currentHike ? currentHike.title : '', - shouldShowQuestions - }; - } + getCurrentHike, + state => state.hikesApp.shouldShowQuestions, + (currentHike, shouldShowQuestions) => ({ + title: currentHike ? currentHike.title : '', + shouldShowQuestions + }) ); // export plain component for testing diff --git a/common/app/routes/Hikes/components/Hikes.jsx b/common/app/routes/Hikes/components/Hikes.jsx index 7c3aa04d4a..62b097eba4 100644 --- a/common/app/routes/Hikes/components/Hikes.jsx +++ b/common/app/routes/Hikes/components/Hikes.jsx @@ -2,7 +2,7 @@ import React, { PropTypes } from 'react'; import { compose } from 'redux'; import { connect } from 'react-redux'; import { Row } from 'react-bootstrap'; -import shouldComponentUpdate from 'react-pure-render/function'; +import PureComponent from 'react-pure-render/component'; import { createSelector } from 'reselect'; // import debug from 'debug'; @@ -15,13 +15,14 @@ import contain from '../../../utils/professor-x'; // const log = debug('fcc:hikes'); const mapStateToProps = createSelector( - state => state.hikesApp.hikes, - hikes => { - if (!hikes || !hikes.entities || !hikes.results) { + state => state.hikesApp.hikes.entities, + state => state.hikesApp.hikes.results, + (hikesMap, hikesByDashedName)=> { + if (!hikesMap || !hikesByDashedName) { return { hikes: [] }; } return { - hikes: hikes.results.map(dashedName => hikes.entities[dashedName]) + hikes: hikesByDashedName.map(dashedName => hikesMap[dashedName]) }; } ); @@ -36,7 +37,7 @@ const fetchOptions = { } }; -export class Hikes extends React.Component { +export class Hikes extends PureComponent { static displayName = 'Hikes'; static propTypes = { diff --git a/common/app/routes/Hikes/components/Lecture.jsx b/common/app/routes/Hikes/components/Lecture.jsx index d0546febf8..2598766d69 100644 --- a/common/app/routes/Hikes/components/Lecture.jsx +++ b/common/app/routes/Hikes/components/Lecture.jsx @@ -6,19 +6,18 @@ import { createSelector } from 'reselect'; import debug from 'debug'; import { toggleQuestionView } from '../redux/actions'; +import { getCurrentHike } from '../redux/selectors'; const log = debug('fcc:hikes'); const mapStateToProps = createSelector( - state => state.hikesApp.hikes.entities, - state => state.hikesApp.currentHike, - (hikes, currentHikeDashedName) => { - const currentHike = hikes[currentHikeDashedName]; + getCurrentHike, + (currentHike) => { const { dashedName, description, challengeSeed: [id] = [0] - } = currentHike || {}; + } = currentHike; return { id, dashedName, diff --git a/common/app/routes/Hikes/components/Map.jsx b/common/app/routes/Hikes/components/Map.jsx index 81d77432c0..391c68693b 100644 --- a/common/app/routes/Hikes/components/Map.jsx +++ b/common/app/routes/Hikes/components/Map.jsx @@ -14,7 +14,7 @@ export default React.createClass({ hikes = [{}] } = this.props; - const vidElements = hikes.map(({ title, dashedName}) => { + const vidElements = hikes.map(({ title, dashedName }) => { return ( diff --git a/common/app/routes/Hikes/components/Questions.jsx b/common/app/routes/Hikes/components/Questions.jsx index 71d492923b..bad9d09819 100644 --- a/common/app/routes/Hikes/components/Questions.jsx +++ b/common/app/routes/Hikes/components/Questions.jsx @@ -10,8 +10,10 @@ import { releaseQuestion, grabQuestion } from '../redux/actions'; +import { getCurrentHike } from '../redux/selectors'; const answerThreshold = 100; +const springProperties = { stiffness: 120, damping: 10 }; const actionsToBind = { answerQuestion, moveQuestion, @@ -20,11 +22,10 @@ const actionsToBind = { }; const mapStateToProps = createSelector( - state => state.hikesApp.hikes.entities, - state => state.hikesApp.hikes.results, + getCurrentHike, state => state.hikesApp, state => state.app.isSignedIn, - (hikesMap, hikesByDashname, ui, isSignedIn) => { + (currentHike, ui, isSignedIn) => { const { currentQuestion = 1, mouse = [ 0, 0 ], @@ -34,7 +35,12 @@ const mapStateToProps = createSelector( shouldShakeQuestion = false } = ui; + const { + tests = [] + } = currentHike; + return { + tests, currentQuestion, isCorrect, mouse, @@ -138,7 +144,7 @@ class Question extends React.Component { render() { const { tests = [], - mouse: [x], + mouse: [xPosition], currentQuestion, shouldShakeQuestion } = this.props; @@ -158,7 +164,7 @@ class Question extends React.Component { xs={ 8 } xsOffset={ 2 }> - + { questionElement }

diff --git a/common/app/routes/Hikes/redux/selectors.js b/common/app/routes/Hikes/redux/selectors.js new file mode 100644 index 0000000000..f89651eada --- /dev/null +++ b/common/app/routes/Hikes/redux/selectors.js @@ -0,0 +1,8 @@ +// use this file for common selectors +import { createSelector } from 'reselect'; + +export const getCurrentHike = createSelector( + state => state.hikesApp.hikes.entities, + state => state.hikesApp.currentHike, + (hikesMap, currentHikeDashedName) => (hikesMap[currentHikeDashedName] || {}) +); From c4ba7ac46a04a1b6f838aa4f2a196c4da81011bd Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Thu, 4 Feb 2016 15:44:02 -0800 Subject: [PATCH 05/27] Fix document title syncing --- client/sagas/title-saga.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/client/sagas/title-saga.js b/client/sagas/title-saga.js index 59f357f274..c4a2a09eca 100644 --- a/client/sagas/title-saga.js +++ b/client/sagas/title-saga.js @@ -2,14 +2,15 @@ // () => // (next: (action: Action) => Object) => // titleSage(action: Action) => Object|Void -export default (doc) => () => next => { +export default ({ doc }) => ({ getState }) => next => { return function titleSage(action) { // get next state const result = next(action); - if (action !== 'updateTitle') { + if (action.type !== 'app.updateTitle') { return result; } - const newTitle = result.app.title; + const state = getState(); + const newTitle = state.app.title; doc.title = newTitle; return result; }; From 1e9c9baeddbe0192ebce4a6138338ac01935df6c Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Fri, 5 Feb 2016 16:40:02 -0800 Subject: [PATCH 06/27] Hikes loading next hike --- .../app/routes/Hikes/components/Questions.jsx | 25 +- common/app/routes/Hikes/redux/answer-saga.js | 231 ++++++++++-------- common/app/routes/Hikes/redux/reducer.js | 4 +- common/app/routes/Hikes/redux/utils.js | 13 +- 4 files changed, 156 insertions(+), 117 deletions(-) diff --git a/common/app/routes/Hikes/components/Questions.jsx b/common/app/routes/Hikes/components/Questions.jsx index bad9d09819..46011285a7 100644 --- a/common/app/routes/Hikes/components/Questions.jsx +++ b/common/app/routes/Hikes/components/Questions.jsx @@ -2,6 +2,7 @@ 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 { @@ -53,6 +54,11 @@ const mapStateToProps = createSelector( ); class Question extends React.Component { + constructor(...args) { + super(...args); + this._subscriptions = new CompositeDisposable(); + } + static displayName = 'Questions'; static propTypes = { @@ -72,6 +78,10 @@ class Question extends React.Component { shouldShakeQuestion: PropTypes.bool }; + componentWillUnmount() { + this._subscriptions.dispose(); + } + handleMouseUp(e, answer, info) { e.stopPropagation(); if (!this.props.isPressed) { @@ -84,12 +94,15 @@ class Question extends React.Component { } = this.props; releaseQuestion(); - answerQuestion({ + const subscription = answerQuestion({ e, answer, info, threshold: answerThreshold - }); + }) + .subscribe(); + + this._subscriptions.add(subscription); } handleMouseMove(isPressed, { delta, moveQuestion }) { @@ -101,17 +114,21 @@ 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(); } - return answerQuestion({ + const subscription = answerQuestion({ answer, userAnswer, info, isSignedIn - }); + }) + .subscribe(); + + subscriptions.add(subscription); }; } diff --git a/common/app/routes/Hikes/redux/answer-saga.js b/common/app/routes/Hikes/redux/answer-saga.js index c9c2500899..450814adfc 100644 --- a/common/app/routes/Hikes/redux/answer-saga.js +++ b/common/app/routes/Hikes/redux/answer-saga.js @@ -1,5 +1,5 @@ import { Observable } from 'rx'; -// import { routeActions } from 'react-simple-router'; +import { push } from 'react-router-redux'; import types from './types'; import { getMouse } from './utils'; @@ -7,119 +7,138 @@ import { getMouse } from './utils'; import { makeToast, updatePoints } from '../../../redux/actions'; import { hikeCompleted, goToNextHike } from './actions'; import { postJSON$ } from '../../../../utils/ajax-stream'; +import { getCurrentHike } from './selectors'; -export default () => ({ getState, dispatch }) => next => { - return function answerSaga(action) { - if (types.answerQuestion !== action.type) { +function handleAnswer(getState, dispatch, next, action) { + const { + e, + answer, + userAnswer, + info, + threshold + } = action.payload; + + const state = getState(); + const { id, name, challengeType, tests } = getCurrentHike(state); + const { + app: { isSignedIn }, + hikesApp: { + currentQuestion, + delta = [ 0, 0 ] + } + } = state; + + 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); } - 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; + if (positionX >= threshold) { + finalAnswer = true; } - // incorrect question - if (answer !== finalAnswer) { - if (info) { - dispatch(makeToast({ - title: 'Hint', - message: info, - type: 'info' - })); - } - - return Observable - .just({ type: types.removeShake }) - .delay(500) - .startWith({ type: types.startShake }) - .doOnNext(dispatch); + if (positionX <= -threshold) { + finalAnswer = false; } + } else { + finalAnswer = userAnswer; + } - 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: 'app.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 + // incorrect question + if (answer !== finalAnswer) { + if (info) { + dispatch(makeToast({ + title: 'Hint', + message: info, + type: 'info' })); + } + + return Observable + .just({ type: types.endShake }) + .delay(500) + .startWith({ type: types.startShake }) + .doOnNext(dispatch); + } + + if (tests[currentQuestion]) { + return Observable + .just({ type: types.goToNextQuestion }) + .delay(300) + .startWith({ type: types.primeNextQuestion }) + .doOnNext(dispatch); + } + + 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: 'app.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 + })) + .doOnNext(dispatch); +} + +export default () => ({ getState, dispatch }) => next => { + return function answerSaga(action) { + if (action.type === types.answerQuestion) { + return handleAnswer(getState, dispatch, next, action); + } + + // let goToNextQuestion hit reducers first + const result = next(action); + if (action.type === types.goToNextHike) { + 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 result; }; }; diff --git a/common/app/routes/Hikes/redux/reducer.js b/common/app/routes/Hikes/redux/reducer.js index 3cc84b0c38..44565c0de1 100644 --- a/common/app/routes/Hikes/redux/reducer.js +++ b/common/app/routes/Hikes/redux/reducer.js @@ -1,6 +1,6 @@ import { handleActions } from 'redux-actions'; import types from './types'; -import { findNextHike } from './utils'; +import { findNextHikeName } from './utils'; const initialState = { hikes: { @@ -79,7 +79,7 @@ export default handleActions( [types.goToNextHike]: state => ({ ...state, - currentHike: findNextHike(state.hikes, state.currentHike), + currentHike: findNextHikeName(state.hikes, state.currentHike), showQuestions: false, currentQuestion: 1, mouse: [ 0, 0 ] diff --git a/common/app/routes/Hikes/redux/utils.js b/common/app/routes/Hikes/redux/utils.js index d03b33dc9a..e9fc5bd777 100644 --- a/common/app/routes/Hikes/redux/utils.js +++ b/common/app/routes/Hikes/redux/utils.js @@ -42,21 +42,24 @@ export function getCurrentHike(hikes = {}, dashedName) { return hikes.entities[dashedName]; } -export function findNextHike({ entities, results }, dashedName) { +// findNextHikeName( +// hikes: { results: String[] }, +// dashedName: String +// ) => String +export function findNextHikeName({ results }, dashedName) { if (!dashedName) { log('find next hike no id provided'); - return entities[results[0]]; + return results[0]; } const currentIndex = _.findIndex( results, - ({ dashedName: _dashedName }) => _dashedName === dashedName + _dashedName => _dashedName === dashedName ); if (currentIndex >= results.length) { return ''; } - - return entities[results[currentIndex + 1]]; + return results[currentIndex + 1]; } From 943367c17ba20f9b68099cb6a2b4c8b3fe516ca6 Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Fri, 5 Feb 2016 17:09:43 -0800 Subject: [PATCH 07/27] Fix rendering specific hike from server --- common/app/routes/Hikes/components/Hikes.jsx | 2 +- common/app/routes/Hikes/redux/fetch-hikes-saga.js | 4 ++-- common/app/routes/Hikes/redux/utils.js | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/common/app/routes/Hikes/components/Hikes.jsx b/common/app/routes/Hikes/components/Hikes.jsx index 62b097eba4..f1595d61e0 100644 --- a/common/app/routes/Hikes/components/Hikes.jsx +++ b/common/app/routes/Hikes/components/Hikes.jsx @@ -31,7 +31,7 @@ const fetchOptions = { fetchAction: 'fetchHikes', isPrimed: ({ hikes }) => hikes && !!hikes.length, - getPayload: ({ params: { dashedName } }) => dashedName, + getActionArgs: ({ params: { dashedName } }) => [ dashedName ], shouldContainerFetch(props, nextProps) { return props.params.dashedName !== nextProps.params.dashedName; } diff --git a/common/app/routes/Hikes/redux/fetch-hikes-saga.js b/common/app/routes/Hikes/redux/fetch-hikes-saga.js index 5315a38ae8..2c926fe881 100644 --- a/common/app/routes/Hikes/redux/fetch-hikes-saga.js +++ b/common/app/routes/Hikes/redux/fetch-hikes-saga.js @@ -6,7 +6,7 @@ import types from './types'; import { fetchHikesCompleted } from './actions'; import { handleError } from '../../../redux/types'; -import { getCurrentHike } from './utils'; +import { findCurrentHike } from './utils'; // const log = debug('fcc:fetch-hikes-saga'); const hike = new Schema('hike', { idAttribute: 'dashedName' }); @@ -30,7 +30,7 @@ export default ({ services }) => ({ dispatch }) => next => { results: result.hikes }; - const currentHike = getCurrentHike(hikes, dashedName); + const currentHike = findCurrentHike(hikes, dashedName); return fetchHikesCompleted(hikes, currentHike); }) diff --git a/common/app/routes/Hikes/redux/utils.js b/common/app/routes/Hikes/redux/utils.js index e9fc5bd777..2a50e2fb1e 100644 --- a/common/app/routes/Hikes/redux/utils.js +++ b/common/app/routes/Hikes/redux/utils.js @@ -18,7 +18,7 @@ function getFirstHike(hikes) { // hikes: Hikes, // dashedName: String // }) => String -export function findCurrentHike(hikes = {}, dashedName) { +export function findCurrentHike(hikes, dashedName) { if (!dashedName) { return getFirstHike(hikes) || {}; } @@ -32,7 +32,7 @@ export function findCurrentHike(hikes = {}, dashedName) { }) .reduce((throwAway, hike) => { return hike; - }, {}); + }, ''); } export function getCurrentHike(hikes = {}, dashedName) { From f2494e55772fa59dfda47c3514c2b44a0631d18c Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Fri, 5 Feb 2016 17:41:30 -0800 Subject: [PATCH 08/27] Answering questions using buttons works --- common/app/routes/Hikes/redux/actions.js | 2 ++ common/app/routes/Hikes/redux/answer-saga.js | 4 +++- common/app/routes/Hikes/redux/types.js | 1 + 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/common/app/routes/Hikes/redux/actions.js b/common/app/routes/Hikes/redux/actions.js index 85ca4e6769..6df65f60cd 100644 --- a/common/app/routes/Hikes/redux/actions.js +++ b/common/app/routes/Hikes/redux/actions.js @@ -7,6 +7,7 @@ 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 @@ -14,6 +15,7 @@ export const fetchHikesCompleted = createAction( types.fetchHikesCompleted, (hikes, currentHike) => ({ hikes, currentHike }) ); + export const resetHike = createAction(types.resetHike); export const toggleQuestionView = createAction(types.toggleQuestionView); diff --git a/common/app/routes/Hikes/redux/answer-saga.js b/common/app/routes/Hikes/redux/answer-saga.js index 450814adfc..1e773a4e28 100644 --- a/common/app/routes/Hikes/redux/answer-saga.js +++ b/common/app/routes/Hikes/redux/answer-saga.js @@ -118,6 +118,8 @@ function handleAnswer(getState, dispatch, next, action) { type: 'error', error })) + // end with action so we know it is ok to transition + .doOnCompleted(() => dispatch({ type: types.transitionHike })) .doOnNext(dispatch); } @@ -129,7 +131,7 @@ export default () => ({ getState, dispatch }) => next => { // let goToNextQuestion hit reducers first const result = next(action); - if (action.type === types.goToNextHike) { + if (action.type === types.transitionHike) { const { hikesApp: { currentHike } } = getState(); // if no next hike currentHike will equal '' which is falsy if (currentHike) { diff --git a/common/app/routes/Hikes/redux/types.js b/common/app/routes/Hikes/redux/types.js index d867430dad..2e51441926 100644 --- a/common/app/routes/Hikes/redux/types.js +++ b/common/app/routes/Hikes/redux/types.js @@ -15,6 +15,7 @@ const types = [ 'primeNextQuestion', 'goToNextQuestion', + 'transitionHike', 'hikeCompleted', 'goToNextHike' From 5f9739452066a2a1a160a46d1f1e6a37dc9504e0 Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Fri, 5 Feb 2016 17:46:14 -0800 Subject: [PATCH 09/27] Fix grabQuestion actions --- common/app/routes/Hikes/redux/actions.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/common/app/routes/Hikes/redux/actions.js b/common/app/routes/Hikes/redux/actions.js index 6df65f60cd..9560ce4367 100644 --- a/common/app/routes/Hikes/redux/actions.js +++ b/common/app/routes/Hikes/redux/actions.js @@ -20,7 +20,7 @@ export const resetHike = createAction(types.resetHike); export const toggleQuestionView = createAction(types.toggleQuestionView); -export const grabQuestions = createAction(types.grabQuestions, e => { +export const grabQuestion = createAction(types.grabQuestion, e => { let { pageX, pageY, touches } = e; if (touches) { e.preventDefault(); @@ -33,7 +33,7 @@ export const grabQuestions = createAction(types.grabQuestions, e => { return { delta, mouse }; }); -export const releaseQuestion = createAction(types.releaseQuestions); +export const releaseQuestion = createAction(types.releaseQuestion); export const moveQuestion = createAction( types.moveQuestion, ({ e, delta }) => getMouse(e, delta) From 371cde1e345703e6b9cbb53e97677f08c2733d9f Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Fri, 5 Feb 2016 20:48:59 -0800 Subject: [PATCH 10/27] Initial Job move to redux --- common/app/create-reducer.js | 4 +- .../app/routes/Jobs/components/GoToPayPal.jsx | 277 ----------------- .../routes/Jobs/components/JobNotFound.jsx | 10 +- .../app/routes/Jobs/components/JobTotal.jsx | 284 ++++++++++++++++++ common/app/routes/Jobs/components/Jobs.jsx | 254 +++++++++------- common/app/routes/Jobs/components/List.jsx | 89 +++--- .../Jobs/components/NewJobCompleted.jsx | 6 +- common/app/routes/Jobs/components/Preview.jsx | 123 ++++---- common/app/routes/Jobs/components/Show.jsx | 174 ++++++----- common/app/routes/Jobs/redux/actions.js | 0 .../app/routes/Jobs/redux/fetch-jobs-saga.js | 7 + .../app/routes/Jobs/{flux => redux}/index.js | 0 .../{flux/Actions.js => redux/oldActions.js} | 0 common/app/routes/Jobs/redux/reducer.js | 0 common/app/routes/Jobs/redux/types.js | 18 ++ 15 files changed, 661 insertions(+), 585 deletions(-) delete mode 100644 common/app/routes/Jobs/components/GoToPayPal.jsx create mode 100644 common/app/routes/Jobs/components/JobTotal.jsx create mode 100644 common/app/routes/Jobs/redux/actions.js create mode 100644 common/app/routes/Jobs/redux/fetch-jobs-saga.js rename common/app/routes/Jobs/{flux => redux}/index.js (100%) rename common/app/routes/Jobs/{flux/Actions.js => redux/oldActions.js} (100%) create mode 100644 common/app/routes/Jobs/redux/reducer.js create mode 100644 common/app/routes/Jobs/redux/types.js diff --git a/common/app/create-reducer.js b/common/app/create-reducer.js index ee7478011e..82d51d9546 100644 --- a/common/app/create-reducer.js +++ b/common/app/create-reducer.js @@ -1,4 +1,5 @@ import { combineReducers } from 'redux'; +import { reducer as formReducer } from 'redux-form'; import { reducer as app } from './redux'; import { reducer as hikesApp } from './routes/Hikes/redux'; @@ -7,6 +8,7 @@ export default function createReducer(sideReducers = {}) { return combineReducers({ ...sideReducers, app, - hikesApp + hikesApp, + form: formReducer }); } diff --git a/common/app/routes/Jobs/components/GoToPayPal.jsx b/common/app/routes/Jobs/components/GoToPayPal.jsx deleted file mode 100644 index 38f3a15c5b..0000000000 --- a/common/app/routes/Jobs/components/GoToPayPal.jsx +++ /dev/null @@ -1,277 +0,0 @@ -import React, { PropTypes } from 'react'; -import { Button, Input, Col, Row, Well } from 'react-bootstrap'; -import { contain } from 'thundercats-react'; - -// real paypal buttons -// will take your money -const paypalIds = { - regular: 'Q8Z82ZLAX3Q8N', - highlighted: 'VC8QPSKCYMZLN' -}; - -export default contain( - { - store: 'appStore', - actions: [ - 'jobActions', - 'appActions' - ], - map({ jobsApp: { - currentJob: { id, isHighlighted } = {}, - buttonId = isHighlighted ? - paypalIds.highlighted : - paypalIds.regular, - price = 1000, - discountAmount = 0, - promoCode = '', - promoApplied = false, - promoName = '' - }}) { - return { - id, - isHighlighted, - buttonId, - price, - discountAmount, - promoName, - promoCode, - promoApplied - }; - } - }, - React.createClass({ - displayName: 'GoToPayPal', - - propTypes: { - appActions: PropTypes.object, - id: PropTypes.string, - isHighlighted: PropTypes.bool, - buttonId: PropTypes.string, - price: PropTypes.number, - discountAmount: PropTypes.number, - promoName: PropTypes.string, - promoCode: PropTypes.string, - promoApplied: PropTypes.bool, - jobActions: PropTypes.object - }, - - componentDidMount() { - const { jobActions } = this.props; - jobActions.clearPromo(); - }, - - goToJobBoard() { - const { appActions } = this.props; - setTimeout(() => appActions.goTo('/jobs'), 0); - }, - - renderDiscount(discountAmount) { - if (!discountAmount) { - return null; - } - return ( - - -

Promo Discount

- - -

-{ discountAmount }

- -
- ); - }, - - renderHighlightPrice(isHighlighted) { - if (!isHighlighted) { - return null; - } - return ( - - -

Highlighting

- - -

+ 250

- -
- ); - }, - - renderPromo() { - const { - id, - promoApplied, - promoCode, - promoName, - isHighlighted, - jobActions - } = this.props; - if (promoApplied) { - return ( -
-
- - - { promoName } applied - - -
- ); - } - return ( -
-
- - - Have a promo code? - - - - - - - - - - -
- ); - }, - - render() { - const { - id, - isHighlighted, - buttonId, - price, - discountAmount - } = this.props; - - return ( -
- - -
- - -

- One more step -

-
- You're Awesome! just one more step to go. - Clicking on the link below will redirect to paypal. - - -
- - - -

Job Posting

- - -

+ { price }

- -
- { this.renderHighlightPrice(isHighlighted) } - { this.renderDiscount(discountAmount) } - - -

Total

- - -

${ - price - discountAmount + (isHighlighted ? 250 : 0) - }

- -
-
- { this.renderPromo() } -
- - -
- - - - -
- An array of credit cards - - - -
-
- - -
- ); - } - }) -); diff --git a/common/app/routes/Jobs/components/JobNotFound.jsx b/common/app/routes/Jobs/components/JobNotFound.jsx index 343e068f56..e05a886694 100644 --- a/common/app/routes/Jobs/components/JobNotFound.jsx +++ b/common/app/routes/Jobs/components/JobNotFound.jsx @@ -2,8 +2,12 @@ import React from 'react'; import { LinkContainer } from 'react-router-bootstrap'; import { Button, Row, Col } from 'react-bootstrap'; -export default React.createClass({ - displayName: 'NoJobFound', +export default class extends React.Component { + static displayName = 'NoJobFound'; + + shouldComponentUpdate() { + return false; + } render() { return ( @@ -28,4 +32,4 @@ export default React.createClass({
); } -}); +} diff --git a/common/app/routes/Jobs/components/JobTotal.jsx b/common/app/routes/Jobs/components/JobTotal.jsx new file mode 100644 index 0000000000..266d3d9812 --- /dev/null +++ b/common/app/routes/Jobs/components/JobTotal.jsx @@ -0,0 +1,284 @@ +import React, { PropTypes } from 'react'; +import { Button, Input, Col, Row, Well } from 'react-bootstrap'; +import { connect } from 'react-redux'; +import PureComponent from 'react-pure-render/component'; +import { createSelector } from 'reselect'; + +// real paypal buttons +// will take your money +const paypalIds = { + regular: 'Q8Z82ZLAX3Q8N', + highlighted: 'VC8QPSKCYMZLN' +}; + +const bindableActions = { +}; + +const mapStateToProps = createSelector( + state => state.jobsApp.currentJob, + state => state.jobsApp, + ( + { id, isHighlighted } = {}, + { + buttonId, + price = 1000, + discountAmount = 0, + promoCode = '', + promoApplied = false, + promoName = '' + } + ) => { + if (!buttonId) { + buttonId = isHighlighted ? + paypalIds.highlighted : + paypalIds.regular; + } + return { + id, + isHighlighted, + price, + discountAmount, + promoName, + promoCode, + promoApplied + }; + } +); + +export class JobTotal extends PureComponent { + static displayName = 'JobTotal'; + + static propTypes = { + id: PropTypes.string, + isHighlighted: PropTypes.bool, + buttonId: PropTypes.string, + price: PropTypes.number, + discountAmount: PropTypes.number, + promoName: PropTypes.string, + promoCode: PropTypes.string, + promoApplied: PropTypes.bool + }; + + componentDidMount() { + const { jobActions } = this.props; + jobActions.clearPromo(); + } + + goToJobBoard() { + const { appActions } = this.props; + setTimeout(() => appActions.goTo('/jobs'), 0); + } + + renderDiscount(discountAmount) { + if (!discountAmount) { + return null; + } + return ( + + +

Promo Discount

+ + +

-{ discountAmount }

+ +
+ ); + } + + renderHighlightPrice(isHighlighted) { + if (!isHighlighted) { + return null; + } + return ( + + +

Highlighting

+ + +

+ 250

+ +
+ ); + } + + renderPromo() { + const { + id, + promoApplied, + promoCode, + promoName, + isHighlighted, + jobActions + } = this.props; + + if (promoApplied) { + return ( +
+
+ + + { promoName } applied + + +
+ ); + } + + return ( +
+
+ + + Have a promo code? + + + + + + + + + + +
+ ); + } + + render() { + const { + id, + isHighlighted, + buttonId, + price, + discountAmount + } = this.props; + + return ( +
+ + +
+ + +

+ One more step +

+
+ You're Awesome! just one more step to go. + Clicking on the link below will redirect to paypal. + + +
+ + + +

Job Posting

+ + +

+ { price }

+ +
+ { this.renderHighlightPrice(isHighlighted) } + { this.renderDiscount(discountAmount) } + + +

Total

+ + +

${ + price - discountAmount + (isHighlighted ? 250 : 0) + }

+ +
+
+ { this.renderPromo() } +
+ + +
+ + + + +
+ An array of credit cards + + + +
+
+ + +
+ ); + } +} + +export default connect(mapStateToProps, bindableActions)(JobTotal); diff --git a/common/app/routes/Jobs/components/Jobs.jsx b/common/app/routes/Jobs/components/Jobs.jsx index 9704d26931..a315227d70 100644 --- a/common/app/routes/Jobs/components/Jobs.jsx +++ b/common/app/routes/Jobs/components/Jobs.jsx @@ -1,132 +1,156 @@ import React, { cloneElement, PropTypes } from 'react'; -import { contain } from 'thundercats-react'; +import { connect, compose } from 'redux'; +import { createSelector } from 'reselect'; +import { push } from 'react-router-redux'; + +import PureComponent from 'react-pure-render/component'; import { Button, Row, Col } from 'react-bootstrap'; +import contain from '../../../utils/professor-x'; import ListJobs from './List.jsx'; -export default contain( - { - store: 'appStore', - map({ jobsApp: { jobs, showModal }}) { - return { jobs, showModal }; - }, - fetchAction: 'jobActions.getJobs', - isPrimed({ jobs = [] }) { - return !!jobs.length; - }, - actions: [ - 'appActions', - 'jobActions' - ] - }, - React.createClass({ - displayName: 'Jobs', +import { + findJob, + fetchJobs +} from '../redux/actions'; - propTypes: { - children: PropTypes.element, - appActions: PropTypes.object, - jobActions: PropTypes.object, - jobs: PropTypes.array, - showModal: PropTypes.bool - }, +const mapSateToProps = createSelector( + state => state.jobsApp.jobs.entities, + state => state.jobsApp.jobs.results, + state => state.jobsApp, + (jobsMap, jobsById) => { + return jobsById.map(id => jobsMap[id]); + } +); - handleJobClick(id) { - const { appActions, jobActions } = this.props; - if (!id) { - return null; - } - jobActions.findJob(id); - appActions.goTo(`/jobs/${id}`); - }, +const bindableActions = { + findJob, + fetchJobs, + push +}; - renderList(handleJobClick, jobs) { - return ( - - ); - }, +const fetchOptions = { + fetchAction: 'fetchJobs', + isPrimed({ jobs }) { + return !!jobs.results.length; + } +}; - renderChild(child, jobs) { - if (!child) { - return null; - } - return cloneElement( - child, - { jobs } - ); - }, +export class Jobs extends PureComponent { + static displayName = 'Jobs'; - render() { - const { - children, - jobs, - appActions - } = this.props; + static propTypes = { + push: PropTypes.func, + findJob: PropTypes.func, + fetchJobs: PropTypes.func, + children: PropTypes.element, + jobs: PropTypes.array, + showModal: PropTypes.bool + }; - return ( + createJobClickHandler(id) { + const { findJob, push } = this.props; + if (!id) { + return null; + } + + return id => { + findJob(id); + push(`/jobs/${id}`); + }; + } + + renderList(handleJobClick, jobs) { + return ( + + ); + } + + renderChild(child, jobs) { + if (!child) { + return null; + } + return cloneElement( + child, + { jobs } + ); + } + + render() { + const { + children, + jobs + } = this.props; + + return ( + + +

+ Hire a JavaScript engineer who's experienced in HTML5, + Node.js, MongoDB, and Agile Development. +

+
+ + + +
+ + +
-

- Hire a JavaScript engineer who's experienced in HTML5, - Node.js, MongoDB, and Agile Development. -

-
- - - -
- - -
- - - {` - - -
-

- We hired our last developer out of Free Code Camp - and couldn't be happier. Free Code Camp is now - our go-to way to bring on pre-screened candidates - who are enthusiastic about learning quickly and - becoming immediately productive in their new career. -

-
- Michael Gai, CEO at CoNarrative -
-
- -
- + md={ 2 } + xs={ 4 }> + {` + + +
+

+ We hired our last developer out of Free Code Camp + and couldn't be happier. Free Code Camp is now + our go-to way to bring on pre-screened candidates + who are enthusiastic about learning quickly and + becoming immediately productive in their new career. +

+
+ Michael Gai, CEO at CoNarrative +
+
+ +
+ { this.renderChild(children, jobs) || - this.renderList(this.handleJobClick, jobs) } + this.renderList(this.createJobClickHandler(), jobs) } - ); - } - }) -); + ); + } +} + +export default compose( + connect(mapSateToProps, bindableActions), + contain(fetchOptions) +)(Jobs); diff --git a/common/app/routes/Jobs/components/List.jsx b/common/app/routes/Jobs/components/List.jsx index 4edc4cd5ad..940937436f 100644 --- a/common/app/routes/Jobs/components/List.jsx +++ b/common/app/routes/Jobs/components/List.jsx @@ -1,14 +1,15 @@ import React, { PropTypes } from 'react'; import classnames from 'classnames'; import { ListGroup, ListGroupItem } from 'react-bootstrap'; +import PureComponent from 'react-pure-render/component'; -export default React.createClass({ - displayName: 'ListJobs', +export default class ListJobs extends PureComponent { + static displayName = 'ListJobs'; - propTypes: { + static propTypes = { handleClick: PropTypes.func, jobs: PropTypes.array - }, + }; addLocation(locale) { if (!locale) { @@ -19,50 +20,50 @@ export default React.createClass({ { locale } ); - }, + } renderJobs(handleClick, jobs = []) { return jobs - .filter(({ isPaid, isApproved, isFilled }) => { - return isPaid && isApproved && !isFilled; - }) - .map(({ - id, - company, - position, - isHighlighted, - locale - }) => { + .filter(({ isPaid, isApproved, isFilled }) => { + return isPaid && isApproved && !isFilled; + }) + .map(({ + id, + company, + position, + isHighlighted, + locale + }) => { - const className = classnames({ - 'jobs-list': true, - 'col-xs-12': true, - 'jobs-list-highlight': isHighlighted - }); - - return ( - handleClick(id) }> -
-

- { company } - {' '} - - - { position } - -

-

- { this.addLocation(locale) } -

-
-
- ); + const className = classnames({ + 'jobs-list': true, + 'col-xs-12': true, + 'jobs-list-highlight': isHighlighted }); - }, + + return ( + handleClick(id) }> +
+

+ { company } + {' '} + + - { position } + +

+

+ { this.addLocation(locale) } +

+
+
+ ); + }); + } render() { const { @@ -76,4 +77,4 @@ export default React.createClass({ ); } -}); +} diff --git a/common/app/routes/Jobs/components/NewJobCompleted.jsx b/common/app/routes/Jobs/components/NewJobCompleted.jsx index 4f11a2371d..8767960e38 100644 --- a/common/app/routes/Jobs/components/NewJobCompleted.jsx +++ b/common/app/routes/Jobs/components/NewJobCompleted.jsx @@ -2,8 +2,8 @@ import React from 'react'; import { LinkContainer } from 'react-router-bootstrap'; import { Button, Col, Row } from 'react-bootstrap'; -export default React.createClass({ - displayName: 'NewJobCompleted', +export default class extends React.createClass { + static displayName = 'NewJobCompleted'; render() { return ( @@ -36,4 +36,4 @@ export default React.createClass({
); } -}); +} diff --git a/common/app/routes/Jobs/components/Preview.jsx b/common/app/routes/Jobs/components/Preview.jsx index 804b34b6d8..a50eca2794 100644 --- a/common/app/routes/Jobs/components/Preview.jsx +++ b/common/app/routes/Jobs/components/Preview.jsx @@ -1,79 +1,84 @@ import React, { PropTypes } from 'react'; import { Button, Row, Col } from 'react-bootstrap'; -import { contain } from 'thundercats-react'; +import { connect } from 'redux'; +import { createSelector } from 'reselect'; +import PureComponent from 'react-pure-render/component'; +import { goBack, push } from 'react-router-redux'; import ShowJob from './ShowJob.jsx'; import JobNotFound from './JobNotFound.jsx'; -export default contain( - { - store: 'appStore', - actions: [ - 'appActions', - 'jobActions' - ], - map({ jobsApp: { form: job = {} } }) { - return { job }; +import { clearSavedForm, saveJobToDb } from '../redux/actions'; + +const mapStateToProps = createSelector( + state => state.jobsApp.form, + (job = {}) => ({ job }) +); + +const bindableActions = { + goBack, + push, + clearSavedForm, + saveJobToDb +}; + +export class JobPreview extends PureComponent { + static displayName = 'Preview'; + + static propTypes = { + job: PropTypes.object + }; + + componentDidMount() { + const { push, job } = this.props; + // redirect user in client + if (!job || !job.position || !job.description) { + push('/jobs/new'); } - }, - React.createClass({ - displayName: 'Preview', + } - propTypes: { - appActions: PropTypes.object, - job: PropTypes.object, - jobActions: PropTypes.object - }, + render() { + const { job, goBack, clearSavedForm, saveJobToDb } = this.props; - componentDidMount() { - const { appActions, job } = this.props; - // redirect user in client - if (!job || !job.position || !job.description) { - appActions.goTo('/jobs/new'); - } - }, + if (!job || !job.position || !job.description) { + return ; + } - render() { - const { appActions, job, jobActions } = this.props; + return ( +
+ +
+
+ + +
+
- ); - } - }) -); + ); + } +} + +export default connect(mapStateToProps, bindableActions)(JobPreview); diff --git a/common/app/routes/Jobs/components/Show.jsx b/common/app/routes/Jobs/components/Show.jsx index 5ccee6e131..84ca630b92 100644 --- a/common/app/routes/Jobs/components/Show.jsx +++ b/common/app/routes/Jobs/components/Show.jsx @@ -1,6 +1,11 @@ import React, { PropTypes } from 'react'; -import { History } from 'react-router'; -import { contain } from 'thundercats-react'; +import { connect, compose } from 'redux'; +import { push } from 'react-router-redux'; +import PureComponent from 'react-pure-render/component'; +import { createSelector } from 'reselect'; + +import contain from '../../../utils/professor-x'; +import { fetchJob } from '../redux/actions'; import ShowJob from './ShowJob.jsx'; import JobNotFound from './JobNotFound.jsx'; @@ -51,86 +56,89 @@ function generateMessage( "You've earned it, so feel free to apply."; } -export default contain( - { - store: 'appStore', - fetchAction: 'jobActions.getJob', - map({ - username, - isFrontEndCert, - isBackEndCert, - jobsApp: { currentJob } - }) { - return { - username, - job: currentJob, - isFrontEndCert, - isBackEndCert - }; - }, - getPayload({ params: { id } }) { - return id; - }, - isPrimed({ params: { id } = {}, job = {} }) { - return job.id === id; - }, - // using es6 destructuring - shouldContainerFetch({ job = {} }, { params: { id } } - ) { - return job.id !== id; - } - }, - React.createClass({ - displayName: 'Show', - - propTypes: { - job: PropTypes.object, - isBackEndCert: PropTypes.bool, - isFrontEndCert: PropTypes.bool, - username: PropTypes.string - }, - - mixins: [History], - - componentDidMount() { - const { job } = this.props; - // redirect user in client - if (!isJobValid(job)) { - this.history.pushState(null, '/jobs'); - } - }, - - render() { - const { - isBackEndCert, - isFrontEndCert, - job, - username - } = this.props; - - if (!isJobValid(job)) { - return ; - } - - const isSignedIn = !!username; - - const showApply = shouldShowApply( - job, - { isFrontEndCert, isBackEndCert } - ); - - const message = generateMessage( - job, - { isFrontEndCert, isBackEndCert, isSignedIn } - ); - - return ( - - ); - } +const mapStateToProps = createSelector( + state => state.app, + state => state.jobsApp.currentJob, + ({ username, isFrontEndCert, isBackEndCert }, job = {}) => ({ + username, + isFrontEndCert, + isBackEndCert, + job }) ); + +const bindableActions = { + push, + fetchJob +}; + +const fetchOptions = { + fetchAction: 'fetchJob', + getActionArgs({ params: { id } }) { + return [ id ]; + }, + isPrimed({ params: { id } = {}, job = {} }) { + return job.id === id; + }, + // using es6 destructuring + shouldRefetch({ job }, { params: { id } }) { + return job.id !== id; + } +}; + +export class Show extends PureComponent { + static displayName = 'Show'; + + static propTypes = { + job: PropTypes.object, + isBackEndCert: PropTypes.bool, + isFrontEndCert: PropTypes.bool, + username: PropTypes.string + }; + + componentDidMount() { + const { job, push } = this.props; + // redirect user in client + if (!isJobValid(job)) { + push('/jobs'); + } + } + + render() { + const { + isBackEndCert, + isFrontEndCert, + job, + username + } = this.props; + + if (!isJobValid(job)) { + return ; + } + + const isSignedIn = !!username; + + const showApply = shouldShowApply( + job, + { isFrontEndCert, isBackEndCert } + ); + + const message = generateMessage( + job, + { isFrontEndCert, isBackEndCert, isSignedIn } + ); + + return ( + + ); + } +} + +export default compose( + connect(mapStateToProps, bindableActions), + contain(fetchOptions) +)(Show); diff --git a/common/app/routes/Jobs/redux/actions.js b/common/app/routes/Jobs/redux/actions.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/app/routes/Jobs/redux/fetch-jobs-saga.js b/common/app/routes/Jobs/redux/fetch-jobs-saga.js new file mode 100644 index 0000000000..2712d2e47d --- /dev/null +++ b/common/app/routes/Jobs/redux/fetch-jobs-saga.js @@ -0,0 +1,7 @@ +import { fetchJobs, fetchJobsCompleted } from './types'; + +export default ({ services }) => ({ dispatch, getState }) => next => { + return function fetchJobsSaga(action) { + return next(action); + }; +}; diff --git a/common/app/routes/Jobs/flux/index.js b/common/app/routes/Jobs/redux/index.js similarity index 100% rename from common/app/routes/Jobs/flux/index.js rename to common/app/routes/Jobs/redux/index.js diff --git a/common/app/routes/Jobs/flux/Actions.js b/common/app/routes/Jobs/redux/oldActions.js similarity index 100% rename from common/app/routes/Jobs/flux/Actions.js rename to common/app/routes/Jobs/redux/oldActions.js diff --git a/common/app/routes/Jobs/redux/reducer.js b/common/app/routes/Jobs/redux/reducer.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/app/routes/Jobs/redux/types.js b/common/app/routes/Jobs/redux/types.js new file mode 100644 index 0000000000..3e3ccc2b6c --- /dev/null +++ b/common/app/routes/Jobs/redux/types.js @@ -0,0 +1,18 @@ +const types = [ + 'fetchJobs', + 'fetchJobsCompleted', + + 'findJob', + + 'saveJob', + 'getJob', + 'getJobs', + 'openModal', + 'closeModal', + 'handleFormUpdate', + 'saveForm', + 'clear' +]; + +export default types + .reduce((types, type) => ({ ...types, [type]: `jobs.${type}` }), {}); From 056d749ddd27541503cbeba28e7ddb8eb781c471 Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Thu, 25 Feb 2016 18:30:10 -0800 Subject: [PATCH 11/27] Wrap up flux actions --- client/sagas/local-storage-saga.js | 67 ++ common/app/routes/Hikes/redux/types.js | 6 +- common/app/routes/Jobs/components/Jobs.jsx | 3 +- common/app/routes/Jobs/components/NewJob.jsx | 715 +++++++++--------- common/app/routes/Jobs/components/Preview.jsx | 7 +- common/app/routes/Jobs/components/Show.jsx | 3 +- common/app/routes/Jobs/index.js | 4 +- common/app/routes/Jobs/redux/actions.js | 20 + .../app/routes/Jobs/redux/apply-promo-saga.js | 39 + .../app/routes/Jobs/redux/fetch-jobs-saga.js | 49 +- common/app/routes/Jobs/redux/index.js | 9 +- common/app/routes/Jobs/redux/reducer.js | 79 ++ common/app/routes/Jobs/redux/save-job-saga.js | 26 + common/app/routes/Jobs/redux/types.js | 17 +- common/app/sagas.js | 5 +- 15 files changed, 655 insertions(+), 394 deletions(-) create mode 100644 client/sagas/local-storage-saga.js create mode 100644 common/app/routes/Jobs/redux/apply-promo-saga.js create mode 100644 common/app/routes/Jobs/redux/save-job-saga.js diff --git a/client/sagas/local-storage-saga.js b/client/sagas/local-storage-saga.js new file mode 100644 index 0000000000..7913dcf42f --- /dev/null +++ b/client/sagas/local-storage-saga.js @@ -0,0 +1,67 @@ +import { + saveForm, + clearForm, + loadSavedForm +} from '../common/app/routes/Jobs/redux/types'; + +import { + loadSavedFormCompleted +} from '../common/app/routes/Jobs/redux/actions'; + +const formKey = 'newJob'; +let enabled = false; +let store = typeof window !== 'undefined' ? + window.localStorage : + false; + +try { + const testKey = '__testKey__'; + store.setItem(testKey, testKey); + enabled = store.getItem(testKey) !== testKey; + store.removeItem(testKey); +} catch (e) { + enabled = !e; +} + +if (!enabled) { + console.error(new Error('No localStorage found')); +} + +export default () => ({ dispatch }) => next => { + return function localStorageSaga(action) { + if (!enabled) { return next(action); } + + if (action.type === saveForm) { + const form = action.payload; + try { + store.setItem(formKey, JSON.stringify(form)); + return null; + } catch (e) { + return dispatch({ + type: 'app.handleError', + error: new Error('could not parse form data') + }); + } + } + + if (action.type === clearForm) { + store.removeItem(formKey); + return null; + } + + if (action.type === loadSavedForm) { + const formString = store.getItem(formKey); + try { + const form = JSON.parse(formString); + return dispatch(loadSavedFormCompleted(form)); + } catch (err) { + return dispatch({ + type: 'app.handleError', + error: new Error('could not parse form data') + }); + } + } + + return next(action); + }; +}; diff --git a/common/app/routes/Hikes/redux/types.js b/common/app/routes/Hikes/redux/types.js index 2e51441926..da3a622adc 100644 --- a/common/app/routes/Hikes/redux/types.js +++ b/common/app/routes/Hikes/redux/types.js @@ -21,5 +21,7 @@ const types = [ 'goToNextHike' ]; -export default types - .reduce((types, type) => ({ ...types, [type]: `videos.${type}` }), {}); +export default types.reduce((types, type) => { + types[type] = `videos.${type}`; + return types; +}, {}); diff --git a/common/app/routes/Jobs/components/Jobs.jsx b/common/app/routes/Jobs/components/Jobs.jsx index a315227d70..bea7d205b0 100644 --- a/common/app/routes/Jobs/components/Jobs.jsx +++ b/common/app/routes/Jobs/components/Jobs.jsx @@ -1,5 +1,6 @@ import React, { cloneElement, PropTypes } from 'react'; -import { connect, compose } from 'redux'; +import { compose } from 'redux'; +import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import { push } from 'react-router-redux'; diff --git a/common/app/routes/Jobs/components/NewJob.jsx b/common/app/routes/Jobs/components/NewJob.jsx index 43f2dfb6ef..1111057def 100644 --- a/common/app/routes/Jobs/components/NewJob.jsx +++ b/common/app/routes/Jobs/components/NewJob.jsx @@ -1,8 +1,8 @@ import { helpers } from 'rx'; import React, { PropTypes } from 'react'; -import { History } from 'react-router'; -import { contain } from 'thundercats-react'; -import debugFactory from 'debug'; +import { reduxForm } from 'redux-form'; +import { connector } from 'react-redux'; +import debug from 'debug'; import dedent from 'dedent'; import normalizeUrl from 'normalize-url'; @@ -26,7 +26,7 @@ import { isURL } from 'validator'; -const debug = debugFactory('fcc:jobs:newForm'); +const log = debug('fcc:jobs:newForm'); const checkValidity = [ 'position', @@ -100,396 +100,369 @@ function makeRequired(validator) { return (val) => !!val && validator(val); } -export default contain({ - store: 'appStore', - actions: 'jobActions', - map({ jobsApp: { form = {} } }) { - const { - position, - locale, - description, - email, - url, - logo, - company, - isFrontEndCert = true, - isBackEndCert, - isHighlighted, - isRemoteOk, - howToApply - } = form; - return { - position: formatValue(position, makeRequired(isAscii)), - locale: formatValue(locale, makeRequired(isAscii)), - description: formatValue(description, makeRequired(helpers.identity)), - email: formatValue(email, makeRequired(isEmail)), - url: formatValue(formatUrl(url), isValidURL), - logo: formatValue(formatUrl(logo), isValidURL), - company: formatValue(company, makeRequired(isAscii)), - isHighlighted: formatValue(isHighlighted, null, 'bool'), - isRemoteOk: formatValue(isRemoteOk, null, 'bool'), - howToApply: formatValue(howToApply, makeRequired(isAscii)), - isFrontEndCert, - isBackEndCert - }; - }, - subscribeOnWillMount() { - return typeof window !== 'undefined'; - } - }, - React.createClass({ - displayName: 'NewJob', +const formOptions = { + fields: [ + 'position', + 'locale', + 'description', + 'email', + 'url', + 'logo', + 'company', + 'isHighlighted', + 'isRemoteOk', + 'isFrontEndCert', + 'isBackEndCert', + 'howToApply' + ] +} - propTypes: { - jobActions: PropTypes.object, - position: PropTypes.object, - locale: PropTypes.object, - description: PropTypes.object, - email: PropTypes.object, - url: PropTypes.object, - logo: PropTypes.object, - company: PropTypes.object, - isHighlighted: PropTypes.object, - isRemoteOk: PropTypes.object, - isFrontEndCert: PropTypes.bool, - isBackEndCert: PropTypes.bool, - howToApply: PropTypes.object - }, +export class NewJob extends React.Component { + static displayName = 'NewJob'; - mixins: [History], + static propTypes = { + jobActions: PropTypes.object, + fields: PropTypes.object, + onSubmit: PropTypes.func + }; - handleSubmit(e) { - e.preventDefault(); - const pros = this.props; - let valid = true; - checkValidity.forEach((prop) => { - // if value exist, check if it is valid - if (pros[prop].value && pros[prop].type !== 'boolean') { - valid = valid && !!pros[prop].valid; - } - }); - - if ( - !valid || - !pros.isFrontEndCert && - !pros.isBackEndCert - ) { - debug('form not valid'); - return; + handleSubmit(e) { + e.preventDefault(); + const pros = this.props; + let valid = true; + checkValidity.forEach((prop) => { + // if value exist, check if it is valid + if (pros[prop].value && pros[prop].type !== 'boolean') { + valid = valid && !!pros[prop].valid; } + }); - const { - jobActions, + if ( + !valid || + !pros.isFrontEndCert && + !pros.isBackEndCert + ) { + debug('form not valid'); + return; + } - // form values - position, - locale, - description, - email, - url, - logo, - company, - isFrontEndCert, - isBackEndCert, - isHighlighted, - isRemoteOk, - howToApply - } = this.props; + const { + jobActions, - // sanitize user output - const jobValues = { - position: inHTMLData(position.value), - locale: inHTMLData(locale.value), - description: inHTMLData(description.value), - email: inHTMLData(email.value), - url: formatUrl(uriInSingleQuotedAttr(url.value), false), - logo: formatUrl(uriInSingleQuotedAttr(logo.value), false), - company: inHTMLData(company.value), - isHighlighted: !!isHighlighted.value, - isRemoteOk: !!isRemoteOk.value, - howToApply: inHTMLData(howToApply.value), - isFrontEndCert, - isBackEndCert - }; + // form values + position, + locale, + description, + email, + url, + logo, + company, + isFrontEndCert, + isBackEndCert, + isHighlighted, + isRemoteOk, + howToApply + } = this.props; - const job = Object.keys(jobValues).reduce((accu, prop) => { - if (jobValues[prop]) { - accu[prop] = jobValues[prop]; - } - return accu; - }, {}); + // sanitize user output + const jobValues = { + position: inHTMLData(position.value), + locale: inHTMLData(locale.value), + description: inHTMLData(description.value), + email: inHTMLData(email.value), + url: formatUrl(uriInSingleQuotedAttr(url.value), false), + logo: formatUrl(uriInSingleQuotedAttr(logo.value), false), + company: inHTMLData(company.value), + isHighlighted: !!isHighlighted.value, + isRemoteOk: !!isRemoteOk.value, + howToApply: inHTMLData(howToApply.value), + isFrontEndCert, + isBackEndCert + }; - job.postedOn = new Date(); - debug('job sanitized', job); - jobActions.saveForm(job); + const job = Object.keys(jobValues).reduce((accu, prop) => { + if (jobValues[prop]) { + accu[prop] = jobValues[prop]; + } + return accu; + }, {}); - this.history.pushState(null, '/jobs/new/preview'); - }, + job.postedOn = new Date(); + debug('job sanitized', job); + jobActions.saveForm(job); - componentDidMount() { - const { jobActions } = this.props; - jobActions.getSavedForm(); - }, + this.history.pushState(null, '/jobs/new/preview'); + }, - handleChange(name, { target: { value } }) { - const { jobActions: { handleForm } } = this.props; - handleForm({ [name]: value }); - }, + componentDidMount() { + const { jobActions } = this.props; + jobActions.getSavedForm(); + }, - handleCertClick(name) { - const { jobActions: { handleForm } } = this.props; - const otherButton = name === 'isFrontEndCert' ? - 'isBackEndCert' : - 'isFrontEndCert'; + handleChange(name, { target: { value } }) { + const { jobActions: { handleForm } } = this.props; + handleForm({ [name]: value }); + }, - handleForm({ - [name]: true, - [otherButton]: false - }); - }, + handleCertClick(name) { + const { jobActions: { handleForm } } = this.props; + const otherButton = name === 'isFrontEndCert' ? + 'isBackEndCert' : + 'isFrontEndCert'; - render() { - const { - position, - locale, - description, - email, - url, - logo, - company, - isHighlighted, - isRemoteOk, - howToApply, - isFrontEndCert, - isBackEndCert, - jobActions: { handleForm } - } = this.props; + handleForm({ + [name]: true, + [otherButton]: false + }); + }, - const { handleChange } = this; - const labelClass = 'col-sm-offset-1 col-sm-2'; - const inputClass = 'col-sm-6'; + render() { + const { + position, + locale, + description, + email, + url, + logo, + company, + isHighlighted, + isRemoteOk, + howToApply, + isFrontEndCert, + isBackEndCert, + jobActions: { handleForm } + } = this.props; - return ( -
- - -
-
+ const { handleChange } = this; + const labelClass = 'col-sm-offset-1 col-sm-2'; + const inputClass = 'col-sm-6'; -
-

First, select your ideal applicant:

+ return ( +
+ + +
+ + +
+

First, select your ideal applicant:

+
+ + + + + + +
+ + + + + +
+

Tell us about the position

+
+
+ handleChange('position', e) } + placeholder={ + 'e.g. Full Stack Developer, Front End Developer, etc.' + } + required={ true } + type='text' + value={ position.value } + wrapperClassName={ inputClass } /> + handleChange('locale', e) } + placeholder='e.g. San Francisco, Remote, etc.' + required={ true } + type='text' + value={ locale.value } + wrapperClassName={ inputClass } /> + handleChange('description', e) } + required={ true } + rows='10' + type='textarea' + value={ description.value } + wrapperClassName={ inputClass } /> + handleForm({ + isRemoteOk: !!checked + }) + } + type='checkbox' + wrapperClassName={ checkboxClass } /> +
+ +
+ +
+

How should they apply?

+ handleChange('howToApply', e) } + placeholder={ howToApplyCopy } + required={ true } + rows='2' + type='textarea' + value={ howToApply.value } + wrapperClassName={ inputClass } /> +
+
+
+
+

Tell us about your organization

+
+ handleChange('company', e) } + type='text' + value={ company.value } + wrapperClassName={ inputClass } /> + handleChange('email', e) } + placeholder='This is how we will contact you' + required={ true } + type='email' + value={ email.value } + wrapperClassName={ inputClass } /> + handleChange('url', e) } + placeholder='http://yourcompany.com' + type='url' + value={ url.value } + wrapperClassName={ inputClass } /> + handleChange('logo', e) } + placeholder='http://yourcompany.com/logo.png' + type='url' + value={ logo.value } + wrapperClassName={ inputClass } /> + +
+
+
+
+

Make it stand out

+
+
- - - -
- - - - - -
-

Tell us about the position

-
-
- handleChange('position', e) } - placeholder={ - 'e.g. Full Stack Developer, Front End Developer, etc.' - } - required={ true } - type='text' - value={ position.value } - wrapperClassName={ inputClass } /> - handleChange('locale', e) } - placeholder='e.g. San Francisco, Remote, etc.' - required={ true } - type='text' - value={ locale.value } - wrapperClassName={ inputClass } /> - handleChange('description', e) } - required={ true } - rows='10' - type='textarea' - value={ description.value } - wrapperClassName={ inputClass } /> - handleForm({ - isRemoteOk: !!checked - }) - } - type='checkbox' - wrapperClassName={ checkboxClass } /> -
- -
- -
-

How should they apply?

-
- handleChange('howToApply', e) } - placeholder={ howToApplyCopy } - required={ true } - rows='2' - type='textarea' - value={ howToApply.value } - wrapperClassName={ inputClass } /> -
- -
-
-
-

Tell us about your organization

-
- handleChange('company', e) } - type='text' - value={ company.value } - wrapperClassName={ inputClass } /> - handleChange('email', e) } - placeholder='This is how we will contact you' - required={ true } - type='email' - value={ email.value } - wrapperClassName={ inputClass } /> - handleChange('url', e) } - placeholder='http://yourcompany.com' - type='url' - value={ url.value } - wrapperClassName={ inputClass } /> - handleChange('logo', e) } - placeholder='http://yourcompany.com/logo.png' - type='url' - value={ logo.value } - wrapperClassName={ inputClass } /> - -
-
-
-
-

Make it stand out

-
-
- - + md={ 6 } + mdOffset={ 3 }> Highlight this ad to give it extra attention.
- Featured listings receive more clicks and more applications. - -
-
- - handleForm({ - isHighlighted: !!checked - }) - } - type='checkbox' - wrapperClassName={ - checkboxClass.replace('text-left', '') - } /> - -
- - - - + Featured listings receive more clicks and more applications. - -
- - -
- ); - } - }) -); +
+ + handleForm({ + isHighlighted: !!checked + }) + } + type='checkbox' + wrapperClassName={ + checkboxClass.replace('text-left', '') + } /> + +
+ + + + + + + +
+ + +
+ ); + } +} + +export default reduxForm( + formOptions, + mapStateToProps, + bindableActions +)(NewJob); diff --git a/common/app/routes/Jobs/components/Preview.jsx b/common/app/routes/Jobs/components/Preview.jsx index a50eca2794..cfdec61ddb 100644 --- a/common/app/routes/Jobs/components/Preview.jsx +++ b/common/app/routes/Jobs/components/Preview.jsx @@ -1,6 +1,6 @@ import React, { PropTypes } from 'react'; import { Button, Row, Col } from 'react-bootstrap'; -import { connect } from 'redux'; +import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import PureComponent from 'react-pure-render/component'; import { goBack, push } from 'react-router-redux'; @@ -11,8 +11,9 @@ import JobNotFound from './JobNotFound.jsx'; import { clearSavedForm, saveJobToDb } from '../redux/actions'; const mapStateToProps = createSelector( - state => state.jobsApp.form, - (job = {}) => ({ job }) + state => state.jobsApp.previewJob, + state => state.jobsApp.jobs.entities + (job, jobsMap) => ({ job: jobsMap[job] || {} }) ); const bindableActions = { diff --git a/common/app/routes/Jobs/components/Show.jsx b/common/app/routes/Jobs/components/Show.jsx index 84ca630b92..05e9320a45 100644 --- a/common/app/routes/Jobs/components/Show.jsx +++ b/common/app/routes/Jobs/components/Show.jsx @@ -1,5 +1,6 @@ import React, { PropTypes } from 'react'; -import { connect, compose } from 'redux'; +import { compose } from 'redux'; +import { connect } from 'react-redux'; import { push } from 'react-router-redux'; import PureComponent from 'react-pure-render/component'; import { createSelector } from 'reselect'; diff --git a/common/app/routes/Jobs/index.js b/common/app/routes/Jobs/index.js index 9d8ee8c2dc..b388bdc577 100644 --- a/common/app/routes/Jobs/index.js +++ b/common/app/routes/Jobs/index.js @@ -2,7 +2,7 @@ import Jobs from './components/Jobs.jsx'; import NewJob from './components/NewJob.jsx'; import Show from './components/Show.jsx'; import Preview from './components/Preview.jsx'; -import GoToPayPal from './components/GoToPayPal.jsx'; +import JobTotal from './components/JobTotal.jsx'; import NewJobCompleted from './components/NewJobCompleted.jsx'; /* @@ -23,7 +23,7 @@ export default { component: Preview }, { path: 'jobs/new/check-out', - component: GoToPayPal + component: JobTotal }, { path: 'jobs/new/completed', component: NewJobCompleted diff --git a/common/app/routes/Jobs/redux/actions.js b/common/app/routes/Jobs/redux/actions.js index e69de29bb2..0212a2a375 100644 --- a/common/app/routes/Jobs/redux/actions.js +++ b/common/app/routes/Jobs/redux/actions.js @@ -0,0 +1,20 @@ +import { createAction } from 'redux-actions'; + +import types from './types'; + +export const fetchJobs = createAction(types.fetchJobs); +export const fetchJobsCompleted = createAction( + types.fetchJobsCompleted, + (currentJob, jobs) => ({ currentJob, jobs }) +); + +export const findJob = createAction(types.findJob); + +export const saveJob = createAction(types.saveJob); +export const saveJobCompleted = createAction(types.saveJobCompleted); + +export const saveForm = createAction(types.saveForm); +export const clearForm = createAction(types.clearSavedForm); +export const loadSavedFormCompleted = createAction( + types.loadSavedFormCompleted +); diff --git a/common/app/routes/Jobs/redux/apply-promo-saga.js b/common/app/routes/Jobs/redux/apply-promo-saga.js new file mode 100644 index 0000000000..e5b182baf5 --- /dev/null +++ b/common/app/routes/Jobs/redux/apply-promo-saga.js @@ -0,0 +1,39 @@ +import { Observable } from 'rx'; + +import { testPromo } from './types'; +import { applyPromo } from './actions'; +import { postJSON$ } from '../../../../utils/ajax-stream'; + +export default () => ({ dispatch }) => next => { + return function applyPromoSaga(action) { + if (action.type !== testPromo) { + return next(action); + } + + const { id, code = '', type = null } = action.payload; + + const body = { + id, + code: code.replace(/[^\d\w\s]/, '') + }; + + if (type) { + body.type = type; + } + + return postJSON$('/api/promos/getButton', body) + .retry(3) + .map(({ promo }) => { + if (!promo || !promo.buttonId) { + throw new Error('No promo returned by server'); + } + + return applyPromo(promo); + }) + .catch(error => Observable.just({ + type: 'app.handleError', + error + })) + .doOnNext(dispatch); + }; +}; diff --git a/common/app/routes/Jobs/redux/fetch-jobs-saga.js b/common/app/routes/Jobs/redux/fetch-jobs-saga.js index 2712d2e47d..e7fec1a429 100644 --- a/common/app/routes/Jobs/redux/fetch-jobs-saga.js +++ b/common/app/routes/Jobs/redux/fetch-jobs-saga.js @@ -1,7 +1,50 @@ -import { fetchJobs, fetchJobsCompleted } from './types'; +import { Observable } from 'rx'; +import { normalize, Schema, arrayOf } from 'normalizr'; -export default ({ services }) => ({ dispatch, getState }) => next => { +import { fetchJobsCompleted } from './actions'; +import { fetchJobs } from './types'; +import { handleError } from '../../../redux/types'; + +const job = new Schema('job', { idAttribute: 'id' }); + +export default ({ services }) => ({ dispatch }) => next => { return function fetchJobsSaga(action) { - return next(action); + if (action.type !== fetchJobs) { + return next(action); + } + + const { payload: id } = action; + const data = { service: 'jobs' }; + if (id) { + data.id = id; + } + + return services.readService$(data) + .map(jobs => { + if (!Array.isArray(jobs)) { + jobs = [jobs]; + } + + const { entities, result } = normalize( + { jobs }, + { jobs: arrayOf(job) } + ); + + + return fetchJobsCompleted( + result.jobs[0], + { + entities: entities.jobs, + results: result.jobs + } + ); + }) + .catch(error => { + return Observable.just({ + type: handleError, + error + }); + }) + .doOnNext(dispatch); }; }; diff --git a/common/app/routes/Jobs/redux/index.js b/common/app/routes/Jobs/redux/index.js index 0936f320ae..b0b05159f1 100644 --- a/common/app/routes/Jobs/redux/index.js +++ b/common/app/routes/Jobs/redux/index.js @@ -1 +1,8 @@ -export default from './Actions'; +export actions from './actions'; +export reducer from './reducer'; +export types from './types'; + +import fetchJobsSaga from './fetch-jobs-saga'; +import saveJobSaga from './save-job-saga'; + +export const sagas = [ fetchJobsSaga, saveJobSaga ]; diff --git a/common/app/routes/Jobs/redux/reducer.js b/common/app/routes/Jobs/redux/reducer.js index e69de29bb2..2fa43101ee 100644 --- a/common/app/routes/Jobs/redux/reducer.js +++ b/common/app/routes/Jobs/redux/reducer.js @@ -0,0 +1,79 @@ +import { handleActions } from 'redux-actions'; + +import types from './types'; + +const replaceMethod = ''.replace; +function replace(str) { + if (!str) { return ''; } + return replaceMethod.call(str, /[^\d\w\s]/, ''); +} + +const initialState = { + currentJob: '', + newJob: {}, + jobs: { + entities: {}, + results: [] + } +}; + +export default handleActions( + { + [types.findJob]: (state, { payload: id }) => { + const currentJob = state.jobs.entities[id]; + return { + ...state, + currentJob: currentJob && currentJob.id ? + currentJob.id : + state.currentJob + }; + }, + [types.saveJobCompleted]: (state, { payload: newJob }) => { + return { + ...state, + newJob + }; + }, + [types.fetchJobCompleted]: (state, { payload: { jobs, currentJob } }) => ({ + ...state, + currentJob, + jobs + }), + [types.updatePromoCode]: (state, { payload }) => ({ + ...state, + promoCode: replace(payload) + }), + [types.applyPromo]: (state, { payload: promo }) => { + + const { + fullPrice: price, + buttonId, + discountAmount, + code: promoCode, + name: promoName + } = promo; + + return { + ...state, + price, + buttonId, + discountAmount, + promoCode, + promoApplied: true, + promoName + }; + }, + [types.clearPromo]: state => ({ + /* eslint-disable no-undefined */ + ...state, + price: undefined, + buttonId: undefined, + discountAmount: undefined, + promoCode: undefined, + promoApplied: false, + promoName: undefined + /* eslint-enable no-undefined */ + }) + }, + initialState +); diff --git a/common/app/routes/Jobs/redux/save-job-saga.js b/common/app/routes/Jobs/redux/save-job-saga.js new file mode 100644 index 0000000000..0faf17d823 --- /dev/null +++ b/common/app/routes/Jobs/redux/save-job-saga.js @@ -0,0 +1,26 @@ +import { Observable } from 'rx'; + +import { saveJobCompleted } from './actions'; +import { saveJob } from './types'; + +import { handleError } from '../../../redux/types'; + +export default ({ services }) => ({ dispatch }) => next => { + return function saveJobSaga(action) { + if (action.type !== saveJob) { + return next(action); + } + const { payload: job } = action; + + return services.createService$({ + service: 'jobs', + params: { job } + }) + .map(job => saveJobCompleted(job)) + .catch(error => Observable.just({ + type: handleError, + error + })) + .doOnNext(dispatch); + }; +}; diff --git a/common/app/routes/Jobs/redux/types.js b/common/app/routes/Jobs/redux/types.js index 3e3ccc2b6c..7417e734b6 100644 --- a/common/app/routes/Jobs/redux/types.js +++ b/common/app/routes/Jobs/redux/types.js @@ -3,16 +3,15 @@ const types = [ 'fetchJobsCompleted', 'findJob', - 'saveJob', - 'getJob', - 'getJobs', - 'openModal', - 'closeModal', - 'handleFormUpdate', + 'saveForm', - 'clear' + 'clearForm', + 'loadSavedForm', + 'loadSavedFormCompleted' ]; -export default types - .reduce((types, type) => ({ ...types, [type]: `jobs.${type}` }), {}); +export default types.reduce((types, type) => { + types[type] = `jobs.${type}`; + return types; +}, {}); diff --git a/common/app/sagas.js b/common/app/sagas.js index fc40bc384c..cfd486242b 100644 --- a/common/app/sagas.js +++ b/common/app/sagas.js @@ -1,6 +1,9 @@ import { sagas as appSagas } from './redux'; import { sagas as hikesSagas} from './routes/Hikes/redux'; +import { sagas as jobsSagas } from './routes/Jobs/redux'; + export default [ ...appSagas, - ...hikesSagas + ...hikesSagas, + ...jobsSagas ]; From 6bff10ea9c7d50f726ad147713935ad8bb02b62c Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Sun, 28 Feb 2016 15:45:38 -0800 Subject: [PATCH 12/27] Jobs page initially renders --- common/app/create-reducer.js | 7 +- common/app/routes/Jobs/components/Jobs.jsx | 6 +- common/app/routes/Jobs/components/NewJob.jsx | 363 +++++++----------- common/app/routes/Jobs/components/Preview.jsx | 7 +- .../routes/Jobs/redux/jobs-form-normalizer.js | 38 ++ common/app/utils/professor-x.js | 2 - 6 files changed, 184 insertions(+), 239 deletions(-) create mode 100644 common/app/routes/Jobs/redux/jobs-form-normalizer.js diff --git a/common/app/create-reducer.js b/common/app/create-reducer.js index 82d51d9546..2e9ac1dba9 100644 --- a/common/app/create-reducer.js +++ b/common/app/create-reducer.js @@ -3,12 +3,17 @@ import { reducer as formReducer } from 'redux-form'; import { reducer as app } from './redux'; import { reducer as hikesApp } from './routes/Hikes/redux'; +import { + reducer as jobsApp, + formNormalizer as jobsNormalizer +} from './routes/Jobs/redux'; export default function createReducer(sideReducers = {}) { return combineReducers({ ...sideReducers, app, hikesApp, - form: formReducer + jobsApp, + form: formReducer.normalize(jobsNormalizer) }); } diff --git a/common/app/routes/Jobs/components/Jobs.jsx b/common/app/routes/Jobs/components/Jobs.jsx index bea7d205b0..36fe14cbe4 100644 --- a/common/app/routes/Jobs/components/Jobs.jsx +++ b/common/app/routes/Jobs/components/Jobs.jsx @@ -15,12 +15,12 @@ import { fetchJobs } from '../redux/actions'; -const mapSateToProps = createSelector( +const mapStateToProps = createSelector( state => state.jobsApp.jobs.entities, state => state.jobsApp.jobs.results, state => state.jobsApp, (jobsMap, jobsById) => { - return jobsById.map(id => jobsMap[id]); + return { jobs: jobsById.map(id => jobsMap[id]) }; } ); @@ -152,6 +152,6 @@ export class Jobs extends PureComponent { } export default compose( - connect(mapSateToProps, bindableActions), + connect(mapStateToProps, bindableActions), contain(fetchOptions) )(Jobs); diff --git a/common/app/routes/Jobs/components/NewJob.jsx b/common/app/routes/Jobs/components/NewJob.jsx index 1111057def..3fbccf4547 100644 --- a/common/app/routes/Jobs/components/NewJob.jsx +++ b/common/app/routes/Jobs/components/NewJob.jsx @@ -1,17 +1,14 @@ import { helpers } from 'rx'; import React, { PropTypes } from 'react'; import { reduxForm } from 'redux-form'; -import { connector } from 'react-redux'; -import debug from 'debug'; +// import debug from 'debug'; import dedent from 'dedent'; -import normalizeUrl from 'normalize-url'; - -import { getDefaults } from '../utils'; import { - inHTMLData, - uriInSingleQuotedAttr -} from 'xss-filters'; + isAscii, + isEmail, + isURL +} from 'validator'; import { Button, @@ -20,30 +17,14 @@ import { Row } from 'react-bootstrap'; -import { - isAscii, - isEmail, - isURL -} from 'validator'; +import { saveJob } from '../redux/actions'; -const log = debug('fcc:jobs:newForm'); +// const log = debug('fcc:jobs:newForm'); -const checkValidity = [ - 'position', - 'locale', - 'description', - 'email', - 'url', - 'logo', - 'company', - 'isHighlighted', - 'howToApply' -]; const hightlightCopy = ` Highlight my post to make it stand out. (+$250) `; - const isRemoteCopy = ` This job can be performed remotely. `; @@ -60,177 +41,106 @@ const checkboxClass = dedent` col-sm-6 col-md-offset-3 `; -function formatValue(value, validator, type = 'string') { - const formatted = getDefaults(type); - if (validator && type === 'string' && typeof value === 'string') { - formatted.valid = validator(value); - } - if (value) { - formatted.value = value; - formatted.bsStyle = formatted.valid ? 'success' : 'error'; - } - return formatted; -} - -const normalizeOptions = { - stripWWW: false +const certTypes = { + isFrontEndCert: 'isFrontEndCert', + isBackEndCert: 'isBackEndCert' }; -function formatUrl(url, shouldKeepTrailingSlash = true) { - if ( - typeof url === 'string' && - url.length > 4 && - url.indexOf('.') !== -1 - ) { - // prevent trailing / from being stripped during typing - let lastChar = ''; - if (shouldKeepTrailingSlash && url.substring(url.length - 1) === '/') { - lastChar = '/'; - } - return normalizeUrl(url, normalizeOptions) + lastChar; - } - return url; -} - function isValidURL(data) { return isURL(data, { 'require_protocol': true }); } +const fields = [ + 'position', + 'locale', + 'description', + 'email', + 'url', + 'logo', + 'company', + 'isHighlighted', + 'isRemoteOk', + 'isFrontEndCert', + 'isBackEndCert', + 'howToApply' +]; + +const fieldValidators = { + position: makeRequired(isAscii), + locale: makeRequired(isAscii), + description: makeRequired(helpers.identity), + email: makeRequired(isEmail), + url: isValidURL, + logo: isValidURL, + company: makeRequired(isAscii), + howToApply: makeRequired(isAscii) +}; + function makeRequired(validator) { return (val) => !!val && validator(val); } -const formOptions = { - fields: [ - 'position', - 'locale', - 'description', - 'email', - 'url', - 'logo', - 'company', - 'isHighlighted', - 'isRemoteOk', - 'isFrontEndCert', - 'isBackEndCert', - 'howToApply' - ] +function validateForm(values) { + return Object.keys(fieldValidators) + .map(field => { + if (fieldValidators[field](values[field])) { + return null; + } + return { [field]: fieldValidators[field](values[field]) }; + }) + .filter(Boolean) + .reduce((errors, error) => ({ ...errors, ...error }), {}); +} + +function getBsStyle(field) { + if (field.pristine) { + return null; + } + + return field.error ? + 'error' : + 'success'; } export class NewJob extends React.Component { static displayName = 'NewJob'; static propTypes = { - jobActions: PropTypes.object, fields: PropTypes.object, - onSubmit: PropTypes.func + handleSubmit: PropTypes.func }; - handleSubmit(e) { - e.preventDefault(); - const pros = this.props; - let valid = true; - checkValidity.forEach((prop) => { - // if value exist, check if it is valid - if (pros[prop].value && pros[prop].type !== 'boolean') { - valid = valid && !!pros[prop].valid; - } - }); - - if ( - !valid || - !pros.isFrontEndCert && - !pros.isBackEndCert - ) { - debug('form not valid'); - return; - } - - const { - jobActions, - - // form values - position, - locale, - description, - email, - url, - logo, - company, - isFrontEndCert, - isBackEndCert, - isHighlighted, - isRemoteOk, - howToApply - } = this.props; - - // sanitize user output - const jobValues = { - position: inHTMLData(position.value), - locale: inHTMLData(locale.value), - description: inHTMLData(description.value), - email: inHTMLData(email.value), - url: formatUrl(uriInSingleQuotedAttr(url.value), false), - logo: formatUrl(uriInSingleQuotedAttr(logo.value), false), - company: inHTMLData(company.value), - isHighlighted: !!isHighlighted.value, - isRemoteOk: !!isRemoteOk.value, - howToApply: inHTMLData(howToApply.value), - isFrontEndCert, - isBackEndCert - }; - - const job = Object.keys(jobValues).reduce((accu, prop) => { - if (jobValues[prop]) { - accu[prop] = jobValues[prop]; - } - return accu; - }, {}); - - job.postedOn = new Date(); - debug('job sanitized', job); - jobActions.saveForm(job); - - this.history.pushState(null, '/jobs/new/preview'); - }, - componentDidMount() { - const { jobActions } = this.props; - jobActions.getSavedForm(); - }, - - handleChange(name, { target: { value } }) { - const { jobActions: { handleForm } } = this.props; - handleForm({ [name]: value }); - }, + // this.prop.getSavedForm(); + } handleCertClick(name) { - const { jobActions: { handleForm } } = this.props; - const otherButton = name === 'isFrontEndCert' ? - 'isBackEndCert' : - 'isFrontEndCert'; - - handleForm({ - [name]: true, - [otherButton]: false + const { fields } = this.props; + Object.keys(certTypes).forEach(certType => { + if (certType === name) { + return fields[certType].onChange(true); + } + fields[certType].onChange(false); }); - }, + } render() { const { - position, - locale, - description, - email, - url, - logo, - company, - isHighlighted, - isRemoteOk, - howToApply, - isFrontEndCert, - isBackEndCert, - jobActions: { handleForm } + fields: { + position, + locale, + description, + email, + url, + logo, + company, + isHighlighted, + isRemoteOk, + howToApply, + isFrontEndCert, + isBackEndCert + }, + handleSubmit } = this.props; const { handleChange } = this; @@ -246,7 +156,7 @@ export class NewJob extends React.Component {
+ onSubmit={ handleSubmit(data => this.handleSubmit(data)) }>

First, select your ideal applicant:

@@ -259,10 +169,10 @@ export class NewJob extends React.Component {

handleChange('position', e) } placeholder={ 'e.g. Full Stack Developer, Front End Developer, etc.' } required={ true } type='text' - value={ position.value } - wrapperClassName={ inputClass } /> + wrapperClassName={ inputClass } + { ...position } + /> handleChange('locale', e) } placeholder='e.g. San Francisco, Remote, etc.' required={ true } type='text' - value={ locale.value } - wrapperClassName={ inputClass } /> + wrapperClassName={ inputClass } + { ...locale } + /> handleChange('description', e) } required={ true } rows='10' type='textarea' - value={ description.value } - wrapperClassName={ inputClass } /> + wrapperClassName={ inputClass } + { ...description } + /> handleForm({ - isRemoteOk: !!checked - }) - } type='checkbox' - wrapperClassName={ checkboxClass } /> + wrapperClassName={ checkboxClass } + { ...isRemoteOk } + />

@@ -349,16 +255,16 @@ export class NewJob extends React.Component {

How should they apply?

handleChange('howToApply', e) } placeholder={ howToApplyCopy } required={ true } rows='2' type='textarea' - value={ howToApply.value } - wrapperClassName={ inputClass } /> + wrapperClassName={ inputClass } + { ...howToApply } + />
@@ -367,41 +273,42 @@ export class NewJob extends React.Component {

Tell us about your organization

handleChange('company', e) } type='text' - value={ company.value } - wrapperClassName={ inputClass } /> + wrapperClassName={ inputClass } + { ...company } + /> handleChange('email', e) } placeholder='This is how we will contact you' required={ true } type='email' - value={ email.value } - wrapperClassName={ inputClass } /> + wrapperClassName={ inputClass } + { ...email } + /> handleChange('url', e) } placeholder='http://yourcompany.com' type='url' - value={ url.value } - wrapperClassName={ inputClass } /> + wrapperClassName={ inputClass } + { ...url } + /> handleChange('logo', e) } placeholder='http://yourcompany.com/logo.png' type='url' - value={ logo.value } - wrapperClassName={ inputClass } /> + wrapperClassName={ inputClass } + { ...logo } + />

@@ -416,7 +323,7 @@ export class NewJob extends React.Component { mdOffset={ 3 }> Highlight this ad to give it extra attention.
- Featured listings receive more clicks and more applications. + Featured listings receive more clicks and more applications.
@@ -424,17 +331,13 @@ export class NewJob extends React.Component { handleForm({ - isHighlighted: !!checked - }) - } type='checkbox' wrapperClassName={ checkboxClass.replace('text-left', '') - } /> + } + { ...isHighlighted } + />
@@ -462,7 +365,13 @@ export class NewJob extends React.Component { } export default reduxForm( - formOptions, - mapStateToProps, - bindableActions + { + form: 'NewJob', + fields, + validate: validateForm + }, + null, + { + onSubmit: saveJob + } )(NewJob); diff --git a/common/app/routes/Jobs/components/Preview.jsx b/common/app/routes/Jobs/components/Preview.jsx index cfdec61ddb..e7eca1b92a 100644 --- a/common/app/routes/Jobs/components/Preview.jsx +++ b/common/app/routes/Jobs/components/Preview.jsx @@ -1,7 +1,6 @@ import React, { PropTypes } from 'react'; import { Button, Row, Col } from 'react-bootstrap'; import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; import PureComponent from 'react-pure-render/component'; import { goBack, push } from 'react-router-redux'; @@ -10,11 +9,7 @@ import JobNotFound from './JobNotFound.jsx'; import { clearSavedForm, saveJobToDb } from '../redux/actions'; -const mapStateToProps = createSelector( - state => state.jobsApp.previewJob, - state => state.jobsApp.jobs.entities - (job, jobsMap) => ({ job: jobsMap[job] || {} }) -); +const mapStateToProps = state => ({ job: state.jobsApp.newJob }); const bindableActions = { goBack, diff --git a/common/app/routes/Jobs/redux/jobs-form-normalizer.js b/common/app/routes/Jobs/redux/jobs-form-normalizer.js new file mode 100644 index 0000000000..06c5ffa2d7 --- /dev/null +++ b/common/app/routes/Jobs/redux/jobs-form-normalizer.js @@ -0,0 +1,38 @@ +import normalizeUrl from 'normalize-url'; +import { + inHTMLData, + uriInSingleQuotedAttr +} from 'xss-filters'; + +const normalizeOptions = { + stripWWW: false +}; + +function formatUrl(url, shouldKeepTrailingSlash = true) { + if ( + typeof url === 'string' && + url.length > 4 && + url.indexOf('.') !== -1 + ) { + // prevent trailing / from being stripped during typing + let lastChar = ''; + if (shouldKeepTrailingSlash && url.substring(url.length - 1) === '/') { + lastChar = '/'; + } + return normalizeUrl(url, normalizeOptions) + lastChar; + } + return url; +} + +export default { + NewJob: { + position: inHTMLData, + locale: inHTMLData, + description: inHTMLData, + email: inHTMLData, + url: value => formatUrl(uriInSingleQuotedAttr(value)), + logo: value => formatUrl(uriInSingleQuotedAttr(value)), + company: inHTMLData, + howToApply: inHTMLData + } +}; diff --git a/common/app/utils/professor-x.js b/common/app/utils/professor-x.js index d1b2512858..194b28178e 100644 --- a/common/app/utils/professor-x.js +++ b/common/app/utils/professor-x.js @@ -66,8 +66,6 @@ export default function contain(options = {}, Component) { } static displayName = `Container(${Component.displayName})`; - static propTypes = Component.propTypes; - static contextTypes = { ...Component.contextTypes, professor: PropTypes.object From 5c7697837756b79b66b962d2fea4ee7c5a92ca09 Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Sun, 28 Feb 2016 17:00:31 -0800 Subject: [PATCH 13/27] Fix jobs fetch --- common/app/routes/Jobs/components/Jobs.jsx | 17 +++++++---------- common/app/routes/Jobs/redux/fetch-jobs-saga.js | 2 +- common/app/routes/Jobs/redux/reducer.js | 2 +- 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/common/app/routes/Jobs/components/Jobs.jsx b/common/app/routes/Jobs/components/Jobs.jsx index 36fe14cbe4..1991567fcd 100644 --- a/common/app/routes/Jobs/components/Jobs.jsx +++ b/common/app/routes/Jobs/components/Jobs.jsx @@ -2,7 +2,7 @@ import React, { cloneElement, PropTypes } from 'react'; import { compose } from 'redux'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; -import { push } from 'react-router-redux'; +import { LinkContainer } from 'react-router-bootstrap'; import PureComponent from 'react-pure-render/component'; import { Button, Row, Col } from 'react-bootstrap'; @@ -26,8 +26,7 @@ const mapStateToProps = createSelector( const bindableActions = { findJob, - fetchJobs, - push + fetchJobs }; const fetchOptions = { @@ -101,13 +100,11 @@ export class Jobs extends PureComponent { sm={ 8 } smOffset={ 2 } xs={ 12 }> - + + +
diff --git a/common/app/routes/Jobs/redux/fetch-jobs-saga.js b/common/app/routes/Jobs/redux/fetch-jobs-saga.js index e7fec1a429..676bd3c8e9 100644 --- a/common/app/routes/Jobs/redux/fetch-jobs-saga.js +++ b/common/app/routes/Jobs/redux/fetch-jobs-saga.js @@ -34,7 +34,7 @@ export default ({ services }) => ({ dispatch }) => next => { return fetchJobsCompleted( result.jobs[0], { - entities: entities.jobs, + entities: entities.job, results: result.jobs } ); diff --git a/common/app/routes/Jobs/redux/reducer.js b/common/app/routes/Jobs/redux/reducer.js index 2fa43101ee..c5e366d467 100644 --- a/common/app/routes/Jobs/redux/reducer.js +++ b/common/app/routes/Jobs/redux/reducer.js @@ -34,7 +34,7 @@ export default handleActions( newJob }; }, - [types.fetchJobCompleted]: (state, { payload: { jobs, currentJob } }) => ({ + [types.fetchJobsCompleted]: (state, { payload: { jobs, currentJob } }) => ({ ...state, currentJob, jobs From f6c126033602cf4d3f39305941763ebe5ac69770 Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Sun, 28 Feb 2016 17:25:21 -0800 Subject: [PATCH 14/27] Fix job routing --- common/app/routes/Jobs/components/Jobs.jsx | 10 ++---- common/app/routes/Jobs/components/List.jsx | 42 ++++++++++++---------- common/app/routes/Jobs/components/Show.jsx | 11 +++--- 3 files changed, 33 insertions(+), 30 deletions(-) diff --git a/common/app/routes/Jobs/components/Jobs.jsx b/common/app/routes/Jobs/components/Jobs.jsx index 1991567fcd..f5c678ebbe 100644 --- a/common/app/routes/Jobs/components/Jobs.jsx +++ b/common/app/routes/Jobs/components/Jobs.jsx @@ -48,15 +48,11 @@ export class Jobs extends PureComponent { showModal: PropTypes.bool }; - createJobClickHandler(id) { - const { findJob, push } = this.props; - if (!id) { - return null; - } + createJobClickHandler() { + const { findJob } = this.props; - return id => { + return (id) => { findJob(id); - push(`/jobs/${id}`); }; } diff --git a/common/app/routes/Jobs/components/List.jsx b/common/app/routes/Jobs/components/List.jsx index 940937436f..daebd1d999 100644 --- a/common/app/routes/Jobs/components/List.jsx +++ b/common/app/routes/Jobs/components/List.jsx @@ -1,5 +1,6 @@ import React, { PropTypes } from 'react'; import classnames from 'classnames'; +import { LinkContainer } from 'react-router-bootstrap'; import { ListGroup, ListGroupItem } from 'react-bootstrap'; import PureComponent from 'react-pure-render/component'; @@ -41,26 +42,31 @@ export default class ListJobs extends PureComponent { 'jobs-list-highlight': isHighlighted }); + const to = `/jobs/${id}`; + return ( - handleClick(id) }> -
-

- { company } - {' '} - - - { position } - -

-

- { this.addLocation(locale) } -

-
-
+ to={ to }> + handleClick(id) }> +
+

+ { company } + {' '} + + - { position } + +

+

+ { this.addLocation(locale) } +

+
+
+ ); }); } diff --git a/common/app/routes/Jobs/components/Show.jsx b/common/app/routes/Jobs/components/Show.jsx index 05e9320a45..410c7cfcf6 100644 --- a/common/app/routes/Jobs/components/Show.jsx +++ b/common/app/routes/Jobs/components/Show.jsx @@ -6,7 +6,7 @@ import PureComponent from 'react-pure-render/component'; import { createSelector } from 'reselect'; import contain from '../../../utils/professor-x'; -import { fetchJob } from '../redux/actions'; +import { fetchJobs } from '../redux/actions'; import ShowJob from './ShowJob.jsx'; import JobNotFound from './JobNotFound.jsx'; @@ -60,21 +60,22 @@ function generateMessage( const mapStateToProps = createSelector( state => state.app, state => state.jobsApp.currentJob, - ({ username, isFrontEndCert, isBackEndCert }, job = {}) => ({ + state => state.jobsApp.jobs.entities, + ({ username, isFrontEndCert, isBackEndCert }, currentJob, jobs) => ({ username, isFrontEndCert, isBackEndCert, - job + job: jobs[currentJob] || {} }) ); const bindableActions = { push, - fetchJob + fetchJobs }; const fetchOptions = { - fetchAction: 'fetchJob', + fetchAction: 'fetchJobs', getActionArgs({ params: { id } }) { return [ id ]; }, From 5ee802999efa1aa44c93a767ce7ebd567e8803eb Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Sun, 28 Feb 2016 23:01:26 -0800 Subject: [PATCH 15/27] Fix jobs display and priming --- common/app/routes/Jobs/components/Jobs.jsx | 2 +- common/app/utils/professor-x.js | 2 +- server/services/job.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/common/app/routes/Jobs/components/Jobs.jsx b/common/app/routes/Jobs/components/Jobs.jsx index f5c678ebbe..89d19a4931 100644 --- a/common/app/routes/Jobs/components/Jobs.jsx +++ b/common/app/routes/Jobs/components/Jobs.jsx @@ -32,7 +32,7 @@ const bindableActions = { const fetchOptions = { fetchAction: 'fetchJobs', isPrimed({ jobs }) { - return !!jobs.results.length; + return !!jobs.length; } }; diff --git a/common/app/utils/professor-x.js b/common/app/utils/professor-x.js index 194b28178e..a9402f90d7 100644 --- a/common/app/utils/professor-x.js +++ b/common/app/utils/professor-x.js @@ -54,7 +54,7 @@ export default function contain(options = {}, Component) { options.getActionArgs : (() => []); - const isPrimed = typeof typeof options.isPrimed === 'function' ? + const isPrimed = typeof options.isPrimed === 'function' ? options.isPrimed : (() => false); diff --git a/server/services/job.js b/server/services/job.js index 48da7f4605..78d7988885 100644 --- a/server/services/job.js +++ b/server/services/job.js @@ -32,7 +32,7 @@ export default function getJobServices(app) { return Job.findById(id, cb); } Job.find(whereFilt, (err, jobs) => { - cb(err, jobs); + cb(err, jobs.map(job => job.toJSON())); }); } }; From a173ddf375b5836d2e1c1cd0341d3ca7024bc4ce Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Mon, 29 Feb 2016 12:59:35 -0800 Subject: [PATCH 16/27] Fix bad boolean in professor-x --- common/app/utils/professor-x.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/app/utils/professor-x.js b/common/app/utils/professor-x.js index a9402f90d7..9de356867a 100644 --- a/common/app/utils/professor-x.js +++ b/common/app/utils/professor-x.js @@ -158,7 +158,7 @@ export default function contain(options = {}, Component) { ); const fetch$ = action.apply(null, actionArgs); - if (__DEV__ && Observable.isObservable(fetch$)) { + if (__DEV__ && !Observable.isObservable(fetch$)) { throw new Error( 'fetch action should return observable' ); From 5dab7fddbc55a87eee66378587e87296b7d2836b Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Mon, 29 Feb 2016 17:04:45 -0800 Subject: [PATCH 17/27] Fix jobs list and refetching --- common/app/routes/Jobs/components/Jobs.jsx | 2 +- common/app/routes/Jobs/redux/fetch-jobs-saga.js | 2 +- server/services/job.js | 13 ++++++++----- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/common/app/routes/Jobs/components/Jobs.jsx b/common/app/routes/Jobs/components/Jobs.jsx index 89d19a4931..f12e0ff91f 100644 --- a/common/app/routes/Jobs/components/Jobs.jsx +++ b/common/app/routes/Jobs/components/Jobs.jsx @@ -32,7 +32,7 @@ const bindableActions = { const fetchOptions = { fetchAction: 'fetchJobs', isPrimed({ jobs }) { - return !!jobs.length; + return jobs.length > 1; } }; diff --git a/common/app/routes/Jobs/redux/fetch-jobs-saga.js b/common/app/routes/Jobs/redux/fetch-jobs-saga.js index 676bd3c8e9..a959605fbf 100644 --- a/common/app/routes/Jobs/redux/fetch-jobs-saga.js +++ b/common/app/routes/Jobs/redux/fetch-jobs-saga.js @@ -16,7 +16,7 @@ export default ({ services }) => ({ dispatch }) => next => { const { payload: id } = action; const data = { service: 'jobs' }; if (id) { - data.id = id; + data.params = { id }; } return services.readService$(data) diff --git a/server/services/job.js b/server/services/job.js index 78d7988885..e5fdf87392 100644 --- a/server/services/job.js +++ b/server/services/job.js @@ -23,17 +23,20 @@ export default function getJobServices(app) { }); Job.create(job, (err, savedJob) => { - cb(err, savedJob); + cb(err, savedJob.toJSON()); }); }, read(req, resource, params, config, cb) { const id = params ? params.id : null; + console.log('params', params); if (id) { - return Job.findById(id, cb); + return Job.findById(id) + .then(job => cb(null, job.toJSON())) + .catch(cb); } - Job.find(whereFilt, (err, jobs) => { - cb(err, jobs.map(job => job.toJSON())); - }); + Job.find(whereFilt) + .then(jobs => cb(null, jobs.map(job => job.toJSON()))) + .catch(cb); } }; } From 15c94179116c7e0b8fabe5d3dcbea9c92038d964 Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Tue, 1 Mar 2016 20:31:43 -0800 Subject: [PATCH 18/27] Fix job saving on form submit --- common/app/routes/Jobs/components/NewJob.jsx | 32 +++++++++++++++---- common/app/routes/Jobs/redux/index.js | 1 + .../routes/Jobs/redux/jobs-form-normalizer.js | 24 ++++++++------ common/app/routes/Jobs/redux/types.js | 1 + common/utils/services-creator.js | 2 +- 5 files changed, 42 insertions(+), 18 deletions(-) diff --git a/common/app/routes/Jobs/components/NewJob.jsx b/common/app/routes/Jobs/components/NewJob.jsx index 3fbccf4547..c2ff2388ba 100644 --- a/common/app/routes/Jobs/components/NewJob.jsx +++ b/common/app/routes/Jobs/components/NewJob.jsx @@ -1,4 +1,4 @@ -import { helpers } from 'rx'; +import { CompositeDisposable, helpers } from 'rx'; import React, { PropTypes } from 'react'; import { reduxForm } from 'redux-form'; // import debug from 'debug'; @@ -70,14 +70,17 @@ const fieldValidators = { locale: makeRequired(isAscii), description: makeRequired(helpers.identity), email: makeRequired(isEmail), - url: isValidURL, - logo: isValidURL, + url: makeRequired(isValidURL), + logo: makeOptional(isValidURL), company: makeRequired(isAscii), howToApply: makeRequired(isAscii) }; +function makeOptional(validator) { + return val => val ? validator(val) : true; +} function makeRequired(validator) { - return (val) => !!val && validator(val); + return (val) => val ? validator(val) : false; } function validateForm(values) { @@ -86,7 +89,7 @@ function validateForm(values) { if (fieldValidators[field](values[field])) { return null; } - return { [field]: fieldValidators[field](values[field]) }; + return { [field]: !fieldValidators[field](values[field]) }; }) .filter(Boolean) .reduce((errors, error) => ({ ...errors, ...error }), {}); @@ -103,17 +106,32 @@ function getBsStyle(field) { } export class NewJob extends React.Component { + constructor(...args) { + super(...args); + this._subscriptions = new CompositeDisposable(); + } + static displayName = 'NewJob'; static propTypes = { fields: PropTypes.object, - handleSubmit: PropTypes.func + handleSubmit: PropTypes.func, + saveJob: PropTypes.func }; componentDidMount() { // this.prop.getSavedForm(); } + componentWillUnmount() { + this._subscriptions.dispose(); + } + + handleSubmit(job) { + const subscription = this.props.saveJob(job).subscribe(); + this._subscriptions.add(subscription); + } + handleCertClick(name) { const { fields } = this.props; Object.keys(certTypes).forEach(certType => { @@ -372,6 +390,6 @@ export default reduxForm( }, null, { - onSubmit: saveJob + saveJob } )(NewJob); diff --git a/common/app/routes/Jobs/redux/index.js b/common/app/routes/Jobs/redux/index.js index b0b05159f1..e11361c7b2 100644 --- a/common/app/routes/Jobs/redux/index.js +++ b/common/app/routes/Jobs/redux/index.js @@ -4,5 +4,6 @@ export types from './types'; import fetchJobsSaga from './fetch-jobs-saga'; import saveJobSaga from './save-job-saga'; +export formNormalizer from './jobs-form-normalizer'; export const sagas = [ fetchJobsSaga, saveJobSaga ]; diff --git a/common/app/routes/Jobs/redux/jobs-form-normalizer.js b/common/app/routes/Jobs/redux/jobs-form-normalizer.js index 06c5ffa2d7..fb6ed8bc62 100644 --- a/common/app/routes/Jobs/redux/jobs-form-normalizer.js +++ b/common/app/routes/Jobs/redux/jobs-form-normalizer.js @@ -8,7 +8,11 @@ const normalizeOptions = { stripWWW: false }; -function formatUrl(url, shouldKeepTrailingSlash = true) { +function ifDefinedNormalize(normalizer) { + return value => value ? normalizer(value) : value; +} + +function formatUrl(url) { if ( typeof url === 'string' && url.length > 4 && @@ -16,7 +20,7 @@ function formatUrl(url, shouldKeepTrailingSlash = true) { ) { // prevent trailing / from being stripped during typing let lastChar = ''; - if (shouldKeepTrailingSlash && url.substring(url.length - 1) === '/') { + if (url.substring(url.length - 1) === '/') { lastChar = '/'; } return normalizeUrl(url, normalizeOptions) + lastChar; @@ -26,13 +30,13 @@ function formatUrl(url, shouldKeepTrailingSlash = true) { export default { NewJob: { - position: inHTMLData, - locale: inHTMLData, - description: inHTMLData, - email: inHTMLData, - url: value => formatUrl(uriInSingleQuotedAttr(value)), - logo: value => formatUrl(uriInSingleQuotedAttr(value)), - company: inHTMLData, - howToApply: inHTMLData + position: ifDefinedNormalize(inHTMLData), + locale: ifDefinedNormalize(inHTMLData), + description: ifDefinedNormalize(inHTMLData), + email: ifDefinedNormalize(inHTMLData), + url: ifDefinedNormalize(value => formatUrl(uriInSingleQuotedAttr(value))), + logo: ifDefinedNormalize(value => formatUrl(uriInSingleQuotedAttr(value))), + company: ifDefinedNormalize(inHTMLData), + howToApply: ifDefinedNormalize(inHTMLData) } }; diff --git a/common/app/routes/Jobs/redux/types.js b/common/app/routes/Jobs/redux/types.js index 7417e734b6..befa6299c0 100644 --- a/common/app/routes/Jobs/redux/types.js +++ b/common/app/routes/Jobs/redux/types.js @@ -4,6 +4,7 @@ const types = [ 'findJob', 'saveJob', + 'saveJobCompleted', 'saveForm', 'clearForm', diff --git a/common/utils/services-creator.js b/common/utils/services-creator.js index aa7e8bfe97..323cba96fe 100644 --- a/common/utils/services-creator.js +++ b/common/utils/services-creator.js @@ -32,7 +32,7 @@ export default stampit({ }); }, createService$({ service: resource, params, body, config }) { - return Observable.create(function(observer) { + return Observable.create(observer => { this.services.create( resource, params, From d6f21b01e61ee42eb16c5f8e283617d4cffb0768 Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Tue, 1 Mar 2016 20:59:03 -0800 Subject: [PATCH 19/27] Add location change on successful job creation --- common/app/routes/Jobs/redux/save-job-saga.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/common/app/routes/Jobs/redux/save-job-saga.js b/common/app/routes/Jobs/redux/save-job-saga.js index 0faf17d823..34d59d5753 100644 --- a/common/app/routes/Jobs/redux/save-job-saga.js +++ b/common/app/routes/Jobs/redux/save-job-saga.js @@ -1,4 +1,5 @@ import { Observable } from 'rx'; +import { push } from 'react-router-redux'; import { saveJobCompleted } from './actions'; import { saveJob } from './types'; @@ -16,7 +17,11 @@ export default ({ services }) => ({ dispatch }) => next => { service: 'jobs', params: { job } }) - .map(job => saveJobCompleted(job)) + .retry(3) + .flatMap(job => Observable.of( + saveJobCompleted(job), + push('/jobs/new/preview') + )) .catch(error => Observable.just({ type: handleError, error From a2a988764ce505ae01a210dd4997abfb8e881b28 Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Wed, 2 Mar 2016 16:33:44 -0800 Subject: [PATCH 20/27] Fix job creation --- client/sagas/index.js | 3 +- client/sagas/local-storage-saga.js | 18 +++--- .../app/routes/Jobs/components/JobTotal.jsx | 46 +++++++++++---- common/app/routes/Jobs/components/NewJob.jsx | 30 +++++----- common/app/routes/Jobs/components/Preview.jsx | 58 ++++++++++++------- common/app/routes/Jobs/redux/actions.js | 20 ++++++- .../app/routes/Jobs/redux/apply-promo-saga.js | 8 +-- common/app/routes/Jobs/redux/index.js | 4 +- common/app/routes/Jobs/redux/reducer.js | 22 ++++--- common/app/routes/Jobs/redux/save-job-saga.js | 11 ++-- common/app/routes/Jobs/redux/types.js | 13 ++++- 11 files changed, 149 insertions(+), 84 deletions(-) diff --git a/client/sagas/index.js b/client/sagas/index.js index fc72193446..cb4e09a185 100644 --- a/client/sagas/index.js +++ b/client/sagas/index.js @@ -1,4 +1,5 @@ import errSaga from './err-saga'; import titleSaga from './title-saga'; +import localStorageSaga from './local-storage-saga'; -export default [errSaga, titleSaga]; +export default [errSaga, titleSaga, localStorageSaga]; diff --git a/client/sagas/local-storage-saga.js b/client/sagas/local-storage-saga.js index 7913dcf42f..ecf69bcf31 100644 --- a/client/sagas/local-storage-saga.js +++ b/client/sagas/local-storage-saga.js @@ -2,11 +2,12 @@ import { saveForm, clearForm, loadSavedForm -} from '../common/app/routes/Jobs/redux/types'; +} from '../../common/app/routes/Jobs/redux/types'; import { + saveCompleted, loadSavedFormCompleted -} from '../common/app/routes/Jobs/redux/actions'; +} from '../../common/app/routes/Jobs/redux/actions'; const formKey = 'newJob'; let enabled = false; @@ -17,7 +18,7 @@ let store = typeof window !== 'undefined' ? try { const testKey = '__testKey__'; store.setItem(testKey, testKey); - enabled = store.getItem(testKey) !== testKey; + enabled = store.getItem(testKey) === testKey; store.removeItem(testKey); } catch (e) { enabled = !e; @@ -35,11 +36,12 @@ export default () => ({ dispatch }) => next => { const form = action.payload; try { store.setItem(formKey, JSON.stringify(form)); - return null; - } catch (e) { + next(action); + return dispatch(saveCompleted(form)); + } catch (error) { return dispatch({ type: 'app.handleError', - error: new Error('could not parse form data') + error }); } } @@ -54,10 +56,10 @@ export default () => ({ dispatch }) => next => { try { const form = JSON.parse(formString); return dispatch(loadSavedFormCompleted(form)); - } catch (err) { + } catch (error) { return dispatch({ type: 'app.handleError', - error: new Error('could not parse form data') + error }); } } diff --git a/common/app/routes/Jobs/components/JobTotal.jsx b/common/app/routes/Jobs/components/JobTotal.jsx index 266d3d9812..9524d44288 100644 --- a/common/app/routes/Jobs/components/JobTotal.jsx +++ b/common/app/routes/Jobs/components/JobTotal.jsx @@ -1,9 +1,17 @@ +import { CompositeDisposable } from 'rx'; import React, { PropTypes } from 'react'; import { Button, Input, Col, Row, Well } from 'react-bootstrap'; import { connect } from 'react-redux'; +import { push } from 'react-router-redux'; import PureComponent from 'react-pure-render/component'; import { createSelector } from 'reselect'; +import { + applyPromo, + clearPromo, + updatePromo +} from '../redux/actions'; + // real paypal buttons // will take your money const paypalIds = { @@ -12,10 +20,14 @@ const paypalIds = { }; const bindableActions = { + applyPromo, + clearPromo, + push, + updatePromo }; const mapStateToProps = createSelector( - state => state.jobsApp.currentJob, + state => state.jobsApp.newJob, state => state.jobsApp, ( { id, isHighlighted } = {}, @@ -46,6 +58,11 @@ const mapStateToProps = createSelector( ); export class JobTotal extends PureComponent { + constructor(...args) { + super(...args); + this._subscriptions = new CompositeDisposable(); + } + static displayName = 'JobTotal'; static propTypes = { @@ -60,13 +77,15 @@ export class JobTotal extends PureComponent { }; componentDidMount() { - const { jobActions } = this.props; - jobActions.clearPromo(); + if (!this.props.id) { + this.props.push('/jobs'); + } + + this.props.clearPromo(); } - goToJobBoard() { - const { appActions } = this.props; - setTimeout(() => appActions.goTo('/jobs'), 0); + componentWillUnmount() { + this._subscriptions.dispose(); } renderDiscount(discountAmount) { @@ -114,7 +133,8 @@ export class JobTotal extends PureComponent { promoCode, promoName, isHighlighted, - jobActions + applyPromo, + updatePromo } = this.props; if (promoApplied) { @@ -147,7 +167,7 @@ export class JobTotal extends PureComponent { md={ 3 } mdOffset={ 3 }> @@ -156,11 +176,12 @@ export class JobTotal extends PureComponent { @@ -176,7 +197,8 @@ export class JobTotal extends PureComponent { isHighlighted, buttonId, price, - discountAmount + discountAmount, + push } = this.props; return ( @@ -239,7 +261,7 @@ export class JobTotal extends PureComponent { setTimeout(push, 0, '/jobs') } target='_blank'> ({ initialValues: state.jobsApp.initialValues }), { - saveJob + loadSavedForm, + push, + saveForm } )(NewJob); diff --git a/common/app/routes/Jobs/components/Preview.jsx b/common/app/routes/Jobs/components/Preview.jsx index e7eca1b92a..478b3cfea8 100644 --- a/common/app/routes/Jobs/components/Preview.jsx +++ b/common/app/routes/Jobs/components/Preview.jsx @@ -1,3 +1,4 @@ +import { CompositeDisposable } from 'rx'; import React, { PropTypes } from 'react'; import { Button, Row, Col } from 'react-bootstrap'; import { connect } from 'react-redux'; @@ -7,22 +8,30 @@ import { goBack, push } from 'react-router-redux'; import ShowJob from './ShowJob.jsx'; import JobNotFound from './JobNotFound.jsx'; -import { clearSavedForm, saveJobToDb } from '../redux/actions'; +import { clearForm, saveJob } from '../redux/actions'; const mapStateToProps = state => ({ job: state.jobsApp.newJob }); const bindableActions = { goBack, push, - clearSavedForm, - saveJobToDb + clearForm, + saveJob }; export class JobPreview extends PureComponent { + constructor(...args) { + super(...args); + this._subscriptions = new CompositeDisposable(); + } + static displayName = 'Preview'; static propTypes = { - job: PropTypes.object + job: PropTypes.object, + saveJob: PropTypes.func, + clearForm: PropTypes.func, + push: PropTypes.func }; componentDidMount() { @@ -33,8 +42,19 @@ export class JobPreview extends PureComponent { } } + componentWillUnmount() { + this._subscriptions.dispose(); + } + + handleJobSubmit() { + const { clearForm, saveJob, job } = this.props; + clearForm(); + const subscription = saveJob(job).subscribe(); + this._subscriptions.add(subscription); + } + render() { - const { job, goBack, clearSavedForm, saveJobToDb } = this.props; + const { job, goBack } = this.props; if (!job || !job.position || !job.description) { return ; @@ -54,25 +74,19 @@ export class JobPreview extends PureComponent { - -
- - -
+ + +
+ + +
); } } diff --git a/common/app/routes/Jobs/redux/actions.js b/common/app/routes/Jobs/redux/actions.js index 0212a2a375..e3a6f7a6de 100644 --- a/common/app/routes/Jobs/redux/actions.js +++ b/common/app/routes/Jobs/redux/actions.js @@ -10,11 +10,25 @@ export const fetchJobsCompleted = createAction( export const findJob = createAction(types.findJob); +// saves to database export const saveJob = createAction(types.saveJob); -export const saveJobCompleted = createAction(types.saveJobCompleted); - +// saves to localStorage export const saveForm = createAction(types.saveForm); -export const clearForm = createAction(types.clearSavedForm); + +export const saveCompleted = createAction(types.saveCompleted); + +export const clearForm = createAction(types.clearForm); + +export const loadSavedForm = createAction(types.loadSavedForm); export const loadSavedFormCompleted = createAction( types.loadSavedFormCompleted ); + +export const clearPromo = createAction(types.clearPromo); +export const updatePromo = createAction( + types.updatePromo, + ({ target: { value = '' } = {} } = {}) => value +); + +export const applyPromo = createAction(types.applyPromo); +export const applyPromoCompleted = createAction(types.applyPromoCompleted); diff --git a/common/app/routes/Jobs/redux/apply-promo-saga.js b/common/app/routes/Jobs/redux/apply-promo-saga.js index e5b182baf5..4268383e35 100644 --- a/common/app/routes/Jobs/redux/apply-promo-saga.js +++ b/common/app/routes/Jobs/redux/apply-promo-saga.js @@ -1,12 +1,12 @@ import { Observable } from 'rx'; -import { testPromo } from './types'; -import { applyPromo } from './actions'; +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 !== testPromo) { + if (action.type !== applyPromo) { return next(action); } @@ -28,7 +28,7 @@ export default () => ({ dispatch }) => next => { throw new Error('No promo returned by server'); } - return applyPromo(promo); + return applyPromoCompleted(promo); }) .catch(error => Observable.just({ type: 'app.handleError', diff --git a/common/app/routes/Jobs/redux/index.js b/common/app/routes/Jobs/redux/index.js index e11361c7b2..fe3f432b3e 100644 --- a/common/app/routes/Jobs/redux/index.js +++ b/common/app/routes/Jobs/redux/index.js @@ -4,6 +4,8 @@ export types from './types'; import fetchJobsSaga from './fetch-jobs-saga'; import saveJobSaga from './save-job-saga'; +import applyPromoSaga from './apply-promo-saga'; + export formNormalizer from './jobs-form-normalizer'; -export const sagas = [ fetchJobsSaga, saveJobSaga ]; +export const sagas = [ fetchJobsSaga, saveJobSaga, applyPromoSaga ]; diff --git a/common/app/routes/Jobs/redux/reducer.js b/common/app/routes/Jobs/redux/reducer.js index c5e366d467..e227f9c254 100644 --- a/common/app/routes/Jobs/redux/reducer.js +++ b/common/app/routes/Jobs/redux/reducer.js @@ -9,6 +9,8 @@ function replace(str) { } const initialState = { + // used by NewJob form + initialValues: {}, currentJob: '', newJob: {}, jobs: { @@ -28,22 +30,26 @@ export default handleActions( state.currentJob }; }, - [types.saveJobCompleted]: (state, { payload: newJob }) => { - return { - ...state, - newJob - }; - }, [types.fetchJobsCompleted]: (state, { payload: { jobs, currentJob } }) => ({ ...state, currentJob, jobs }), - [types.updatePromoCode]: (state, { payload }) => ({ + [types.updatePromo]: (state, { payload }) => ({ ...state, promoCode: replace(payload) }), - [types.applyPromo]: (state, { payload: promo }) => { + [types.saveCompleted]: (state, { payload: newJob }) => { + return { + ...state, + newJob + }; + }, + [types.loadSavedFormCompleted]: (state, { payload: initialValues }) => ({ + ...state, + initialValues + }), + [types.applyPromoCompleted]: (state, { payload: promo }) => { const { fullPrice: price, diff --git a/common/app/routes/Jobs/redux/save-job-saga.js b/common/app/routes/Jobs/redux/save-job-saga.js index 34d59d5753..0bb3c2c562 100644 --- a/common/app/routes/Jobs/redux/save-job-saga.js +++ b/common/app/routes/Jobs/redux/save-job-saga.js @@ -1,15 +1,16 @@ -import { Observable } from 'rx'; import { push } from 'react-router-redux'; +import { Observable } from 'rx'; -import { saveJobCompleted } from './actions'; +import { saveCompleted } from './actions'; 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 next(action); + return result; } const { payload: job } = action; @@ -19,8 +20,8 @@ export default ({ services }) => ({ dispatch }) => next => { }) .retry(3) .flatMap(job => Observable.of( - saveJobCompleted(job), - push('/jobs/new/preview') + saveCompleted(job), + push('/jobs/new/check-out') )) .catch(error => Observable.just({ type: handleError, diff --git a/common/app/routes/Jobs/redux/types.js b/common/app/routes/Jobs/redux/types.js index befa6299c0..aa6fe40a06 100644 --- a/common/app/routes/Jobs/redux/types.js +++ b/common/app/routes/Jobs/redux/types.js @@ -4,12 +4,19 @@ const types = [ 'findJob', 'saveJob', - 'saveJobCompleted', - 'saveForm', + + 'saveCompleted', + 'clearForm', + 'loadSavedForm', - 'loadSavedFormCompleted' + 'loadSavedFormCompleted', + + 'clearPromo', + 'updatePromo', + 'applyPromo', + 'applyPromoCompleted' ]; export default types.reduce((types, type) => { From c50510db71e8ccb4ac31188707248f936f17d272 Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Wed, 2 Mar 2016 19:45:54 -0800 Subject: [PATCH 21/27] Update history/react-router --- client/index.js | 4 ++-- common/app/create-app.jsx | 8 ++++---- common/app/routes/Jobs/components/JobTotal.jsx | 2 +- common/app/routes/Jobs/components/Preview.jsx | 2 +- package.json | 6 +++--- server/boot/a-react.js | 15 +++++++-------- server/services/job.js | 1 - 7 files changed, 18 insertions(+), 20 deletions(-) diff --git a/client/index.js b/client/index.js index 92d4d9c972..0925a6c948 100644 --- a/client/index.js +++ b/client/index.js @@ -4,7 +4,7 @@ import React from 'react'; import debug from 'debug'; import { Router } from 'react-router'; import { routeReducer as routing, syncHistory } from 'react-router-redux'; -import { createLocation, createHistory } from 'history'; +import { createHistory } from 'history'; import app$ from '../common/app'; import provideStore from '../common/app/provide-store'; @@ -23,7 +23,7 @@ const serviceOptions = { xhrPath: '/services' }; Rx.config.longStackSupport = !!debug.enabled; const history = createHistory(); -const appLocation = createLocation( +const appLocation = history.createLocation( location.pathname + location.search ); const routingMiddleware = syncHistory(history); diff --git a/common/app/create-app.jsx b/common/app/create-app.jsx index 27a6cd23b0..f07a830ab2 100644 --- a/common/app/create-app.jsx +++ b/common/app/create-app.jsx @@ -21,7 +21,7 @@ const routes = { components: App, ...childRoutes }; // // createApp(settings: { -// location?: Location, +// location?: Location|String, // history?: History, // initialState?: Object|Void, // serviceOptions?: Object, @@ -65,13 +65,13 @@ export default function createApp({ const store = compose(...enhancers)(createStore)(reducer, initialState); // createRouteProps({ - // location: LocationDescriptor, + // redirect: LocationDescriptor, // history: History, // routes: Object // }) => Observable return createRouteProps({ routes, location, history }) - .map(([ nextLocation, props ]) => ({ - nextLocation, + .map(([ redirect, props ]) => ({ + redirect, props, reducer, store diff --git a/common/app/routes/Jobs/components/JobTotal.jsx b/common/app/routes/Jobs/components/JobTotal.jsx index 9524d44288..97b3db9e37 100644 --- a/common/app/routes/Jobs/components/JobTotal.jsx +++ b/common/app/routes/Jobs/components/JobTotal.jsx @@ -76,7 +76,7 @@ export class JobTotal extends PureComponent { promoApplied: PropTypes.bool }; - componentDidMount() { + componentWillMount() { if (!this.props.id) { this.props.push('/jobs'); } diff --git a/common/app/routes/Jobs/components/Preview.jsx b/common/app/routes/Jobs/components/Preview.jsx index 478b3cfea8..7509408e50 100644 --- a/common/app/routes/Jobs/components/Preview.jsx +++ b/common/app/routes/Jobs/components/Preview.jsx @@ -34,7 +34,7 @@ export class JobPreview extends PureComponent { push: PropTypes.func }; - componentDidMount() { + componentWillMount() { const { push, job } = this.props; // redirect user in client if (!job || !job.position || !job.description) { diff --git a/package.json b/package.json index 15e4d5a240..62d49b137a 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,7 @@ "gulp-util": "^3.0.6", "helmet": "^1.1.0", "helmet-csp": "^1.0.3", - "history": "^1.17.0", + "history": "^2.0.0", "jade": "^1.11.0", "json-loader": "~0.5.2", "less": "^2.5.1", @@ -108,8 +108,8 @@ "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-router": "^2.0.0", + "react-router-bootstrap": "~0.20.1", "react-toastr": "^2.4.0", "react-router-redux": "^2.1.0", "react-vimeo": "~0.1.0", diff --git a/server/boot/a-react.js b/server/boot/a-react.js index 009e585361..5d13e39b99 100644 --- a/server/boot/a-react.js +++ b/server/boot/a-react.js @@ -1,6 +1,5 @@ import React from 'react'; -import { RoutingContext } from 'react-router'; -import { createLocation } from 'history'; +import { RouterContext } from 'react-router'; import debug from 'debug'; import renderToString from '../../common/app/utils/render-to-string'; @@ -39,15 +38,15 @@ export default function reactSubRouter(app) { function serveReactApp(req, res, next) { const serviceOptions = { req }; - const location = createLocation(req.path); - - // returns a router wrapped app app$({ - location, + location: req.path, serviceOptions }) // if react-router does not find a route send down the chain - .filter(({ props }) => { + .filter(({ redirect, props }) => { + if (!props && redirect) { + res.redirect(redirect.pathname + redirect.search); + } if (!props) { log(`react tried to find ${location.pathname} but got 404`); return next(); @@ -58,7 +57,7 @@ export default function reactSubRouter(app) { log('render react markup and pre-fetch data'); return renderToString( - provideStore(React.createElement(RoutingContext, props), store) + provideStore(React.createElement(RouterContext, props), store) ) .map(({ markup }) => ({ markup, store })); }) diff --git a/server/services/job.js b/server/services/job.js index e5fdf87392..94e7ee2190 100644 --- a/server/services/job.js +++ b/server/services/job.js @@ -28,7 +28,6 @@ export default function getJobServices(app) { }, read(req, resource, params, config, cb) { const id = params ? params.id : null; - console.log('params', params); if (id) { return Job.findById(id) .then(job => cb(null, job.toJSON())) From 74592e72b474073af4c52162956e6cb0142af40d Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Wed, 2 Mar 2016 20:54:14 -0800 Subject: [PATCH 22/27] Update eslint, fix lint issues --- .eslintrc | 17 +++---- client/commonFramework/code-storage.js | 1 + client/commonFramework/end.js | 9 +++- client/commonFramework/phone-scroll-lock.js | 2 +- client/commonFramework/step-challenge.js | 51 +++++++++---------- client/main.js | 4 +- client/sagas/err-saga.js | 7 +-- .../app/routes/Hikes/components/Questions.jsx | 1 + common/app/routes/Jobs/components/NewJob.jsx | 2 +- common/app/temp.js | 40 --------------- common/app/utils/professor-x.js | 4 +- common/app/utils/render-to-string.js | 2 +- common/models/User-Identity.js | 14 ++--- common/models/user.js | 4 +- common/utils/services-creator.js | 4 +- package.json | 4 +- server/boot/a-extendUser.js | 35 ++++++------- server/boot/a-extendUserIdent.js | 2 +- server/boot/challenge.js | 7 +-- server/boot/commit.js | 2 +- server/boot/home.js | 6 +-- server/boot/randomAPIs.js | 38 +++++++------- server/boot/story.js | 4 +- server/boot/user.js | 34 ++++++------- server/middlewares/add-return-to.js | 7 +-- server/middlewares/revision-helpers.js | 2 +- server/services/hikes.js | 2 +- server/services/job.js | 4 +- server/utils/index.js | 2 +- server/utils/rx.js | 4 +- 30 files changed, 141 insertions(+), 174 deletions(-) delete mode 100644 common/app/temp.js diff --git a/.eslintrc b/.eslintrc index 073f6010bc..5e924f47ed 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,6 +1,9 @@ { - "ecmaFeatures": { - "jsx": true + "parserOption": { + "ecmaVersion": 6, + "ecmaFeatures": { + "jsx": true + } }, "env": { "browser": true, @@ -12,6 +15,7 @@ "react" ], "globals": { + "Promise": true, "window": true, "$": true, "ga": true, @@ -58,7 +62,6 @@ "no-caller": 2, "no-div-regex": 2, "no-else-return": 0, - "no-empty-label": 2, "no-eq-null": 1, "no-eval": 2, "no-extend-native": 2, @@ -182,10 +185,7 @@ "always" ], "sort-vars": 0, - "space-after-keywords": [ - 2, - "always" - ], + "keyword-spacing": [ 2 ], "space-before-function-paren": [ 2, "never" @@ -197,7 +197,6 @@ "space-in-brackets": 0, "space-in-parens": 0, "space-infix-ops": 2, - "space-return-throw-case": 2, "space-unary-ops": [ 1, { @@ -214,7 +213,7 @@ "max-depth": 0, "max-len": [ - 1, + 2, 80, 2 ], diff --git a/client/commonFramework/code-storage.js b/client/commonFramework/code-storage.js index 4cfe6cf833..e7ff937528 100644 --- a/client/commonFramework/code-storage.js +++ b/client/commonFramework/code-storage.js @@ -34,6 +34,7 @@ window.common = (function(global) { } } } + return null; }, isAlive: function(key) { diff --git a/client/commonFramework/end.js b/client/commonFramework/end.js index 76d9305c6e..b2a6b09a67 100644 --- a/client/commonFramework/end.js +++ b/client/commonFramework/end.js @@ -51,10 +51,10 @@ $(document).ready(function() {

${err}

`).subscribe(() => {}); } + return null; }, err => console.error(err) ); - } common.resetBtn$ @@ -74,6 +74,7 @@ $(document).ready(function() { common.codeStorage.updateStorage(challengeName, originalCode); common.codeUri.querify(originalCode); common.updateOutputDisplay(output); + return null; }, (err) => { if (err) { @@ -112,6 +113,7 @@ $(document).ready(function() { if (solved) { common.showCompletion(); } + return null; }, ({ err }) => { console.error(err); @@ -138,6 +140,7 @@ $(document).ready(function() { return common.updateOutputDisplay('' + err); } common.displayTestResults(tests); + return null; }, ({ err }) => { console.error(err); @@ -149,7 +152,7 @@ $(document).ready(function() { challengeType === challengeTypes.BONFIRE || challengeType === challengeTypes.JS ) { - Observable.just({}) + return Observable.just({}) .delay(500) .flatMap(() => common.executeChallenge$()) .catch(err => Observable.just({ err })) @@ -161,6 +164,7 @@ $(document).ready(function() { } common.codeStorage.updateStorage(challengeName, originalCode); common.displayTestResults(tests); + return null; }, (err) => { console.error(err); @@ -168,4 +172,5 @@ $(document).ready(function() { } ); } + return null; }); diff --git a/client/commonFramework/phone-scroll-lock.js b/client/commonFramework/phone-scroll-lock.js index a9b58fb23c..a1ab157a71 100644 --- a/client/commonFramework/phone-scroll-lock.js +++ b/client/commonFramework/phone-scroll-lock.js @@ -110,7 +110,7 @@ window.common = (function({ common = { init: [] }}) { return null; } execInProgress = true; - setTimeout(function() { + return setTimeout(function() { if ( $($('.scroll-locker').children()[0]).height() - 800 > e.detail ) { diff --git a/client/commonFramework/step-challenge.js b/client/commonFramework/step-challenge.js index 15ee010f2d..f70bc469bb 100644 --- a/client/commonFramework/step-challenge.js +++ b/client/commonFramework/step-challenge.js @@ -92,18 +92,16 @@ window.common = (function({ $, common = { init: [] }}) { } function handleActionClick(e) { - var props = common.challengeSeed[0] || - { stepIndex: [] }; + var props = common.challengeSeed[0] || { stepIndex: [] }; var $el = $(this); var index = +$el.attr('id'); var propIndex = props.stepIndex.indexOf(index); if (propIndex === -1) { - return $el - .parent() - .find('.disabled') - .removeClass('disabled'); + return $el.parent() + .find('.disabled') + .removeClass('disabled'); } // an API action @@ -112,30 +110,26 @@ window.common = (function({ $, common = { init: [] }}) { var prop = props.properties[propIndex]; var api = props.apis[propIndex]; if (common[prop]) { - return $el - .parent() - .find('.disabled') - .removeClass('disabled'); - } - $ - .post(api) - .done(function(data) { - // assume a boolean indicates passing - if (typeof data === 'boolean') { - return $el - .parent() + return $el.parent() .find('.disabled') .removeClass('disabled'); - } - // assume api returns string when fails - $el - .parent() - .find('.disabled') - .replaceWith('

' + data + '

'); - }) - .fail(function() { - console.log('failed'); - }); + } + return $.post(api) + .done(function(data) { + // assume a boolean indicates passing + if (typeof data === 'boolean') { + return $el.parent() + .find('.disabled') + .removeClass('disabled'); + } + // assume api returns string when fails + return $el.parent() + .find('.disabled') + .replaceWith('

' + data + '

'); + }) + .fail(function() { + console.log('failed'); + }); } function handleFinishClick(e) { @@ -199,6 +193,7 @@ window.common = (function({ $, common = { init: [] }}) { $(nextBtnClass).click(handleNextStepClick); $(actionBtnClass).click(handleActionClick); $(finishBtnClass).click(handleFinishClick); + return null; }); return common; diff --git a/client/main.js b/client/main.js index 7ae02eef75..3276790483 100644 --- a/client/main.js +++ b/client/main.js @@ -93,6 +93,7 @@ main = (function(main, global) { 'Free Code Camp\'s Main Chat' + '
' ); + return null; }); @@ -233,7 +234,7 @@ $(document).ready(function() { }; $('#story-submit').unbind('click'); - $.post('/stories/', data) + return $.post('/stories/', data) .fail(function() { $('#story-submit').bind('click', storySubmitButtonHandler); }) @@ -243,6 +244,7 @@ $(document).ready(function() { return null; } window.location = '/stories/' + storyLink; + return null; }); }; diff --git a/client/sagas/err-saga.js b/client/sagas/err-saga.js index 72a80d4c5d..968aa2b9e0 100644 --- a/client/sagas/err-saga.js +++ b/client/sagas/err-saga.js @@ -4,15 +4,16 @@ // errSaga(action: Action) => Object|Void export default () => ({ dispatch }) => next => { return function errorSaga(action) { - if (!action.error) { return next(action); } + const result = next(action); + if (!action.error) { return result; } console.error(action.error); - dispatch({ + return dispatch({ type: 'app.makeToast', payload: { type: 'error', title: 'Oops, something went wrong', - message: `Something went wrong, please try again later` + message: 'Something went wrong, please try again later' } }); }; diff --git a/common/app/routes/Hikes/components/Questions.jsx b/common/app/routes/Hikes/components/Questions.jsx index 46011285a7..5bf14a3e83 100644 --- a/common/app/routes/Hikes/components/Questions.jsx +++ b/common/app/routes/Hikes/components/Questions.jsx @@ -103,6 +103,7 @@ class Question extends React.Component { .subscribe(); this._subscriptions.add(subscription); + return null; } handleMouseMove(isPressed, { delta, moveQuestion }) { diff --git a/common/app/routes/Jobs/components/NewJob.jsx b/common/app/routes/Jobs/components/NewJob.jsx index dc6c6e2cf3..5cb6c990ac 100644 --- a/common/app/routes/Jobs/components/NewJob.jsx +++ b/common/app/routes/Jobs/components/NewJob.jsx @@ -132,7 +132,7 @@ export class NewJob extends React.Component { if (certType === name) { return fields[certType].onChange(true); } - fields[certType].onChange(false); + return fields[certType].onChange(false); }); } diff --git a/common/app/temp.js b/common/app/temp.js deleted file mode 100644 index 4ed6ed821d..0000000000 --- a/common/app/temp.js +++ /dev/null @@ -1,40 +0,0 @@ -import stamp from 'stampit'; -import { post$, postJSON$ } from '../utils/ajax-stream.js'; - - const serviceStamp = stamp({ - methods: { - readService$(resource, params, config) { - - return Observable.create(function(observer) { - services.read(resource, params, config, (err, res) => { - if (err) { - return observer.onError(err); - } - - observer.onNext(res); - observer.onCompleted(); - }); - - return Disposable.create(function() { - observer.dispose(); - }); - }); - }, - createService$(resource, params, body, config) { - return Observable.create(function(observer) { - services.create(resource, params, body, config, (err, res) => { - if (err) { - return observer.onError(err); - } - - observer.onNext(res); - observer.onCompleted(); - }); - - return Disposable.create(function() { - observer.dispose(); - }); - }); - } - } - }); diff --git a/common/app/utils/professor-x.js b/common/app/utils/professor-x.js index 9de356867a..d4b3e5f1a9 100644 --- a/common/app/utils/professor-x.js +++ b/common/app/utils/professor-x.js @@ -105,7 +105,7 @@ export default function contain(options = {}, Component) { getChildContext(Component.contextTypes, this.context) ); - professor.fetchContext.push({ + return professor.fetchContext.push({ name: options.fetchAction, action, actionArgs, @@ -136,7 +136,7 @@ export default function contain(options = {}, Component) { () => {}, options.handleError ); - this.__subscriptions.add(subscription); + return this.__subscriptions.add(subscription); } componentWillReceiveProps(nextProps, nextContext) { diff --git a/common/app/utils/render-to-string.js b/common/app/utils/render-to-string.js index b19b11bf55..70560a722c 100644 --- a/common/app/utils/render-to-string.js +++ b/common/app/utils/render-to-string.js @@ -17,7 +17,7 @@ export function fetch({ fetchContext = [] }) { .doOnNext(fetch$ => { if (!Observable.isObservable(fetch$)) { throw new Error( - `action creator should return an observable` + 'action creator should return an observable' ); } }) diff --git a/common/models/User-Identity.js b/common/models/User-Identity.js index 2d1c357c35..e57036a966 100644 --- a/common/models/User-Identity.js +++ b/common/models/User-Identity.js @@ -73,7 +73,7 @@ export default function(UserIdent) { } ); } - cb(err, user, identity); + return cb(err, user, identity); }); }); } @@ -99,12 +99,12 @@ export default function(UserIdent) { } else { query = { username: userObj.username }; } - userModel.findOrCreate({ where: query }, userObj, function(err, user) { + return userModel.findOrCreate({ where: query }, userObj, (err, user) => { if (err) { return cb(err); } var date = new Date(); - userIdentityModel.create({ + return userIdentityModel.create({ provider: getSocialProvider(provider), externalId: profile.id, authScheme: authScheme, @@ -122,7 +122,7 @@ export default function(UserIdent) { } ); } - cb(err, user, identity); + return cb(err, user, identity); }); }); }); @@ -134,7 +134,7 @@ export default function(UserIdent) { debug('no user identity instance found'); return next(); } - userIdent.user(function(err, user) { + return userIdent.user(function(err, user) { let userChanged = false; if (err) { return next(err); } if (!user) { @@ -175,11 +175,11 @@ export default function(UserIdent) { if (userChanged) { return user.save(function(err) { if (err) { return next(err); } - next(); + return next(); }); } debug('exiting after user identity before save'); - next(); + return next(); }); }); } diff --git a/common/models/user.js b/common/models/user.js index 952e665fad..c772884066 100644 --- a/common/models/user.js +++ b/common/models/user.js @@ -271,7 +271,7 @@ module.exports = function(User) { )); }); } - User.findOne({ where: { username } }, (err, user) => { + return User.findOne({ where: { username } }, (err, user) => { if (err) { return cb(err); } @@ -327,7 +327,7 @@ module.exports = function(User) { .valueOf(); const user$ = findUser({ where: { username: receiver }}); - user$ + return user$ .tapOnNext((user) => { if (!user) { throw new Error(`could not find receiver for ${ receiver }`); diff --git a/common/utils/services-creator.js b/common/utils/services-creator.js index 323cba96fe..fa12eac123 100644 --- a/common/utils/services-creator.js +++ b/common/utils/services-creator.js @@ -1,4 +1,4 @@ -import{ Observable, Disposable } from 'rx'; +import { Observable, Disposable } from 'rx'; import Fetchr from 'fetchr'; import stampit from 'stampit'; @@ -9,7 +9,7 @@ function callbackObserver(observer) { } observer.onNext(res); - observer.onCompleted(); + return observer.onCompleted(); }; } diff --git a/package.json b/package.json index 62d49b137a..89809dfb83 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "emmet-codemirror": "^1.2.5", "errorhandler": "^1.4.2", "es6-map": "~0.1.1", - "eslint": "~1.10.2", + "eslint": "^2.2.0", "eslint-plugin-react": "^4.1.0", "express": "^4.13.3", "express-flash": "~0.0.2", @@ -61,7 +61,7 @@ "gulp": "^3.9.0", "gulp-babel": "^6.1.1", "gulp-concat": "^2.6.0", - "gulp-eslint": "^1.1.0", + "gulp-eslint": "^2.0.0", "gulp-jsonlint": "^1.1.0", "gulp-less": "^3.0.3", "gulp-nodemon": "^2.0.3", diff --git a/server/boot/a-extendUser.js b/server/boot/a-extendUser.js index d1f58cd319..e43cdeff2b 100644 --- a/server/boot/a-extendUser.js +++ b/server/boot/a-extendUser.js @@ -21,7 +21,7 @@ module.exports = function(app) { if (!id) { return next(); } - Observable.combineLatest( + return Observable.combineLatest( destroyAllRelated(id, UserIdentity), destroyAllRelated(id, UserCredential), function(identData, credData) { @@ -30,19 +30,20 @@ module.exports = function(app) { credData: credData }; } - ).subscribe( - function(data) { - debug('deleted', data); - }, - function(err) { - debug('error deleting user %s stuff', id, err); - next(err); - }, - function() { - debug('user stuff deleted for user %s', id); - next(); - } - ); + ) + .subscribe( + function(data) { + debug('deleted', data); + }, + function(err) { + debug('error deleting user %s stuff', id, err); + next(err); + }, + function() { + debug('user stuff deleted for user %s', id); + next(); + } + ); }); // set email varified false on user email signup @@ -82,15 +83,15 @@ module.exports = function(app) { }; debug('sending welcome email'); - Email.send(mailOptions, function(err) { + return Email.send(mailOptions, function(err) { if (err) { return next(err); } - req.logIn(user, function(err) { + return req.logIn(user, function(err) { if (err) { return next(err); } req.flash('success', { msg: [ "Welcome to Free Code Camp! We've created your account." ] }); - res.redirect(redirect); + return res.redirect(redirect); }); }); }); diff --git a/server/boot/a-extendUserIdent.js b/server/boot/a-extendUserIdent.js index 932965f3ff..3b8cbfea54 100644 --- a/server/boot/a-extendUserIdent.js +++ b/server/boot/a-extendUserIdent.js @@ -1,4 +1,4 @@ -import{ Observable } from 'rx'; +import { Observable } from 'rx'; import debugFactory from 'debug'; import dedent from 'dedent'; diff --git a/server/boot/challenge.js b/server/boot/challenge.js index 1555566dc0..b01b8f6d65 100644 --- a/server/boot/challenge.js +++ b/server/boot/challenge.js @@ -120,6 +120,7 @@ function shouldShowNew(element, block) { }, 0); return newCount / block.length * 100 === 100; } + return null; } // meant to be used with a filter method @@ -516,7 +517,7 @@ module.exports = function(app) { if (data.id) { res.cookie('currentChallengeId', data.id); } - res.render(view, data); + return res.render(view, data); }, next, function() {} @@ -585,7 +586,7 @@ module.exports = function(app) { alreadyCompleted }); } - res.sendStatus(200); + return res.sendStatus(200); } ); } @@ -652,7 +653,7 @@ module.exports = function(app) { user.progressTimestamps.length + 1 }); } - res.status(200).send(true); + return res.status(200).send(true); }) .subscribe(() => {}, next); } diff --git a/server/boot/commit.js b/server/boot/commit.js index 4df3af2f3d..5bb00e7ce7 100644 --- a/server/boot/commit.js +++ b/server/boot/commit.js @@ -217,7 +217,7 @@ export default function commit(app) { }) .subscribe( pledge => { - let msg = `You have successfully stopped your pledge.`; + let msg = 'You have successfully stopped your pledge.'; if (!pledge) { msg = `No pledge found for user ${user.username}.`; } diff --git a/server/boot/home.js b/server/boot/home.js index 8d8a2be441..a4e01ece1e 100644 --- a/server/boot/home.js +++ b/server/boot/home.js @@ -14,9 +14,9 @@ module.exports = function(app) { return next(); } req.user.picture = defaultProfileImage; - req.user.save(function(err) { + return req.user.save(function(err) { if (err) { return next(err); } - next(); + return next(); }); } @@ -24,6 +24,6 @@ module.exports = function(app) { if (req.user) { return res.redirect('/challenges/current-challenge'); } - res.render('home', { title: message }); + return res.render('home', { title: message }); } }; diff --git a/server/boot/randomAPIs.js b/server/boot/randomAPIs.js index ed8e886f08..62e4438fad 100644 --- a/server/boot/randomAPIs.js +++ b/server/boot/randomAPIs.js @@ -145,7 +145,7 @@ module.exports = function(app) { if (err) { return next(err); } - process.nextTick(function() { + return process.nextTick(function() { res.header('Content-Type', 'application/xml'); res.render('resources/sitemap', { appUrl: appUrl, @@ -227,14 +227,18 @@ module.exports = function(app) { } function confirmStickers(req, res) { - req.flash('success', { msg: 'Thank you for supporting our community! You should receive your stickers in the ' + - 'mail soon!'}); - res.redirect('/shop'); + req.flash('success', { + msg: 'Thank you for supporting our community! You should receive ' + + 'your stickers in the mail soon!' + }); + res.redirect('/shop'); } function cancelStickers(req, res) { - req.flash('info', { msg: 'You\'ve cancelled your purchase of our stickers. You can ' - + 'support our community any time by buying some.'}); + req.flash('info', { + msg: 'You\'ve cancelled your purchase of our stickers. You can ' + + 'support our community any time by buying some.' + }); res.redirect('/shop'); } function submitCatPhoto(req, res) { @@ -280,18 +284,14 @@ module.exports = function(app) { function unsubscribe(req, res, next) { User.findOne({ where: { email: req.params.email } }, function(err, user) { if (user) { - if (err) { - return next(err); - } + if (err) { return next(err); } user.sendMonthlyEmail = false; - user.save(function() { - if (err) { - return next(err); - } - res.redirect('/unsubscribed'); + return user.save(function() { + if (err) { return next(err); } + return res.redirect('/unsubscribed'); }); } else { - res.redirect('/unsubscribed'); + return res.redirect('/unsubscribed'); } }); } @@ -330,7 +330,7 @@ module.exports = function(app) { Object.keys(JSON.parse(pulls)).length : 'Can\'t connect to github'; - request( + return request( [ 'https://api.github.com/repos/freecodecamp/', 'freecodecamp/issues?client_id=', @@ -344,7 +344,7 @@ module.exports = function(app) { issues = ((pulls === parseInt(pulls, 10)) && issues) ? Object.keys(JSON.parse(issues)).length - pulls : "Can't connect to GitHub"; - res.send({ + return res.send({ issues: issues, pulls: pulls }); @@ -364,7 +364,7 @@ module.exports = function(app) { (JSON.parse(trello)) : 'Can\'t connect to to Trello'; - res.end(JSON.stringify(trello)); + return res.end(JSON.stringify(trello)); }); } @@ -379,7 +379,7 @@ module.exports = function(app) { blog = (status && status.statusCode === 200) ? JSON.parse(blog) : 'Can\'t connect to Blogger'; - res.end(JSON.stringify(blog)); + return res.end(JSON.stringify(blog)); } ); } diff --git a/server/boot/story.js b/server/boot/story.js index 0f43aef34e..8857290bc0 100755 --- a/server/boot/story.js +++ b/server/boot/story.js @@ -207,7 +207,7 @@ module.exports = function(app) { return upvote.upVotedByUsername === username; }); - res.render('stories/index', { + return res.render('stories/index', { title: story.headline, link: story.link, originalStoryLink: dashedName, @@ -357,7 +357,7 @@ module.exports = function(app) { url = 'http://' + url; } - findStory({ where: { link: url } }) + return findStory({ where: { link: url } }) .map(function(stories) { if (stories.length) { return { diff --git a/server/boot/user.js b/server/boot/user.js index 5eebc4dcb8..8af387b323 100644 --- a/server/boot/user.js +++ b/server/boot/user.js @@ -195,7 +195,7 @@ module.exports = function(app) { if (req.user) { return res.redirect('/'); } - res.render('account/signin', { + return res.render('account/signin', { title: 'Sign in to Free Code Camp using a Social Media Account' }); } @@ -209,7 +209,7 @@ module.exports = function(app) { if (req.user) { return res.redirect('/'); } - res.render('account/email-signin', { + return res.render('account/email-signin', { title: 'Sign in to Free Code Camp using your Email Address' }); } @@ -218,7 +218,7 @@ module.exports = function(app) { if (req.user) { return res.redirect('/'); } - res.render('account/email-signup', { + return res.render('account/email-signup', { title: 'Sign up for Free Code Camp using your Email Address' }); } @@ -387,7 +387,7 @@ module.exports = function(app) { req.flash('errors', { msg: `Looks like user ${username} is not ${certText[certType]}` }); - res.redirect('back'); + return res.redirect('back'); }, next ); @@ -406,7 +406,7 @@ module.exports = function(app) { section at the bottom of this page. ` }); - res.redirect('/' + req.user.username); + return res.redirect('/' + req.user.username); }); } req.user.isLocked = true; @@ -420,7 +420,7 @@ module.exports = function(app) { section at the bottom of this page. ` }); - res.redirect('/' + req.user.username); + return res.redirect('/' + req.user.username); }); } @@ -429,7 +429,7 @@ module.exports = function(app) { if (err) { return next(err); } req.logout(); req.flash('info', { msg: 'Your account has been deleted.' }); - res.redirect('/'); + return res.redirect('/'); }); } @@ -438,7 +438,7 @@ module.exports = function(app) { req.flash('errors', { msg: 'access token invalid' }); return res.render('account/forgot'); } - res.render('account/reset', { + return res.render('account/reset', { title: 'Reset your Password', accessToken: req.accessToken.id }); @@ -453,14 +453,14 @@ module.exports = function(app) { return res.redirect('back'); } - User.findById(req.accessToken.userId, function(err, user) { - if (err) { return next(err); } - user.updateAttribute('password', password, function(err) { + return User.findById(req.accessToken.userId, function(err, user) { if (err) { return next(err); } + return user.updateAttribute('password', password, function(err) { + if (err) { return next(err); } debug('password reset processed successfully'); req.flash('info', { msg: 'password reset processed successfully' }); - res.redirect('/'); + return res.redirect('/'); }); }); } @@ -469,7 +469,7 @@ module.exports = function(app) { if (req.isAuthenticated()) { return res.redirect('/'); } - res.render('account/forgot', { + return res.render('account/forgot', { title: 'Forgot Password' }); } @@ -483,7 +483,7 @@ module.exports = function(app) { return res.redirect('/forgot'); } - User.resetPassword({ + return User.resetPassword({ email: email }, function(err) { if (err) { @@ -496,7 +496,7 @@ module.exports = function(app) { email + ' with further instructions.' }); - res.render('account/forgot'); + return res.render('account/forgot'); }); } @@ -507,7 +507,7 @@ module.exports = function(app) { if (err) { return next(err); } req.flash('success', { msg: 'Thanks for voting!' }); - res.redirect('/map'); + return res.redirect('/map'); }); } else { req.flash('error', { msg: 'You must be signed in to vote.' }); @@ -522,7 +522,7 @@ module.exports = function(app) { if (err) { return next(err); } req.flash('success', { msg: 'Thanks for voting!' }); - res.redirect('/map'); + return res.redirect('/map'); }); } else { req.flash('error', {msg: 'You must be signed in to vote.'}); diff --git a/server/middlewares/add-return-to.js b/server/middlewares/add-return-to.js index 8a5b2b0dc0..0d25940e6b 100644 --- a/server/middlewares/add-return-to.js +++ b/server/middlewares/add-return-to.js @@ -36,8 +36,9 @@ export default function addReturnToUrl() { ) { return next(); } - req.session.returnTo = req.originalUrl === '/map-aside' - ? '/map' : req.originalUrl; - next(); + req.session.returnTo = req.originalUrl === '/map-aside' ? + '/map' : + req.originalUrl; + return next(); }; } diff --git a/server/middlewares/revision-helpers.js b/server/middlewares/revision-helpers.js index 3f84fe9c90..23012280c3 100644 --- a/server/middlewares/revision-helpers.js +++ b/server/middlewares/revision-helpers.js @@ -26,6 +26,6 @@ export default function({ globalPrepend = '' } = {}) { // in production we take use the initially loaded manifest // since this should not change in production res.locals.rev = boundRev; - next(); + return next(); }; } diff --git a/server/services/hikes.js b/server/services/hikes.js index 456cd80f4a..5a99bb5b3b 100644 --- a/server/services/hikes.js +++ b/server/services/hikes.js @@ -25,7 +25,7 @@ export default function hikesService(app) { if (err) { return cb(err); } - cb(null, hikes.map(hike => hike.toJSON())); + return cb(null, hikes.map(hike => hike.toJSON())); }); } }; diff --git a/server/services/job.js b/server/services/job.js index 94e7ee2190..ad0bd35cea 100644 --- a/server/services/job.js +++ b/server/services/job.js @@ -22,7 +22,7 @@ export default function getJobServices(app) { isApproved: false }); - Job.create(job, (err, savedJob) => { + return Job.create(job, (err, savedJob) => { cb(err, savedJob.toJSON()); }); }, @@ -33,7 +33,7 @@ export default function getJobServices(app) { .then(job => cb(null, job.toJSON())) .catch(cb); } - Job.find(whereFilt) + return Job.find(whereFilt) .then(jobs => cb(null, jobs.map(job => job.toJSON()))) .catch(cb); } diff --git a/server/utils/index.js b/server/utils/index.js index d830b55850..9efdc7ae9d 100644 --- a/server/utils/index.js +++ b/server/utils/index.js @@ -75,7 +75,7 @@ module.exports = { result.image = urlImage; result.description = description; - callback(null, result); + return callback(null, result); }); }, diff --git a/server/utils/rx.js b/server/utils/rx.js index 0900cc7bfc..891d24e9f3 100644 --- a/server/utils/rx.js +++ b/server/utils/rx.js @@ -10,13 +10,13 @@ export function saveInstance(instance) { observer.onNext(); return observer.onCompleted(); } - instance.save(function(err, savedInstance) { + return instance.save(function(err, savedInstance) { if (err) { return observer.onError(err); } debug('instance saved'); observer.onNext(savedInstance); - observer.onCompleted(); + return observer.onCompleted(); }); }); } From 5c59e7ea2db5b81361a6ced73ac8eaa4f9e32fef Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Wed, 2 Mar 2016 21:53:42 -0800 Subject: [PATCH 23/27] Fix validator only works with strings --- server/boot/challenge.js | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/server/boot/challenge.js b/server/boot/challenge.js index b01b8f6d65..d793b9fbfd 100644 --- a/server/boot/challenge.js +++ b/server/boot/challenge.js @@ -526,14 +526,12 @@ module.exports = function(app) { function completedChallenge(req, res, next) { req.checkBody('id', 'id must be a ObjectId').isMongoId(); - req.checkBody('name', 'name must be at least 3 characters') .isString() .isLength({ min: 3 }); - req.checkBody('challengeType', 'challengeType must be an integer') - .isNumber() - .isInt(); + .isNumber(); + const type = accepts(req).type('html', 'json', 'text'); const errors = req.validationErrors(true); @@ -598,8 +596,7 @@ module.exports = function(app) { .isString() .isLength({ min: 3 }); req.checkBody('challengeType', 'must be a number') - .isNumber() - .isInt(); + .isNumber(); req.checkBody('solution', 'solution must be a url').isURL(); const errors = req.validationErrors(true); From 71ebfade151908b07b43e9e8fdc512cd4c540397 Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Wed, 2 Mar 2016 21:54:31 -0800 Subject: [PATCH 24/27] Fix send correct datatypes on hike submit --- common/app/routes/Hikes/redux/answer-saga.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/app/routes/Hikes/redux/answer-saga.js b/common/app/routes/Hikes/redux/answer-saga.js index 1e773a4e28..1442778d1b 100644 --- a/common/app/routes/Hikes/redux/answer-saga.js +++ b/common/app/routes/Hikes/redux/answer-saga.js @@ -76,7 +76,7 @@ function handleAnswer(getState, dispatch, next, action) { let updateUser$; if (isSignedIn) { - const body = { id, name, challengeType }; + const body = { id, name, challengeType: +challengeType }; updateUser$ = postJSON$('/completed-challenge', body) // if post fails, will retry once .retry(3) From 2df87854c4fc49ff58ad1ffd074cae79b7e086d3 Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Wed, 2 Mar 2016 22:19:04 -0800 Subject: [PATCH 25/27] Fix unknown hike redirect to map --- client/sagas/hard-go-to-saga.js | 24 +++++++++++++++++++ client/sagas/index.js | 3 ++- common/app/redux/actions.js | 3 +++ common/app/redux/types.js | 4 +++- common/app/routes/Hikes/components/Hikes.jsx | 1 - .../app/routes/Hikes/components/Lecture.jsx | 19 +++++++++++---- 6 files changed, 47 insertions(+), 7 deletions(-) create mode 100644 client/sagas/hard-go-to-saga.js diff --git a/client/sagas/hard-go-to-saga.js b/client/sagas/hard-go-to-saga.js new file mode 100644 index 0000000000..2fa37cc6fc --- /dev/null +++ b/client/sagas/hard-go-to-saga.js @@ -0,0 +1,24 @@ +import { hardGoTo } from '../../common/app/redux/types'; + +const loc = typeof window !== 'undefined' ? + window.location : + {}; + +export default () => ({ dispatch }) => next => { + return function hardGoToSaga(action) { + const result = next(action); + if (action.type !== hardGoTo) { + return result; + } + + if (!loc.pathname) { + dispatch({ + type: 'app.error', + error: new Error('no location object found') + }); + } + + loc.pathname = action.payload || '/map'; + return null; + }; +}; diff --git a/client/sagas/index.js b/client/sagas/index.js index cb4e09a185..3a7fee2345 100644 --- a/client/sagas/index.js +++ b/client/sagas/index.js @@ -1,5 +1,6 @@ import errSaga from './err-saga'; import titleSaga from './title-saga'; import localStorageSaga from './local-storage-saga'; +import hardGoToSaga from './hard-go-to-saga'; -export default [errSaga, titleSaga, localStorageSaga]; +export default [ errSaga, titleSaga, localStorageSaga, hardGoToSaga ]; diff --git a/common/app/redux/actions.js b/common/app/redux/actions.js index 8bdfeeda6a..f04893fdcc 100644 --- a/common/app/redux/actions.js +++ b/common/app/redux/actions.js @@ -27,3 +27,6 @@ export const setUser = createAction(types.setUser); // updatePoints(points: Number) => Action export const updatePoints = createAction(types.updatePoints); + +// hardGoTo(path: String) => Action +export const hardGoTo = createAction(types.hardGoTo); diff --git a/common/app/redux/types.js b/common/app/redux/types.js index 16122457f5..0292822024 100644 --- a/common/app/redux/types.js +++ b/common/app/redux/types.js @@ -6,7 +6,9 @@ const types = [ 'makeToast', 'updatePoints', - 'handleError' + 'handleError', + // used to hit the server + 'hardGoTo' ]; export default types diff --git a/common/app/routes/Hikes/components/Hikes.jsx b/common/app/routes/Hikes/components/Hikes.jsx index f1595d61e0..da41f8e4ca 100644 --- a/common/app/routes/Hikes/components/Hikes.jsx +++ b/common/app/routes/Hikes/components/Hikes.jsx @@ -29,7 +29,6 @@ const mapStateToProps = createSelector( const fetchOptions = { fetchAction: 'fetchHikes', - isPrimed: ({ hikes }) => hikes && !!hikes.length, getActionArgs: ({ params: { dashedName } }) => [ dashedName ], shouldContainerFetch(props, nextProps) { diff --git a/common/app/routes/Hikes/components/Lecture.jsx b/common/app/routes/Hikes/components/Lecture.jsx index 2598766d69..8e8a40a698 100644 --- a/common/app/routes/Hikes/components/Lecture.jsx +++ b/common/app/routes/Hikes/components/Lecture.jsx @@ -5,6 +5,7 @@ import Vimeo from 'react-vimeo'; import { createSelector } from 'reselect'; import debug from 'debug'; +import { hardGoTo } from '../../../redux/actions'; import { toggleQuestionView } from '../redux/actions'; import { getCurrentHike } from '../redux/selectors'; @@ -33,11 +34,18 @@ export class Lecture extends React.Component { // actions toggleQuestionView: PropTypes.func, // ui - id: PropTypes.string, + id: PropTypes.number, description: PropTypes.array, - dashedName: PropTypes.string + dashedName: PropTypes.string, + hardGoTo: PropTypes.func }; + componentWillMount() { + if (!this.props.id) { + this.props.hardGoTo('/map'); + } + } + shouldComponentUpdate(nextProps) { const { props } = this; return nextProps.id !== props.id; @@ -70,7 +78,7 @@ export class Lecture extends React.Component { + videoId={ '' + id } />
@@ -89,4 +97,7 @@ export class Lecture extends React.Component { } } -export default connect(mapStateToProps, { toggleQuestionView })(Lecture); +export default connect( + mapStateToProps, + { hardGoTo, toggleQuestionView } +)(Lecture); From 4ef15109cd495561b5fbd4b151666ed64b5b615e Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Thu, 3 Mar 2016 18:56:18 -0800 Subject: [PATCH 26/27] Add createTypes function --- common/app/redux/types.js | 10 ++++------ common/app/routes/Hikes/redux/types.js | 11 ++++------- common/app/routes/Jobs/redux/types.js | 11 ++++------- common/app/utils/create-types.js | 10 ++++++++++ 4 files changed, 22 insertions(+), 20 deletions(-) create mode 100644 common/app/utils/create-types.js diff --git a/common/app/redux/types.js b/common/app/redux/types.js index 0292822024..ce9282a50b 100644 --- a/common/app/redux/types.js +++ b/common/app/redux/types.js @@ -1,4 +1,6 @@ -const types = [ +import createTypes from '../utils/create-types'; + +export default createTypes([ 'updateTitle', 'fetchUser', @@ -9,8 +11,4 @@ const types = [ 'handleError', // used to hit the server 'hardGoTo' -]; - -export default types - // make into object with signature { type: nameSpace[type] }; - .reduce((types, type) => ({ ...types, [type]: `app.${type}` }), {}); +], 'app'); diff --git a/common/app/routes/Hikes/redux/types.js b/common/app/routes/Hikes/redux/types.js index da3a622adc..26e547dce0 100644 --- a/common/app/routes/Hikes/redux/types.js +++ b/common/app/routes/Hikes/redux/types.js @@ -1,4 +1,6 @@ -const types = [ +import createTypes from '../../../utils/create-types'; + +export default createTypes([ 'fetchHikes', 'fetchHikesCompleted', 'resetHike', @@ -19,9 +21,4 @@ const types = [ 'hikeCompleted', 'goToNextHike' -]; - -export default types.reduce((types, type) => { - types[type] = `videos.${type}`; - return types; -}, {}); +], 'videos'); diff --git a/common/app/routes/Jobs/redux/types.js b/common/app/routes/Jobs/redux/types.js index aa6fe40a06..9a387f58c7 100644 --- a/common/app/routes/Jobs/redux/types.js +++ b/common/app/routes/Jobs/redux/types.js @@ -1,4 +1,6 @@ -const types = [ +import createTypes from '../../../utils/create-types'; + +export default createTypes([ 'fetchJobs', 'fetchJobsCompleted', @@ -17,9 +19,4 @@ const types = [ 'updatePromo', 'applyPromo', 'applyPromoCompleted' -]; - -export default types.reduce((types, type) => { - types[type] = `jobs.${type}`; - return types; -}, {}); +], 'jobs'); diff --git a/common/app/utils/create-types.js b/common/app/utils/create-types.js new file mode 100644 index 0000000000..31a93dba8a --- /dev/null +++ b/common/app/utils/create-types.js @@ -0,0 +1,10 @@ +// createTypes(types: String[], prefix: String) => Object +export default function createTypes(types = [], prefix = '') { + if (!Array.isArray(types) || typeof prefix !== 'string') { + return {}; + } + return types.reduce((types, type) => { + types[type] = prefix + '.' + type; + return types; + }, {}); +} From 29f740a8e4703651ac05b578467f4c2e7be3b17e Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Thu, 3 Mar 2016 19:08:28 -0800 Subject: [PATCH 27/27] Fix for non osx systems --- common/app/{provide-Store.js => provide-store.js} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename common/app/{provide-Store.js => provide-store.js} (100%) diff --git a/common/app/provide-Store.js b/common/app/provide-store.js similarity index 100% rename from common/app/provide-Store.js rename to common/app/provide-store.js