From c71c693d6db544de93fa0e5b1620cda0590ba954 Mon Sep 17 00:00:00 2001 From: Mrugesh Mohapatra Date: Thu, 3 Mar 2016 01:38:37 +0530 Subject: [PATCH 01/37] Fix tests for Change text with Click Events This commit adds checks to alow `.text` along with `.html`, as per the discussion in the issue. --- .../json-apis-and-ajax.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/seed/challenges/01-front-end-development-certification/json-apis-and-ajax.json b/seed/challenges/01-front-end-development-certification/json-apis-and-ajax.json index 5693968d916..12483ccd534 100644 --- a/seed/challenges/01-front-end-development-certification/json-apis-and-ajax.json +++ b/seed/challenges/01-front-end-development-certification/json-apis-and-ajax.json @@ -108,7 +108,7 @@ "" ], "tests": [ - "assert(code.match(/\\$\\s*?\\(\\s*?(?:'|\")\\.message(?:'|\")\\s*?\\)\\s*?\\.html\\s*?\\(\\s*?(?:'|\")Here\\sis\\sthe\\smessage(?:'|\")\\s*?\\);/gi), 'message: Clicking the \"Get Message\" button should give the element with the class message the text \"Here is the message\".');" + "assert(code.match(/\\$\\s*?\\(\\s*?(?:'|\")\\.message(?:'|\")\\s*?\\)\\s*?(\\.html|\\.text)\\s*?\\(\\s*?(?:'|\")Here\\sis\\sthe\\smessage(?:'|\")\\s*?\\);/gi), 'message: Clicking the \"Get Message\" button should give the element with the class message the text \"Here is the message\".');" ], "type": "waypoint", "challengeType": 0, From f6b4e6d3da60d8474c62207f565e0a7d9c9a1058 Mon Sep 17 00:00:00 2001 From: JoshFisk Date: Wed, 2 Mar 2016 21:16:56 -0800 Subject: [PATCH 02/37] Add test to "Falsy Bouncer". --- .../01-front-end-development-certification/basic-bonfires.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/seed/challenges/01-front-end-development-certification/basic-bonfires.json b/seed/challenges/01-front-end-development-certification/basic-bonfires.json index 5b20e296ecd..47cf69c07c6 100644 --- a/seed/challenges/01-front-end-development-certification/basic-bonfires.json +++ b/seed/challenges/01-front-end-development-certification/basic-bonfires.json @@ -585,7 +585,8 @@ "tests": [ "assert.deepEqual(bouncer([7, \"ate\", \"\", false, 9]), [7, \"ate\", 9], 'message: bouncer([7, \"ate\", \"\", false, 9]) should return [7, \"ate\", 9].');", "assert.deepEqual(bouncer([\"a\", \"b\", \"c\"]), [\"a\", \"b\", \"c\"], 'message: bouncer([\"a\", \"b\", \"c\"]) should return [\"a\", \"b\", \"c\"].');", - "assert.deepEqual(bouncer([false, null, 0, NaN, undefined, \"\"]), [], 'message: bouncer([false, null, 0, NaN, undefined, \"\"]) should return [].');" + "assert.deepEqual(bouncer([false, null, 0, NaN, undefined, \"\"]), [], 'message: bouncer([false, null, 0, NaN, undefined, \"\"]) should return [].');", + "assert.deepEqual(bouncer([1, null, NaN, 2, undefined]), [1, 2], 'message: bouncer([1, null, NaN, 2, undefined]) should return [1, 2].');" ], "type": "bonfire", "MDNlinks": [ From 8ef3fdb6a0ad2db5f2496de60ddd5162044f007e Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Wed, 27 Jan 2016 11:34:44 -0800 Subject: [PATCH 03/37] 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 0e95beca2c0..073f6010bcb 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 d681e4cfccd..00000000000 --- 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 f73576d3945..00000000000 --- 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 7f146352853..92d4d9c9721 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 00000000000..e69de29bb2d diff --git a/client/sagas/err-saga.js b/client/sagas/err-saga.js new file mode 100644 index 00000000000..72a80d4c5dc --- /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 00000000000..fc721934465 --- /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 00000000000..59f357f2741 --- /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 e3c3fa1f72c..0afb196a5be 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 82d5568c09c..00000000000 --- 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 00000000000..a8f04d0a403 --- /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 205cb26bcf4..922c05081b6 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 eb634a2f0d5..0032277c7ec 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 00000000000..27a6cd23b04 --- /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 00000000000..ee7478011e3 --- /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 49f7cb342d4..00000000000 --- 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 a07e138ae6c..00000000000 --- 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 99924acf83b..1a3fead824f 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 00000000000..e69de29bb2d diff --git a/common/app/provide-Store.js b/common/app/provide-Store.js new file mode 100644 index 00000000000..a569f64a5fe --- /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 00000000000..cdc221a97b8 --- /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 00000000000..4f01aea99c7 --- /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 00000000000..d0e8381658d --- /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 00000000000..1aaece38f7c --- /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 00000000000..16122457f5b --- /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 c02922da56f..00000000000 --- 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 85ebdb487fa..6f75ae7f9b4 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 ba5a3244476..17c027a4de2 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 09cea7a998c..1aac07f492a 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 0936f320ae2..00000000000 --- 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 fa8ba7863d8..b759608fa95 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 00000000000..a2aec2358ae --- /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 00000000000..0c4cd392113 --- /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 00000000000..07d482f3580 --- /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 00000000000..8d94299b341 --- /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 5395c23602a..281306feecf 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 00000000000..e6a49f09649 --- /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 00000000000..c96ae0e8e51 --- /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 00000000000..d03b33dc9aa --- /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 83aba5ee5b9..43f2dfb6ef3 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 00000000000..fc40bc384c3 --- /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 8673cb2d8ac..4ed6ed821de 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 00000000000..0ba3133ff3e --- /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 00000000000..ca1522ac0c6 --- /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 00000000000..b19b11bf556 --- /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 00000000000..c30c6827403 --- /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 00000000000..3f8ba3912f7 --- /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 a6c52410435..2d1c357c356 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 9a423bdd6b9..4dedb9a9400 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 6d45beb6c27..952e665fad3 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 665e23348cc..1a95784a532 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 00000000000..aa7e8bfe97b --- /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 470c7add7ec..86d1afc0985 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 8efd96aab25..15e4d5a2400 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 b212550a8a4..d1f58cd3198 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 e3d77118397..009e5853615 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 e4082e5f741..6775edd3a00 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 957483f2790..1555566dc06 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 8d5805ee2a2..4df3af2f3d5 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 8daa663b3a2..ed8e886f086 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 516ce9e2689..0f43aef34ed 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 bfb0ca6afa1..5eebc4dcb8f 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 bd4a6810e18..e672d857f3f 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 3fa4dc40c11..a2816e818ce 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 02ce7ab5dc4..0a9474c40ab 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 68086d25634..0900cc7bfcf 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 04/37] 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 0afb196a5be..2041bcf47d0 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 6f75ae7f9b4..55c3ac623fa 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 17c027a4de2..7c3aa04d4a3 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 1aac07f492a..688cdc4b08f 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 5d4fc98dfe4..81d77432c0f 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 1982391d928..7404abd5ed1 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 a2aec2358ae..51eabf90cab 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 0c4cd392113..c9c25008995 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 07d482f3580..5315a38ae85 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 e6a49f09649..d0a95d7e150 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 c96ae0e8e51..d04ab1c3590 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 ca1522ac0c6..d1b2512858c 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 e672d857f3f..456cd80f4a9 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 05/37] 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 2041bcf47d0..0afb196a5be 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 55c3ac623fa..a18c3e3181e 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 688cdc4b08f..d0546febf8a 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 7404abd5ed1..71d492923ba 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 51eabf90cab..85ca4e67696 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 d0a95d7e150..3cc84b0c38a 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 d04ab1c3590..d867430dadc 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 06/37] 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 cdc221a97b8..8bdfeeda6a0 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 1aaece38f7c..34520a75142 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 a18c3e3181e..5e147ea6e6c 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 7c3aa04d4a3..62b097eba4d 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 d0546febf8a..2598766d69e 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 81d77432c0f..391c68693b3 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 71d492923ba..bad9d098193 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 00000000000..f89651eadab --- /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 07/37] 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 59f357f2741..c4a2a09ecae 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 08/37] 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 bad9d098193..46011285a78 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 c9c25008995..450814adfc5 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 3cc84b0c38a..44565c0de12 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 d03b33dc9aa..e9fc5bd777e 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 09/37] 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 62b097eba4d..f1595d61e08 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 5315a38ae85..2c926fe8813 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 e9fc5bd777e..2a50e2fb1e0 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 10/37] 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 85ca4e67696..6df65f60cd3 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 450814adfc5..1e773a4e288 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 d867430dadc..2e514419269 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 11/37] 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 6df65f60cd3..9560ce43671 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 12/37] 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 ee7478011e3..82d51d95467 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 38f3a15c5b2..00000000000 --- 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 343e068f569..e05a8866946 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 00000000000..266d3d9812a --- /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 9704d26931e..a315227d70e 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 4edc4cd5ade..940937436f1 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 4f11a2371dc..8767960e381 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 804b34b6d81..a50eca27944 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 5ccee6e1311..84ca630b92a 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 00000000000..e69de29bb2d 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 00000000000..2712d2e47d0 --- /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 00000000000..e69de29bb2d diff --git a/common/app/routes/Jobs/redux/types.js b/common/app/routes/Jobs/redux/types.js new file mode 100644 index 00000000000..3e3ccc2b6c1 --- /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 13/37] 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 00000000000..7913dcf42fe --- /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 2e514419269..da3a622adce 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 a315227d70e..bea7d205b01 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 43f2dfb6ef3..1111057def3 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 a50eca27944..cfdec61ddbb 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 84ca630b92a..05e9320a457 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 9d8ee8c2dc3..b388bdc5771 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 e69de29bb2d..0212a2a3759 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 00000000000..e5b182baf5a --- /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 2712d2e47d0..e7fec1a4295 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 0936f320ae2..b0b05159f12 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 e69de29bb2d..2fa43101ee5 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 00000000000..0faf17d8236 --- /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 3e3ccc2b6c1..7417e734b6f 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 fc40bc384c3..cfd486242bf 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 14/37] 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 82d51d95467..2e9ac1dba9e 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 bea7d205b01..36fe14cbe44 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 1111057def3..3fbccf45475 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 cfdec61ddbb..e7eca1b92ab 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 00000000000..06c5ffa2d77 --- /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 d1b2512858c..194b28178ec 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 15/37] 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 36fe14cbe44..1991567fcd4 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 e7fec1a4295..676bd3c8e9a 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 2fa43101ee5..c5e366d4675 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 16/37] 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 1991567fcd4..f5c678ebbe1 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 940937436f1..daebd1d999a 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 05e9320a457..410c7cfcf67 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 17/37] 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 f5c678ebbe1..89d19a49312 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 194b28178ec..a9402f90d76 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 48da7f46054..78d7988885a 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 18/37] 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 a9402f90d76..9de356867a3 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 19/37] 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 89d19a49312..f12e0ff91fb 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 676bd3c8e9a..a959605fbfc 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 78d7988885a..e5fdf87392c 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 20/37] 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 3fbccf45475..c2ff2388ba0 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 b0b05159f12..e11361c7b2d 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 06c5ffa2d77..fb6ed8bc62f 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 7417e734b6f..befa6299c0a 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 aa7e8bfe97b..323cba96fe5 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 21/37] 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 0faf17d8236..34d59d57535 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 22/37] 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 fc721934465..cb4e09a1858 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 7913dcf42fe..ecf69bcf310 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 266d3d9812a..9524d442888 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 e7eca1b92ab..478b3cfea85 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 0212a2a3759..e3a6f7a6de7 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 e5b182baf5a..4268383e354 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 e11361c7b2d..fe3f432b3e8 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 c5e366d4675..e227f9c2545 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 34d59d57535..0bb3c2c5621 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 befa6299c0a..aa6fe40a064 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 23/37] 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 92d4d9c9721..0925a6c9485 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 27a6cd23b04..f07a830ab27 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 9524d442888..97b3db9e37e 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 478b3cfea85..7509408e503 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 15e4d5a2400..62d49b137ad 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 009e5853615..5d13e39b99f 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 e5fdf87392c..94e7ee2190e 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 24/37] 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 073f6010bcb..5e924f47edd 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 4cfe6cf833c..e7ff9375285 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 76d9305c6e3..b2a6b09a671 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 a9b58fb23cd..a1ab157a71d 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 15ee010f2d0..f70bc469bb0 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 7ae02eef75e..3276790483e 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 72a80d4c5dc..968aa2b9e00 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 46011285a78..5bf14a3e830 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 dc6c6e2cf34..5cb6c990acf 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 4ed6ed821de..00000000000 --- 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 9de356867a3..d4b3e5f1a95 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 b19b11bf556..70560a722cf 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 2d1c357c356..e57036a966e 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 952e665fad3..c7728840660 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 323cba96fe5..fa12eac123d 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 62d49b137ad..89809dfb835 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 d1f58cd3198..e43cdeff2b5 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 932965f3ffb..3b8cbfea54d 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 1555566dc06..b01b8f6d651 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 4df3af2f3d5..5bb00e7ce74 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 8d8a2be4417..a4e01ece1ea 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 ed8e886f086..62e4438fade 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 0f43aef34ed..8857290bc01 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 5eebc4dcb8f..8af387b323e 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 8a5b2b0dc0b..0d25940e6b2 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 3f84fe9c90e..23012280c3c 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 456cd80f4a9..5a99bb5b3b6 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 94e7ee2190e..ad0bd35cea8 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 d830b55850a..9efdc7ae9d8 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 0900cc7bfcf..891d24e9f36 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 25/37] 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 b01b8f6d651..d793b9fbfd8 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 26/37] 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 1e773a4e288..1442778d1b5 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 27/37] 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 00000000000..2fa37cc6fcf --- /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 cb4e09a1858..3a7fee23455 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 8bdfeeda6a0..f04893fdcc8 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 16122457f5b..02928220244 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 f1595d61e08..da41f8e4ca1 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 2598766d69e..8e8a40a6983 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 28/37] 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 02928220244..ce9282a50b6 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 da3a622adce..26e547dce06 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 aa6fe40a064..9a387f58c73 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 00000000000..31a93dba8ad --- /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 29/37] 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 From cc1aae68049a244c041bf3c643fc4e4dee95dbd2 Mon Sep 17 00:00:00 2001 From: Eric Leung Date: Thu, 3 Mar 2016 21:53:09 -0800 Subject: [PATCH 30/37] Add blockquote and code tags to US Phone Numbers --- .../advanced-bonfires.json | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/seed/challenges/01-front-end-development-certification/advanced-bonfires.json b/seed/challenges/01-front-end-development-certification/advanced-bonfires.json index 197d95af9d9..65fe4520420 100644 --- a/seed/challenges/01-front-end-development-certification/advanced-bonfires.json +++ b/seed/challenges/01-front-end-development-certification/advanced-bonfires.json @@ -8,15 +8,10 @@ "id": "aff0395860f5d3034dc0bfc9", "title": "Validate US Telephone Numbers", "description": [ - "Return true if the passed string is a valid US phone number", + "Return true if the passed string is a valid US phone number.", "The user may fill out the form field any way they choose as long as it is a valid US number. The following are examples of valid formats for US numbers (refer to the tests below for other variants):", - "555-555-5555", - "(555)555-5555", - "(555) 555-5555", - "555 555 5555", - "5555555555", - "1 555 555 5555", - "For this challenge you will be presented with a string such as 800-692-7753 or 8oo-six427676;laskdjf. Your job is to validate or reject the US phone number based on any combination of the formats provided above. The area code is required. If the country code is provided, you must confirm that the country code is 1. Return true if the string is a valid US phone number; otherwise false.", + "
555-555-5555\n(555)555-5555\n(555) 555-5555\n555 555 5555\n5555555555\n1 555 555 5555
", + "For this challenge you will be presented with a string such as 800-692-7753 or 8oo-six427676;laskdjf. Your job is to validate or reject the US phone number based on any combination of the formats provided above. The area code is required. If the country code is provided, you must confirm that the country code is 1. Return true if the string is a valid US phone number; otherwise return false.", "Remember to use Read-Search-Ask if you get stuck. Try to pair program. Write your own code." ], "challengeSeed": [ @@ -67,12 +62,7 @@ "descriptionEs": [ "Haz que la función devuelva true (verdadero) si el texto introducido es un número válido en los EEUU.", "El usuario debe llenar el campo del formulario de la forma que desee siempre y cuando sea un número válido en los EEUU. Los números mostrados a continuación tienen formatos válidos en los EEUU:", - "555-555-5555", - "(555)555-5555", - "(555) 555-5555", - "555 555 5555", - "5555555555", - "1 555 555 5555", + "
555-555-5555\n(555)555-5555\n(555) 555-5555\n555 555 5555\n5555555555\n1 555 555 5555
", "Para esta prueba se te presentará una cadena de texto como por ejemplo: 800-692-7753 o 8oo-six427676;laskdjf. Tu trabajo consiste en validar o rechazar el número telefónico tomando como base cualquier combinación de los formatos anteriormente presentados. El código de área es requrido. Si el código de país es provisto, debes confirmar que este es 1. La función debe devolver true si la cadena de texto es un número telefónico válido en los EEUU; de lo contrario, debe devolver false.", "Recuerda utilizar Read-Search-Ask si te sientes atascado. Intenta programar en pareja. Escribe tu propio código." ] From 580a57d551426e4f124b35fda7f741dfa677c422 Mon Sep 17 00:00:00 2001 From: Eric Leung Date: Thu, 3 Mar 2016 23:11:37 -0800 Subject: [PATCH 31/37] Remove spaces and add code tags to Dates Ranges --- .../advanced-bonfires.json | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/seed/challenges/01-front-end-development-certification/advanced-bonfires.json b/seed/challenges/01-front-end-development-certification/advanced-bonfires.json index 65fe4520420..352f4a6aa3d 100644 --- a/seed/challenges/01-front-end-development-certification/advanced-bonfires.json +++ b/seed/challenges/01-front-end-development-certification/advanced-bonfires.json @@ -260,19 +260,13 @@ "id": "a19f0fbe1872186acd434d5a", "title": "Friendly Date Ranges", "description": [ - "Convert a date range consisting of two dates formatted as YYYY-MM-DD into a more readable format.", - "", - "The friendly display should use month names instead of numbers and ordinal dates instead of cardinal (\"1st\" instead of \"1\").", - "", + "Convert a date range consisting of two dates formatted as YYYY-MM-DD into a more readable format.", + "The friendly display should use month names instead of numbers and ordinal dates instead of cardinal (1st instead of 1).", "Do not display information that is redundant or that can be inferred by the user: if the date range ends in less than a year from when it begins, do not display the ending year. If the range ends in the same month that it begins, do not display the ending year or month.", - "", "Additionally, if the date range begins in the current year and ends within one year, the year should not be displayed at the beginning of the friendly range.", - "", - "Examples: ", + "Examples:", "friendly([\"2016-07-01\", \"2016-07-04\"]) should return [\"July 1st\",\"4th\"]", - "", "friendly([\"2016-07-01\", \"2018-07-04\"]) should return [\"July 1st, 2016\", \"July 4th, 2018\"].", - "", "Remember to use Read-Search-Ask if you get stuck. Try to pair program. Write your own code." ], "challengeSeed": [ From d810ba621874aeb43e88c394c3705648807ae220 Mon Sep 17 00:00:00 2001 From: pomarbar Date: Mon, 29 Feb 2016 17:14:50 -0500 Subject: [PATCH 32/37] Translation to spanish of challenge Computer-security in Computer-basics.json --- .../04-video-challenges/computer-basics.json | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/seed/challenges/04-video-challenges/computer-basics.json b/seed/challenges/04-video-challenges/computer-basics.json index ed7b73d22d8..689bd0eb168 100644 --- a/seed/challenges/04-video-challenges/computer-basics.json +++ b/seed/challenges/04-video-challenges/computer-basics.json @@ -1110,7 +1110,37 @@ ] ], "type": "hike", - "challengeType": 6 + "challengeType": 6, + "nameEs": "Seguridad en los computadores", + "descriptionEs": [ + "Lo fundamental de la seguridad en los computadores y cómo proteger su información.", + "Echemos una mirada a la seguridad en los computadores.", + "De lo primero que vamos a hablar es de algo conocido como ataque diccionario que se dirige a sus claves.", + "Cuando tu creas una clave, algunos lugares de internet te exigen claves muy específicas y complejas, las cuales con frecuencia no son tan necesarias.", + "El tipo de ataque que ellos quieren evitar son los llamados ataques diccionario.", + "Los ataques diccionario están programados para probar todas las palabras de un diccionario o todas las claves mas usuales, para una gran cantidad de nombres de usuario, registradas en sus propias bases de datos. ", + "Su tu clave es Canguro, probablemente podrá ser descubierta ya que es una palabra simple.", + "Aunque algunas claves no son tan sencillas como la anterior, esto tampoco importa.", + "Ellos atacan tal cantidad de cuentas que solo necesitan unas pocas claves que sean sencillas.", + "Cundo se crea una clave, tú usas letras mayúsculas, minúsculas, números y símbolos, pero una de las formas más eficientes es seleccionar números y letras al azar (ejemplo: canguroSyCA67).", + "Por lo tanto mantente lejos de los números seguidos.", + "Suplantación es otra forma de ataque del cual debes estar pendiente. Son correos o páginas web que simulan sitios seguros y engañan a las personas para obtener su información personlal. Son sitios que no están conectados a donde dicen que estan.", + "En general hay dos formas de resguardarse de estos ataques: verificar la dirección web de la página y abrir la dirección de la página web, en otro navegador para poder comprobar si corresponde con el sitio del que dicen que procede.", + "Ahora hablemos sobre cifrado y HTTPS", + "Si te encuentras comprando en una página de internet de una tienda muy prestigiosa y necesitas dar los datos de tu tarjeta de crédito es muy razonable que sospeches que alguien puede estar viendo y copiando esta información.", + "Es imprescindible que te fijes que la dirección de esta página web, en tu navegador, comienza con letras verdes y con HTTPS en vez de comenzar con solo HTTP.", + "Esto significa que el sitio por el que estás enviando esta información, la envía en forma codificada", + "En general, asegúrate que la página sea HTTPS si necesita escribir información privada o importante.", + "Otras recomendaciones importantes: no uses la misma clave para diferentes cuentas importantes", + "Si el sitio que usas ha sido vulnerado, tu clave puede estar comprometida independientemente de su fortaleza.", + "Tampoco descargues archivos desconocidos.", + "Se precavido si no reconoces archivos de tipo .pdf, .txt, .jpg.", + "Algunos de estos archivos pueden ser muy potentes e incluso correr dentro de tu computador.", + "Por último, mantén tu software actualizado, en especial el software que interactúa con internet.", + "Una de las formas en que se puede comprometer tu información es cuando los 'los chicos malos' encuentran huecos en las versiones viejas de los programas", + "Estos defectos se corrigen en las versiones nuevas, por lo que evitarás problemas si mantienes el software actualizado.", + "Estas son algunas cosas muy básicas que debes saber acerca de cómo proteger la información de tu computador." + ] } ] } From f8ae1c6e93213f7c2921a57f27d2eef7eebbb103 Mon Sep 17 00:00:00 2001 From: Logan Tegman Date: Sat, 5 Mar 2016 08:41:28 -0800 Subject: [PATCH 33/37] Prevent eslint 2.3.0 from installing --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 89809dfb835..3566eb44eb9 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": "^2.2.0", + "eslint": "~2.2.0", "eslint-plugin-react": "^4.1.0", "express": "^4.13.3", "express-flash": "~0.0.2", From 6f267483f8b1c2e97f17b771143f6318b6ebe263 Mon Sep 17 00:00:00 2001 From: Hallaathrad Date: Sat, 5 Mar 2016 19:51:03 -0500 Subject: [PATCH 34/37] Fix for map's block overlapping Removed unnecessary offending `height:100%` from the JS on collapse/expand. --- client/main.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/main.js b/client/main.js index 3276790483e..7fa1b968c8e 100644 --- a/client/main.js +++ b/client/main.js @@ -298,12 +298,12 @@ $(document).ready(function() { } function expandBlock(item) { - $(item).addClass('in').css('height', '100%'); + $(item).addClass('in'); expandCaret(item); } function collapseBlock(item) { - $(item).removeClass('in').css('height', '100%'); + $(item).removeClass('in'); collapseCaret(item); } From 4514d5477867770c3ef3e8f011bb926eca100418 Mon Sep 17 00:00:00 2001 From: Eric Leung Date: Sun, 6 Mar 2016 12:22:14 -0800 Subject: [PATCH 35/37] Add code tags and fix wording in Pairwise --- .../advanced-bonfires.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/seed/challenges/01-front-end-development-certification/advanced-bonfires.json b/seed/challenges/01-front-end-development-certification/advanced-bonfires.json index 352f4a6aa3d..5fb09d24186 100644 --- a/seed/challenges/01-front-end-development-certification/advanced-bonfires.json +++ b/seed/challenges/01-front-end-development-certification/advanced-bonfires.json @@ -400,9 +400,9 @@ "id": "a3f503de51cfab748ff001aa", "title": "Pairwise", "description": [ - "Return the sum of all indices of elements of 'arr' that can be paired with one other element to form a sum that equals the value in the second argument 'arg'. If multiple sums are possible, return the smallest sum. Once an element has been used, it cannot be reused to pair with another.", - "For example, pairwise([1, 4, 2, 3, 0, 5], 7) should return 11 because 4, 2, 3 and 5 can be paired with each other to equal 7 and their indices (1, 2, 3, and 5) sum to 11.", - "pairwise([1, 3, 2, 4], 4) would only equal 1, because only the first two elements can be paired to equal 4, and the first element has an index of 0!", + "Return the sum of all element indices of array arr that can be paired with one other element to form a sum that equals the value in the second argument arg. If multiple sums are possible, return the smallest sum. Once an element has been used, it cannot be reused to pair with another.", + "For example, pairwise([1, 4, 2, 3, 0, 5], 7) should return 11 because 4, 2, 3 and 5 can be paired with each other to equal 7 and their indices (1, 2, 3, and 5) sum to 11.", + "pairwise([1, 3, 2, 4], 4) would only return 1, because only the first two elements can be paired to equal 4, and the first element has an index of 0!", "Remember to use Read-Search-Ask if you get stuck. Try to pair program. Write your own code." ], "challengeSeed": [ From d7f0a498755b526dec1cb3de3250168f656f6bb3 Mon Sep 17 00:00:00 2001 From: cuent Date: Sun, 6 Mar 2016 18:59:48 -0500 Subject: [PATCH 36/37] Translate to Spanish new challenges of Update Gear Up for Success and Basic Ziplines files. --- .../basic-ziplines.json | 44 +++++++++---------- .../gear-up-for-success.json | 32 +++++++------- 2 files changed, 38 insertions(+), 38 deletions(-) diff --git a/seed/challenges/01-front-end-development-certification/basic-ziplines.json b/seed/challenges/01-front-end-development-certification/basic-ziplines.json index 7d2b31f6bab..1061e5ff666 100644 --- a/seed/challenges/01-front-end-development-certification/basic-ziplines.json +++ b/seed/challenges/01-front-end-development-certification/basic-ziplines.json @@ -60,31 +60,31 @@ [ "http://i.imgur.com/WBetuBa.jpg", "Un programador frustado golpeando la pantalla de su computador.", - "Nuestros desafíos sobre algoritmos son difíciles. Algunos pueden requerir muchas horas para resolverse. Podrás frustarte, pero no te rindas.", + "Nuestros desafíos sobre algoritmos son difíciles. Algunos pueden requerir muchas horas para resolverse. Podrás frustarte, pero no te rindas. Se vuelve fácil con práctica.", "" ], [ "http://i.imgur.com/p2TpOQd.jpg", "Un tierno perro que salta sobre un obstáculo, pica el ojo y te apunta con su pata.", - "Cuando te atasques, usa la metodología Leer-Buscar-Preguntar. No te preocupes - ya lo has entendido.", + "Cuando te atasques, usa la metodología Leer-Buscar-Preguntar. No te preocupes - lo tienes resuelto.", "" ], [ "http://i.imgur.com/G1saeDt.gif", "Un gif que muestra cómo crear una cuenta en Codepen.", - "Para nuestros desafíos de interfaces, usaremos un editor muy famoso llamado Codepen, el cual es completamente basado en el navegador. Abre CodePen y pulsa en \"Sign up\" en la esquina superior derecha, luego ve hacia abajo donde se encuentra el plan gratuito (free plan) y pulsa en \"Sign up\". Da clic en el botón que dice \"Use info from GitHub\", luego agrega tu dirección de correo electrónico y crea una contraseña. Pulsa el botón que dice \"Sign up\". Luego, en la esquina superior derecha , da clic en \"New pen\".", + "Para nuestros desafíos de interfaces, usaremos un editor de código basado en el navegador que es muy famoso llamado Codepen. Pulsa en el botón de abajo \"Open link in new tab\" para abrir la página de registro de CodePen. Rellena el formulario y pulsa \"Sign up\".
Nota: Si ya tienes una cuenta de CodePen, puedes omitir este paso pulsando \"Open link in new tab\", cierra la nueva pestaña que se abre, entonces pulsa \"go to my next step\". Eliminamos nuestro botón \"skip step\" porque mucha gente solamente pulsa el botón sin realizar estos importantes pasos.
", "http://codepen.io/signup/free" ], [ "http://i.imgur.com/U4y9RJ1.gif", "Un gif que muestra que puedes escribir \"hello world\" en el editor, lo cual escribirá \"hello world\" en la ventana de vista previa. También puedes mover las ventanas para cambiar su tamaño, y cambiar su orientación.", - "En la ventana de HTML, crea un elemento h1 con el texto \"Hola mundo\". Puedes arrastrar los bordes de las ventanas para cambiar su tamaño. También puedes pulsar el botón de \"Change View\" para cambiar la orientación de las ventanas.", + "En la ventana de HTML, crea un elemento h1 con el texto \"Hola mundo\". Puedes arrastrar los bordes de las ventanas para cambiar su tamaño. También puedes pulsar el botón de \"Change View\" para cambiar la orientación de las ventanas.", "" ], [ "http://i.imgur.com/G9KFQDL.gif", "Un gif que muestra el proceso de agregar Bootstrap a tu proyecto.", - "Pulsa el engrane en la esquina superior izquierda de la ventana de CSS, luego ve hacia abajo hasta donde dice \"Quick add\" y elige Bootstrap. Ahora dale a tu elemento h1 la clase \"text-primary\" para cambiar su color y verificar que Bootstrap está activado.", + "Pulsa el engrane en la esquina superior izquierda de la ventana de CSS, luego ve hacia abajo hasta donde dice \"Quick add\" y elige Bootstrap. Ahora dale a tu elemento h1 la clase \"text-primary\" para cambiar su color y verificar que Bootstrap está activado.", "" ] ], @@ -112,13 +112,13 @@ "challengeType": 3, "nameEs": "Construye una página Tributo", "descriptionEs": [ - "Objetivo: Crea una aplicación con CodePen.io que funcionalmente sea similar a esta: http://codepen.io/FreeCodeCamp/full/wMQrXV", - "Regla #1: No veas el código del proyecto de ejemplo en CodePen. Encuentra la forma de hacerlo por tu cuenta.", - "Regla #2: Satisface las siguientes historias de usuario. Usa cualquier librería que necesites. Dale tu estilo personal.", - "Historia de usuario: Puedo ver una página tributo con una imagen y un texto.", - "Historia de usuario: Puedo pulsar un enlace que me llevará a un sitio web externo con mayor información sobre el tema.", + "Objetivo: Crea una aplicación con CodePen.io que funcionalmente sea similar a esta: http://codepen.io/FreeCodeCamp/full/wMQrXV", + "Regla #1: No veas el código del proyecto de ejemplo. Encuentra la forma de hacerlo por tu cuenta.", + "Regla #2: Satisface las siguientes historias de usuario. Usa cualquier librería que necesites. Dale tu estilo personal.", + "Historia de usuario: Puedo ver una página tributo con una imagen y texto.", + "Historia de usuario: Puedo pulsar en un enlace que me llevará a un sitio web externo con mayor información sobre el tema.", "Recuerda utilizar Leer-Buscar-Preguntar si te sientes atascado.", - "Cuando hayas terminado, pulsa el botón de \"I've completed this challenge\" e incluye un link a tu CodePen. ", + "Cuando hayas terminado, pulsa el botón \"I've completed this challenge\" e incluye un link a tu CodePen. ", "Puedes obtener retroalimentación sobre tu proyecto por parte de otros campistas, compartiéndolo en nuestra Sala de chat para revisión de código. También puedes compartirlo en Twitter y en el campamento de tu ciudad (en Facebook)." ], "isRequired": true @@ -167,18 +167,18 @@ ], "nameEs": "Construye una página web para tu portafolio", "descriptionEs": [ - "Objetivo: Crea una aplicación con CodePen.io cuya funcionalidad sea similar a la de esta: http://codepen.io/FreeCodeCamp/full/VemmoX/.", - "Regla #1: No veas el código del proyecto de ejemplo en CodePen. Encuentra la forma de hacerlo por tu cuenta.", - "Regla #2: Satisface las siguientes historias de usuario. Usa cualquier librería que necesites. Dale tu estilo personal.", - "Historia de usuario: Puedo acceder a todo el contenido de la página del portafolio con sólo desplazarme en la ventana.", - "Historia de usuario: Puedo pulsar diferentes botones que me llevarán a las páginas de las diferentes cuentas de redes sociales del creador del portafolio.", - "Historia de usuario: Puedo ver una imagen de los diferentes proyectos que el creador del portafolio ha construido (si no has construido ningún sitio web antes, usa plantillas.)", - "Historia de usuario opcional: Puedo navegar a las diferentes secciones de la página web pulsando botones de navegación.", - "No te preocupes si no tienes nada que mostrar en tu portafolio todavía - en los siguientes desafíos crearás varias apps en CodePen, así que puedes regresar luego para actualizar tu portafolio.", - "Hay varias buenas plantillas, pero para este desafío, tendrás que construir la página web de tu portafolio completamente por tu cuenta. Usar Bootstrap hará el trabajo mucho más fácil para ti.", + "Objetivo: Crea una aplicación con CodePen.io cuya funcionalidad sea similar a la de esta: http://codepen.io/FreeCodeCamp/full/VemmoX/.", + "Regla #1: No veas el código del proyecto de ejemplo. Encuentra la forma de hacerlo por tu cuenta.", + "Regla #2: Satisface las siguientes historias de usuario. Usa cualquier librería que necesites. Dale tu estilo personal.", + "Historia de usuario: Puedo acceder a todo el contenido de la página del portafolio con sólo desplazarme en la ventana.", + "Historia de usuario: Puedo pulsar diferentes botones que me llevarán a las páginas de las diferentes cuentas de redes sociales del creador del portafolio.", + "Historia de usuario: Puedo ver una imagenes en miniatura de los diferentes proyectos que el creador del portafolio ha construido (si no has construido ningún sitio web antes, usa marcadores de posición.)", + "Historia de usuario: Puedo navegar a las diferentes secciones de la página web pulsando botones de navegación.", + "No te preocupes si no tienes nada que mostrar en tu portafolio todavía - en los siguientes desafíos crearás varias aplicaciones en CodePen, así que puedes regresar luego para actualizar tu portafolio.", + "Hay varias plantillas buenas, pero para este desafío, tendrás que construir la página web de tu portafolio completamente por tu cuenta. Usar Bootstrap hará el trabajo mucho más fácil para ti.", "Ten en mente que CodePen.io ignora la función Window.open(), así que si quieres abrir alguna ventana usando jQuery, necesitarás utilizar como objetivo un elemento de ancla invisible como el siguiente: <a target='_blank'>.", - "Recuerda utilizar Read-Search-Ask si te sientes atascado.", - "Cuando hayas terminado, pulsa el botón de \"I've completed this challenge\" e incluye un link a tu CodePen. ", + "Recuerda utilizar Leer-Buscar-Preguntar si te sientes atascado.", + "Cuando hayas terminado, pulsa el botón \"I've completed this challenge\" e incluye un link a tu CodePen. ", "Puedes obtener retroalimentación sobre tu proyecto por parte de otros campistas, compartiéndolo en nuestra Sala de chat para revisión de código. También puedes compartirlo en Twitter y en el campamento de tu ciudad (en Facebook)." ], "isRequired": true, diff --git a/seed/challenges/01-front-end-development-certification/gear-up-for-success.json b/seed/challenges/01-front-end-development-certification/gear-up-for-success.json index 442db0188d7..cdf4b11f29c 100644 --- a/seed/challenges/01-front-end-development-certification/gear-up-for-success.json +++ b/seed/challenges/01-front-end-development-certification/gear-up-for-success.json @@ -22,7 +22,7 @@ "descriptionEs": [ [ "http://i.imgur.com/vJyiXzU.gif", - "Un gif mostrando como puedes pulsar el enlace que está más adelante y llenar todos los campos necesarios para agregar los estudios de Free Code Camp a tu perfil de LinkedIn", + "Un gif mostrando como puedes pulsar el enlace de abajo y llenar todos los campos necesarios para agregar los estudios de Free Code Camp a tu perfil de LinkedIn", "LinkedIn reconoce a Free Code Camp como una universidad. Puedes obtener acceso a nuestra larga red de alumnos agregando Free Code Camp a la sección de educación de tu LinkedIn. Define tu fecha de graduación para el siguiente año. En el campo \"Grado\", escribe \"Certificación de Desarrollo Web Full Stack\". En \"Campo de estudio\", escribe \"Ingeniería de Software\". Después pulsa \"Guardar Cambios\".", "https://www.linkedin.com/profile/edit-education?school=Free+Code+Camp" ] @@ -44,13 +44,13 @@ "releasedOn": "February 10, 2016", "type": "Waypoint", "challengeType": 7, - "nameEs": "Translate", + "nameEs": "Unete a nuestro Subreddit", "descriptionEs": [ [ "http://i.imgur.com/DYjJuCG.gif", - "", - "", - "" + "Un gif mostrando como puedes crear una cuenta de Reddit y unirte a Free Code Camp subreddit.", + "Nuestra comunidad tiene su propio subreddit en Reddit. Esta es una manera conveniente de hacer preguntas y compartir enlaces con toda nuestra comunidad. Si aún no dispones de una cuenta de Reddit, puedes crear una en unos segundos - ni siquiera necesitas una dirección de correo electrónico. A continuación, puedes pulsar el botón \"subscribe\" para unirte a nuestro subreddit. También puedes suscribirte a otros subreddits que estan listados en la barra lateral.", + "https://reddit.com/r/freecodecamp" ] ] }, @@ -76,19 +76,19 @@ "type": "Waypoint", "challengeType": 7, "releasedOn": "February 10, 2016", - "nameEs": "Translate", + "nameEs": "Lee noticias de codificación en nuestros canal de publicaciones Medium", "descriptionEs": [ [ "http://i.imgur.com/FxSOL4a.gif", - "", - "", - "" + "Un gif mostrando cómo crear una cuenta en Medium.", + "Nuestra comunidad tiene un canal de publicaciones Medium, donde escribimos un montón de artículos sobre desarrollo de software. Si aún no dispones de una cuenta Medium, puedes seguir el enlace y registrarte usando una red social o ingresando un correo electrónico (enviarán un correo electrónico que debes abrirlo para crear tu cuenta.) Selecciona un tema de interés, puedes continuar a través de los pasos.", + "https://www.medium.com" ], [ "http://i.imgur.com/zhhywSX.gif", - "", - "", - "" + "Un gif mostrando cómo puedes pulsar el botón \"follow\" para seguir las publicaciones de Free Code Camp.", + "Una vez que inicias sesión, puedes ir al canal de publicaciones de Free Code Camp Medium y pulsar \"follow\". Nuestros campistas publican varios artículos cada semana.", + "https://medium.freecodecamp.com" ] ] }, @@ -108,12 +108,12 @@ "type": "Waypoint", "challengeType": 7, "releasedOn": "February 10, 2016", - "nameEs": "Translate", + "nameEs": "Miranos programar en vivo por Twitch.tv", "descriptionEs": [ [ "http://i.imgur.com/8rtyRY1.gif", - "", - "", + "Un gif mostrando cómo resgistrarse en Twitch.tv y seguir nuestro canal.", + "Nuestros campistas programan en vivo con frecuencia en Twitch.tv, un sitio web popular de streaming. Puedes crear una cuenta en menos de un minuto, luego, sigue al canal de Free Code Camp. Cuando sigas al canal, verás la opción de recibir una notificación por correo electrónico cada vez que uno de nuestros campistas esté en vivo. Puedes unirte a docenas de otros campistas y verlos programar, e interactuar en una sala de chat. Esta es una manera divertida e informal de aprender observando a las personas a construir proyectos.", "https://twitch.tv/freecodecamp" ] ] @@ -138,7 +138,7 @@ [ "http://i.imgur.com/Og1ifsn.gif", "Un gif mostrando como te puedes comprometer con una meta para tus estudios de Free Code Camp y prometer una donación mensual a una organización sin fines de lucro para darte motivación externa de alcanzar esa meta.", - "Puedes poner una meta y prometer donar mensualmente a una organización sin fines de lucro hasta que alcances tu meta. Esto te dará motivación externa en tu aventura de aprender a programar, así como una oportunidad para ayudar inmediatamente a organizaciones sin fines de lucro. Elige tu meta, después elige tu donativo mensual. Cuando pulses en \"comprometerse\", la página de donación de la organización sin fines de lucro se abrirá en una nueva pestaña. Esto es completamente opcional, y puedes cambiar tu compromiso o detenerlo en cualquier momento.", + "Puedes poner una meta y prometer donar mensualmente a una organización sin fines de lucro hasta que alcances tu meta. Esto te dará motivación externa en tu aventura de aprender a programar, así como una oportunidad para ayudar inmediatamente a organizaciones sin fines de lucro. Elige tu meta, después elige tu donativo mensual. Cuando pulses \"commit\", la página de donación de la organización sin fines de lucro se abrirá en una nueva pestaña. Esto es completamente opcional, y puedes cambiar tu compromiso o detenerlo en cualquier momento.", "/comprometerse" ] ] From 9bd49122b50745534f1ef0b5e0bd02cfaece28ea Mon Sep 17 00:00:00 2001 From: regonn Date: Tue, 8 Mar 2016 07:29:12 +0900 Subject: [PATCH 37/37] Translate in Japanese Getting started / Create a GitHub Account and Join our Chat Rooms --- .../00-getting-started/getting-started.json | 58 ++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/seed/challenges/00-getting-started/getting-started.json b/seed/challenges/00-getting-started/getting-started.json index c63fe3dd2a7..dfceda0bace 100644 --- a/seed/challenges/00-getting-started/getting-started.json +++ b/seed/challenges/00-getting-started/getting-started.json @@ -135,7 +135,6 @@ "Para obtener nuestra certificación verificada de Desarrollo de Interfaces, construirás 10 proyectos usando HTML, CSS, jQuery y JavaScript.", "" ], - [ "http://i.imgur.com/Et3iD74.jpg", "Una imagen de nuestro Certificado de Visualización de Datos", @@ -414,6 +413,63 @@ "Vous pouvez également télécharger la salle de chat app pour votre ordinateur ou votre téléphone.", "https://gitter.im/apps" ] + ], + "nameJa": "Githubアカウントを作成し、チャットに参加しましょう", + "descriptionJa": [ + [ + "http://i.imgur.com/EAR7Lvh.jpg", + "Gitterチャットルームの一場面です。", + "コーディングを始める前に、Free Code Camp のチャットルームに参加してください。いつでも、雑談や質問ができたりペアプログラミングをするための仲間を見つけ流ことができます。最初に Github アカウントが必要です。", + "" + ], + [ + "http://i.imgur.com/n6GeSEm.gif", + "この gif は Github を開始するまでの流れを表しています。必要な欄に情報を入れて登録をしてください。そして Github からあなたのメールアドレス宛にメールが届きますのでアカウントを承認してください。", + "\"Open link in new tab\"をクリックして Github を開いてください。 必要な欄に情報を入力して GitHub アカウントを作ってください。実際に使われている email アドレスかを確認してください( GitHub にはこの情報が保存されます )。メールアドレス宛に GitHub からメールが来たことを確認してください。 メールにある\"verify email address\"をクリックして開いてください。
注意: もしすでに GitHub アカウントを持っていたら、あなたは \"Open link in new tab\" をクリックすることでこのステップを飛ばすことができます、新しく開かれたタブを閉じて \"go to my next step\" をクリックしてください。私たちはこの大事なステップが飛ばされてしまうのを防ぐために \"このステップを飛ばす\" ボタンは削除してあります。
", + "https://github.com/join" + ], + [ + "http://i.imgur.com/hFqAEr8.gif", + "この gif は Github の右上にあるプロフィール画像をクリックする方法です。あなたの写真をアップロードするか、自動で生成されるピクセルアートを利用してください。そして、残りの欄に情報を入力し submit ボタンを押してください。", + "GitHub の右上に表示されているピクセルアートをクリックしてください、そして settings を選んでください。あなたの画像をアップロードしてください。画像はあなたの顔が写っていると良いです。他のキャンパーズの仲間たちがチャットルームであなたを見かけるようになります。住んでいる場所や名前を登録することもできます", + "https://github.com/settings/profile" + ], + [ + "http://i.imgur.com/pYk0wOk.gif", + "この gif は GitHub のレポジトリへのスターをつける方法です。", + "オープンソースの Free Code Camp のレポジトリを開いてください。これは私たちボランティアチームの協力者が Free Code Camp で作っているものです。あなたは \"star\" を私たちのリポジトリに付けることができます。\"star を付けること\"は GitHub での \"いいね\" と一緒です。", + "https://github.com/freecodecamp/freecodecamp" + ], + [ + "http://i.imgur.com/OmRmLB4.gif", + "この git は私たちのチャットルームへのリンクをクリックして、\"sign in with GitHub\" ボタンをクリックしています。そして、テキストを入力してキャンパーズの仲間へメッセージを送る方法を表しています。", + "あなたは Github のアカウントを持っているので、私たちのチャットルームへ GitHub を利用してログインできます。\"Hellow world!\" と言って自己紹介をし、あなたがどうやって Free Code Camp を見つけたかや何故プログラミングを学びたいのかを私たちに話してください。", + "https://gitter.im/FreeCodeCamp/FreeCodeCamp" + ], + [ + "http://i.imgur.com/Ecs5XAd.gif", + "この gif は右上の settings ボタンを押すことで、通知の設定を変更する方法を表しています。", + "私たちのチャットルームはとても活発です。あなたは誰かがあなたに対してメンションを送った時にだけ通知してもらうように設定を変更した方が良いでしょう。", + "" + ], + [ + "http://i.imgur.com/T0bGJPe.gif", + "この gif はどうやって該当するユーザに向けて個人的なメーッセージを送れるようにするかを表しています。", + "私たちのチャットルームは全て公開されているので、もしあなたが個人的な情報(メールアドレスや電話番号)を共有したい場合には、プライベートメッセージを利用してください。", + "" + ], + [ + "http://i.imgur.com/vDTMJSh.gif", + "この gif はチャレンジとチャットルームへの行き来がタブを戻すことでできることを表しています。", + "私たちのチャレンジを通して作業をしている間はチャットルームを開いておくと良いでしょう。そうすることで、必要な時に助けを求めることができます。あなたは休憩をしているかのように他のキャンパーズと関わりを持てるでしょう。", + "" + ], + [ + "http://i.imgur.com/WvQvNGN.gif", + "この gif は、チャットルームアプリをあなたのコンピュータに直接インストールするためにダウンロードする方法を表しています。", + "チャットルームのアプリをスマホや自分のパソコンにダウンロードして使うことができます。", + "https://gitter.im/apps" + ] ] }, {