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

{ title }

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

{ title }

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

- { line } -

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

+ { line } +

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

{ title }

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

Question { number }

-

{ question }

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

Question { number }

+

{ question }

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

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

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

Promo Discount

- - -

-{ discountAmount }

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

Highlighting

- - -

+ 250

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

- One more step -

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

Job Posting

- - -

+ { price }

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

Total

- - -

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

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

Promo Discount

+ + +

-{ discountAmount }

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

Highlighting

+ + +

+ 250

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

+ One more step +

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

Job Posting

+ + +

+ { price }

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

Total

+ + +

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

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

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

+
+ + + +
+ + +
-

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

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

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

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

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

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

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

-

- { this.addLocation(locale) } -

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

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

+

+ { this.addLocation(locale) } +

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

First, select your ideal applicant:

+ return ( +
+ + +
+ + +
+

First, select your ideal applicant:

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

Tell us about the position

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

How should they apply?

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

Tell us about your organization

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

Make it stand out

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

Tell us about the position

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

How should they apply?

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

Tell us about your organization

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

Make it stand out

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

First, select your ideal applicant:

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

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

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

How should they apply?

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

Tell us about your organization

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

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

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

-

- { this.addLocation(locale) } -

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

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

+

+ { this.addLocation(locale) } +

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

${err}

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

' + data + '

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

' + data + '

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