diff --git a/.eslintrc b/.eslintrc index 0e95beca2c..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 ], @@ -232,7 +231,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/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/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..0925a6c948 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 { createLocation, createHistory } from 'history'; -import { hydrate } from 'thundercats'; -import { render$ } from 'thundercats-react'; +import { routeReducer as routing, syncHistory } from 'react-router-redux'; +import { createHistory } from 'history'; 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( +const appLocation = history.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/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/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..968aa2b9e0 --- /dev/null +++ b/client/sagas/err-saga.js @@ -0,0 +1,20 @@ +// () => +// (store: Store) => +// (next: (action: Action) => Object) => +// errSaga(action: Action) => Object|Void +export default () => ({ dispatch }) => next => { + return function errorSaga(action) { + const result = next(action); + if (!action.error) { return result; } + + console.error(action.error); + return dispatch({ + type: 'app.makeToast', + payload: { + type: 'error', + title: 'Oops, something went wrong', + message: 'Something went wrong, please try again later' + } + }); + }; +}; 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 new file mode 100644 index 0000000000..3a7fee2345 --- /dev/null +++ b/client/sagas/index.js @@ -0,0 +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, hardGoToSaga ]; diff --git a/client/sagas/local-storage-saga.js b/client/sagas/local-storage-saga.js new file mode 100644 index 0000000000..ecf69bcf31 --- /dev/null +++ b/client/sagas/local-storage-saga.js @@ -0,0 +1,69 @@ +import { + saveForm, + clearForm, + loadSavedForm +} from '../../common/app/routes/Jobs/redux/types'; + +import { + saveCompleted, + 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)); + next(action); + return dispatch(saveCompleted(form)); + } catch (error) { + return dispatch({ + type: 'app.handleError', + error + }); + } + } + + 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 (error) { + return dispatch({ + type: 'app.handleError', + error + }); + } + } + + return next(action); + }; +}; diff --git a/client/sagas/title-saga.js b/client/sagas/title-saga.js new file mode 100644 index 0000000000..c4a2a09eca --- /dev/null +++ b/client/sagas/title-saga.js @@ -0,0 +1,17 @@ +// (doc: Object) => +// () => +// (next: (action: Action) => Object) => +// titleSage(action: Action) => Object|Void +export default ({ doc }) => ({ getState }) => next => { + return function titleSage(action) { + // get next state + const result = next(action); + if (action.type !== 'app.updateTitle') { + return result; + } + const state = getState(); + const newTitle = state.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/Cat.js b/common/app/Cat.js deleted file mode 100644 index 8673cb2d8a..0000000000 --- a/common/app/Cat.js +++ /dev/null @@ -1,65 +0,0 @@ -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) { - - 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(); - }); - }); - } - } - }); - - 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/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..f07a830ab2 --- /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|String, +// 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({ + // redirect: LocationDescriptor, + // history: History, + // routes: Object + // }) => Observable + return createRouteProps({ routes, location, history }) + .map(([ redirect, props ]) => ({ + redirect, + props, + reducer, + store + })); +} diff --git a/common/app/create-reducer.js b/common/app/create-reducer.js new file mode 100644 index 0000000000..2e9ac1dba9 --- /dev/null +++ b/common/app/create-reducer.js @@ -0,0 +1,19 @@ +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'; +import { + reducer as jobsApp, + formNormalizer as jobsNormalizer +} from './routes/Jobs/redux'; + +export default function createReducer(sideReducers = {}) { + return combineReducers({ + ...sideReducers, + app, + hikesApp, + jobsApp, + form: formReducer.normalize(jobsNormalizer) + }); +} 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..f04893fdcc --- /dev/null +++ b/common/app/redux/actions.js @@ -0,0 +1,32 @@ +import { createAction } from 'redux-actions'; +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 => { + id += 1; + return { + ...toast, + id, + type: toast.type || 'info' + }; + } +); + +// 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); + +// hardGoTo(path: String) => Action +export const hardGoTo = createAction(types.hardGoTo); 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..34520a7514 --- /dev/null +++ b/common/app/redux/reducer.js @@ -0,0 +1,35 @@ +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 + }), + + [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..ce9282a50b --- /dev/null +++ b/common/app/redux/types.js @@ -0,0 +1,14 @@ +import createTypes from '../utils/create-types'; + +export default createTypes([ + 'updateTitle', + + 'fetchUser', + 'setUser', + + 'makeToast', + 'updatePoints', + 'handleError', + // used to hit the server + 'hardGoTo' +], 'app'); 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..5e147ea6e6 100644 --- a/common/app/routes/Hikes/components/Hike.jsx +++ b/common/app/routes/Hikes/components/Hike.jsx @@ -1,63 +1,76 @@ 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'; +import { getCurrentHike } from '../redux/selectors'; -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( + getCurrentHike, + state => state.hikesApp.shouldShowQuestions, + (currentHike, shouldShowQuestions) => ({ + title: currentHike ? currentHike.title : '', + shouldShowQuestions }) ); + +// export plain component for testing +export class Hike extends React.Component { + static displayName = 'Hike'; + + static propTypes = { + // actions + resetHike: PropTypes.func, + // ui + title: PropTypes.string, + params: PropTypes.object, + shouldShowQuestions: 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, + shouldShowQuestions + } = this.props; + + return ( + + +
+

{ title }

+
+
+
+
+ { this.renderBody(shouldShowQuestions) } +
+ + + ); + } +} + +// export redux aware component +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 ba5a324447..da41f8e4ca 100644 --- a/common/app/routes/Hikes/components/Hikes.jsx +++ b/common/app/routes/Hikes/components/Hikes.jsx @@ -1,74 +1,82 @@ 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 PureComponent from 'react-pure-render/component'; +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.entities, + state => state.hikesApp.hikes.results, + (hikesMap, hikesByDashedName)=> { + if (!hikesMap || !hikesByDashedName) { + 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: hikesByDashedName.map(dashedName => hikesMap[dashedName]) + }; + } ); + +const fetchOptions = { + fetchAction: 'fetchHikes', + isPrimed: ({ hikes }) => hikes && !!hikes.length, + getActionArgs: ({ params: { dashedName } }) => [ dashedName ], + shouldContainerFetch(props, nextProps) { + return props.params.dashedName !== nextProps.params.dashedName; + } +}; + +export class Hikes extends PureComponent { + static displayName = 'Hikes'; + + static propTypes = { + children: PropTypes.element, + hikes: PropTypes.array, + params: PropTypes.object, + updateTitle: PropTypes.func + }; + + componentWillMount() { + const { updateTitle } = this.props; + updateTitle('Hikes'); + } + + 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..8e8a40a698 100644 --- a/common/app/routes/Hikes/components/Lecture.jsx +++ b/common/app/routes/Hikes/components/Lecture.jsx @@ -1,95 +1,103 @@ 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'); +import { hardGoTo } from '../../../redux/actions'; +import { toggleQuestionView } from '../redux/actions'; +import { getCurrentHike } from '../redux/selectors'; -export default contain( - { - actions: ['hikesActions'], - store: 'appStore', - map(state) { - const { - currentHike: { - dashedName, - description, - challengeSeed: [id] = [0] - } = {} - } = state.hikesApp; +const log = debug('fcc:hikes'); - 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( + getCurrentHike, + (currentHike) => { + const { + dashedName, + description, + challengeSeed: [id] = [0] + } = currentHike; + return { + id, + dashedName, + description + }; + } ); + +export class Lecture extends React.Component { + static displayName = 'Lecture'; + + static propTypes = { + // actions + toggleQuestionView: PropTypes.func, + // ui + id: PropTypes.number, + description: PropTypes.array, + 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; + } + + handleError: log; + + renderTranscript(transcript, dashedName) { + return transcript.map((line, index) => ( +

+ { line } +

+ )); + } + + render() { + const { + id = '1', + description = [], + toggleQuestionView + } = this.props; + + const dashedName = 'foo'; + + return ( + + + + + +
+ { this.renderTranscript(description, dashedName) } +
+ +
+ + ); + } +} + +export default connect( + mapStateToProps, + { hardGoTo, toggleQuestionView } +)(Lecture); diff --git a/common/app/routes/Hikes/components/Map.jsx b/common/app/routes/Hikes/components/Map.jsx index 5d4fc98dfe..391c68693b 100644 --- a/common/app/routes/Hikes/components/Map.jsx +++ b/common/app/routes/Hikes/components/Map.jsx @@ -14,10 +14,10 @@ export default React.createClass({ hikes = [{}] } = this.props; - const vidElements = hikes.map(({ title, dashedName}) => { + 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..5bf14a3e83 100644 --- a/common/app/routes/Hikes/components/Questions.jsx +++ b/common/app/routes/Hikes/components/Questions.jsx @@ -1,177 +1,212 @@ 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 { CompositeDisposable } from 'rx'; +import { createSelector } from 'reselect'; + +import { + answerQuestion, + moveQuestion, + releaseQuestion, + grabQuestion +} from '../redux/actions'; +import { getCurrentHike } from '../redux/selectors'; const answerThreshold = 100; +const springProperties = { stiffness: 120, damping: 10 }; +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( + getCurrentHike, + state => state.hikesApp, + state => state.app.isSignedIn, + (currentHike, 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 - }, + const { + tests = [] + } = currentHike; - 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 { + tests, + currentQuestion, + isCorrect, + mouse, + delta, + isPressed, + shouldShakeQuestion, + isSignedIn + }; + } ); + +class Question extends React.Component { + constructor(...args) { + super(...args); + this._subscriptions = new CompositeDisposable(); + } + + 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 + }; + + componentWillUnmount() { + this._subscriptions.dispose(); + } + + handleMouseUp(e, answer, info) { + e.stopPropagation(); + if (!this.props.isPressed) { + return null; + } + + const { + releaseQuestion, + answerQuestion + } = this.props; + + releaseQuestion(); + const subscription = answerQuestion({ + e, + answer, + info, + threshold: answerThreshold + }) + .subscribe(); + + this._subscriptions.add(subscription); + return null; + } + + handleMouseMove(isPressed, { delta, moveQuestion }) { + if (!isPressed) { + return null; + } + return e => moveQuestion({ e, delta }); + } + + onAnswer(answer, userAnswer, info) { + const { isSignedIn, answerQuestion } = this.props; + const subscriptions = this._subscriptions; + return e => { + if (e && e.preventDefault) { + e.preventDefault(); + } + + const subscription = answerQuestion({ + answer, + userAnswer, + info, + isSignedIn + }) + .subscribe(); + + subscriptions.add(subscription); + }; + } + + 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: [xPosition], + 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/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..9560ce4367 --- /dev/null +++ b/common/app/routes/Hikes/redux/actions.js @@ -0,0 +1,57 @@ +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 resetHike = createAction(types.resetHike); + +export const toggleQuestionView = createAction(types.toggleQuestionView); + +export const grabQuestion = createAction(types.grabQuestion, 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.releaseQuestion); +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 answerQuestion = createAction(types.answerQuestion); + +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..1442778d1b --- /dev/null +++ b/common/app/routes/Hikes/redux/answer-saga.js @@ -0,0 +1,146 @@ +import { Observable } from 'rx'; +import { push } from 'react-router-redux'; + +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'; +import { getCurrentHike } from './selectors'; + +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); + } + + if (positionX >= threshold) { + finalAnswer = true; + } + + if (positionX <= -threshold) { + finalAnswer = false; + } + } else { + finalAnswer = userAnswer; + } + + // 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: +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 + })) + // end with action so we know it is ok to transition + .doOnCompleted(() => dispatch({ type: types.transitionHike })) + .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.transitionHike) { + const { hikesApp: { currentHike } } = getState(); + // if no next hike currentHike will equal '' which is falsy + if (currentHike) { + dispatch(push(`/videos/${currentHike}`)); + } else { + dispatch(push('/map')); + } + } + + return result; + }; +}; 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..2c926fe881 --- /dev/null +++ b/common/app/routes/Hikes/redux/fetch-hikes-saga.js @@ -0,0 +1,45 @@ +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 { findCurrentHike } from './utils'; + +// const log = debug('fcc:fetch-hikes-saga'); +const hike = new Schema('hike', { idAttribute: 'dashedName' }); + +export default ({ services }) => ({ dispatch }) => next => { + return function fetchHikesSaga(action) { + if (action.type !== types.fetchHikes) { + return next(action); + } + + 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 = findCurrentHike(hikes, dashedName); + + 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..44565c0de1 --- /dev/null +++ b/common/app/routes/Hikes/redux/reducer.js @@ -0,0 +1,99 @@ +import { handleActions } from 'redux-actions'; +import types from './types'; +import { findNextHikeName } from './utils'; + +const initialState = { + hikes: { + results: [], + entities: {} + }, + // ui + // hike dashedName + currentHike: '', + // 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.toggleQuestionView]: state => ({ + ...state, + shouldShowQuestions: !state.shouldShowQuestions, + 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, + shouldShowQuestions: false, + mouse: [0, 0], + delta: [0, 0] + }), + + [types.startShake]: state => ({ ...state, shouldShakeQuestion: true }), + [types.endShake]: state => ({ ...state, shouldShakeQuestion: 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: findNextHikeName(state.hikes, state.currentHike), + 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/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] || {}) +); diff --git a/common/app/routes/Hikes/redux/types.js b/common/app/routes/Hikes/redux/types.js new file mode 100644 index 0000000000..26e547dce0 --- /dev/null +++ b/common/app/routes/Hikes/redux/types.js @@ -0,0 +1,24 @@ +import createTypes from '../../../utils/create-types'; + +export default createTypes([ + 'fetchHikes', + 'fetchHikesCompleted', + 'resetHike', + + 'toggleQuestionView', + 'grabQuestion', + 'releaseQuestion', + 'moveQuestion', + + 'answerQuestion', + + 'startShake', + 'endShake', + + 'primeNextQuestion', + 'goToNextQuestion', + 'transitionHike', + + 'hikeCompleted', + 'goToNextHike' +], 'videos'); diff --git a/common/app/routes/Hikes/redux/utils.js b/common/app/routes/Hikes/redux/utils.js new file mode 100644 index 0000000000..2a50e2fb1e --- /dev/null +++ b/common/app/routes/Hikes/redux/utils.js @@ -0,0 +1,77 @@ +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]; +} + +// findNextHikeName( +// hikes: { results: String[] }, +// dashedName: String +// ) => String +export function findNextHikeName({ results }, dashedName) { + if (!dashedName) { + log('find next hike no id provided'); + return results[0]; + } + const currentIndex = _.findIndex( + results, + _dashedName => _dashedName === dashedName + ); + + if (currentIndex >= results.length) { + return ''; + } + return 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/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..97b3db9e37 --- /dev/null +++ b/common/app/routes/Jobs/components/JobTotal.jsx @@ -0,0 +1,306 @@ +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 = { + regular: 'Q8Z82ZLAX3Q8N', + highlighted: 'VC8QPSKCYMZLN' +}; + +const bindableActions = { + applyPromo, + clearPromo, + push, + updatePromo +}; + +const mapStateToProps = createSelector( + state => state.jobsApp.newJob, + 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 { + constructor(...args) { + super(...args); + this._subscriptions = new CompositeDisposable(); + } + + 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 + }; + + componentWillMount() { + if (!this.props.id) { + this.props.push('/jobs'); + } + + this.props.clearPromo(); + } + + componentWillUnmount() { + this._subscriptions.dispose(); + } + + 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, + applyPromo, + updatePromo + } = this.props; + + if (promoApplied) { + return ( +
+
+ + + { promoName } applied + + +
+ ); + } + + return ( +
+
+ + + Have a promo code? + + + + + + + + + + +
+ ); + } + + render() { + const { + id, + isHighlighted, + buttonId, + price, + discountAmount, + push + } = 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() } +
+ + +
setTimeout(push, 0, '/jobs') } + target='_blank'> + + + + +
+ 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..f12e0ff91f 100644 --- a/common/app/routes/Jobs/components/Jobs.jsx +++ b/common/app/routes/Jobs/components/Jobs.jsx @@ -1,132 +1,150 @@ import React, { cloneElement, PropTypes } from 'react'; -import { contain } from 'thundercats-react'; +import { compose } from 'redux'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { LinkContainer } from 'react-router-bootstrap'; + +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 mapStateToProps = createSelector( + state => state.jobsApp.jobs.entities, + state => state.jobsApp.jobs.results, + state => state.jobsApp, + (jobsMap, jobsById) => { + return { jobs: 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 +}; - renderList(handleJobClick, jobs) { - return ( - - ); - }, +const fetchOptions = { + fetchAction: 'fetchJobs', + isPrimed({ jobs }) { + return jobs.length > 1; + } +}; - 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() { + const { findJob } = this.props; + + return (id) => { + findJob(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(mapStateToProps, bindableActions), + contain(fetchOptions) +)(Jobs); diff --git a/common/app/routes/Jobs/components/List.jsx b/common/app/routes/Jobs/components/List.jsx index 4edc4cd5ad..daebd1d999 100644 --- a/common/app/routes/Jobs/components/List.jsx +++ b/common/app/routes/Jobs/components/List.jsx @@ -1,14 +1,16 @@ 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'; -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,31 +21,35 @@ 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 - }); + const className = classnames({ + 'jobs-list': true, + 'col-xs-12': true, + 'jobs-list-highlight': isHighlighted + }); - return ( + const to = `/jobs/${id}`; + + return ( + handleClick(id) }>

@@ -60,9 +66,10 @@ export default React.createClass({

- ); - }); - }, +
+ ); + }); + } render() { const { @@ -76,4 +83,4 @@ export default React.createClass({ ); } -}); +} diff --git a/common/app/routes/Jobs/components/NewJob.jsx b/common/app/routes/Jobs/components/NewJob.jsx index 83aba5ee5b..5cb6c990ac 100644 --- a/common/app/routes/Jobs/components/NewJob.jsx +++ b/common/app/routes/Jobs/components/NewJob.jsx @@ -1,17 +1,15 @@ import { helpers } from 'rx'; import React, { PropTypes } from 'react'; -import { History } from 'react-router'; -import { contain } from 'thundercats-react'; -import debugFactory from 'debug'; +import { push } from 'react-router-redux'; +import { reduxForm } from 'redux-form'; +// 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 +18,14 @@ import { Row } from 'react-bootstrap'; -import { - isAscii, - isEmail, - isURL -} from 'validator'; +import { saveForm, loadSavedForm } from '../redux/actions'; -const debug = debugFactory('freecc: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,196 +42,103 @@ 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: 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; } -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', - - 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 - }, - - mixins: [History], - - 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; +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 }), {}); +} - const { - jobActions, +function getBsStyle(field) { + if (field.pristine) { + return null; + } - // form values - position, - locale, - description, - email, - url, - logo, - company, - isFrontEndCert, - isBackEndCert, - isHighlighted, - isRemoteOk, - howToApply - } = this.props; + return field.error ? + 'error' : + 'success'; +} - // 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 - }; +export class NewJob extends React.Component { + static displayName = 'NewJob'; - const job = Object.keys(jobValues).reduce((accu, prop) => { - if (jobValues[prop]) { - accu[prop] = jobValues[prop]; - } - return accu; - }, {}); + static propTypes = { + fields: PropTypes.object, + handleSubmit: PropTypes.func, + loadSavedForm: PropTypes.func, + push: PropTypes.func, + saveForm: PropTypes.func + }; - job.postedOn = new Date(); - debug('job sanitized', job); - jobActions.saveForm(job); + componentDidMount() { + this.props.loadSavedForm(); + } - this.history.pushState(null, '/jobs/new/preview'); - }, + handleSubmit(job) { + this.props.saveForm(job); + this.props.push('/jobs/new/preview'); + } - componentDidMount() { - const { jobActions } = this.props; - jobActions.getSavedForm(); - }, + handleCertClick(name) { + const { fields } = this.props; + Object.keys(certTypes).forEach(certType => { + if (certType === name) { + return fields[certType].onChange(true); + } + return fields[certType].onChange(false); + }); + } - handleChange(name, { target: { value } }) { - const { jobActions: { handleForm } } = this.props; - handleForm({ [name]: value }); - }, - - handleCertClick(name) { - const { jobActions: { handleForm } } = this.props; - const otherButton = name === 'isFrontEndCert' ? - 'isBackEndCert' : - 'isFrontEndCert'; - - handleForm({ - [name]: true, - [otherButton]: false - }); - }, - - render() { - const { + render() { + const { + fields: { position, locale, description, @@ -261,235 +150,242 @@ export default contain({ isRemoteOk, howToApply, isFrontEndCert, - isBackEndCert, - jobActions: { handleForm } - } = this.props; + isBackEndCert + }, + handleSubmit + } = this.props; - const { handleChange } = this; - const labelClass = 'col-sm-offset-1 col-sm-2'; - const inputClass = 'col-sm-6'; + const { handleChange } = this; + const labelClass = 'col-sm-offset-1 col-sm-2'; + const inputClass = 'col-sm-6'; - return ( -
- - -
-
+ return ( +
+ + +
+ this.handleSubmit(data)) }> -
-

First, select your ideal applicant:

+
+

First, select your ideal applicant:

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

Tell us about the position

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

How should they apply?

+ +
+
+
+
+

Tell us about your organization

+
+ handleChange('company', e) } + type='text' + wrapperClassName={ inputClass } + { ...company } + /> + + + + +
+
+
+
+

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', '') - } /> - -
- - - - - -
- - -
- ); - } - }) -); +
+ + + +
+ + + + + + + +
+ + +
+ ); + } +} + +export default reduxForm( + { + form: 'NewJob', + fields, + validate: validateForm + }, + state => ({ initialValues: state.jobsApp.initialValues }), + { + loadSavedForm, + push, + saveForm + } +)(NewJob); 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..7509408e50 100644 --- a/common/app/routes/Jobs/components/Preview.jsx +++ b/common/app/routes/Jobs/components/Preview.jsx @@ -1,79 +1,94 @@ +import { CompositeDisposable } from 'rx'; import React, { PropTypes } from 'react'; import { Button, Row, Col } from 'react-bootstrap'; -import { contain } from 'thundercats-react'; +import { connect } from 'react-redux'; +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 { clearForm, saveJob } from '../redux/actions'; + +const mapStateToProps = state => ({ job: state.jobsApp.newJob }); + +const bindableActions = { + goBack, + push, + clearForm, + saveJob +}; + +export class JobPreview extends PureComponent { + constructor(...args) { + super(...args); + this._subscriptions = new CompositeDisposable(); + } + + static displayName = 'Preview'; + + static propTypes = { + job: PropTypes.object, + saveJob: PropTypes.func, + clearForm: PropTypes.func, + push: PropTypes.func + }; + + componentWillMount() { + 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 - }, + componentWillUnmount() { + this._subscriptions.dispose(); + } - componentDidMount() { - const { appActions, job } = this.props; - // redirect user in client - if (!job || !job.position || !job.description) { - appActions.goTo('/jobs/new'); - } - }, + handleJobSubmit() { + const { clearForm, saveJob, job } = this.props; + clearForm(); + const subscription = saveJob(job).subscribe(); + this._subscriptions.add(subscription); + } - render() { - const { appActions, job, jobActions } = this.props; + render() { + const { job, goBack } = this.props; - if (!job || !job.position || !job.description) { - return ; - } - - return ( -
- -
-
- - -
- - -
- -
-
- ); + if (!job || !job.position || !job.description) { + return ; } - }) -); + + 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..410c7cfcf6 100644 --- a/common/app/routes/Jobs/components/Show.jsx +++ b/common/app/routes/Jobs/components/Show.jsx @@ -1,6 +1,12 @@ import React, { PropTypes } from 'react'; -import { History } from 'react-router'; -import { contain } from 'thundercats-react'; +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'; + +import contain from '../../../utils/professor-x'; +import { fetchJobs } from '../redux/actions'; import ShowJob from './ShowJob.jsx'; import JobNotFound from './JobNotFound.jsx'; @@ -51,86 +57,90 @@ 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, + state => state.jobsApp.jobs.entities, + ({ username, isFrontEndCert, isBackEndCert }, currentJob, jobs) => ({ + username, + isFrontEndCert, + isBackEndCert, + job: jobs[currentJob] || {} }) ); + +const bindableActions = { + push, + fetchJobs +}; + +const fetchOptions = { + fetchAction: 'fetchJobs', + 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/flux/index.js b/common/app/routes/Jobs/flux/index.js deleted file mode 100644 index 0936f320ae..0000000000 --- a/common/app/routes/Jobs/flux/index.js +++ /dev/null @@ -1 +0,0 @@ -export default from './Actions'; 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 new file mode 100644 index 0000000000..e3a6f7a6de --- /dev/null +++ b/common/app/routes/Jobs/redux/actions.js @@ -0,0 +1,34 @@ +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); + +// saves to database +export const saveJob = createAction(types.saveJob); +// saves to localStorage +export const saveForm = createAction(types.saveForm); + +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 new file mode 100644 index 0000000000..4268383e35 --- /dev/null +++ b/common/app/routes/Jobs/redux/apply-promo-saga.js @@ -0,0 +1,39 @@ +import { Observable } from 'rx'; + +import { applyPromo } from './types'; +import { applyPromoCompleted } from './actions'; +import { postJSON$ } from '../../../../utils/ajax-stream'; + +export default () => ({ dispatch }) => next => { + return function applyPromoSaga(action) { + if (action.type !== applyPromo) { + return next(action); + } + + 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 applyPromoCompleted(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 new file mode 100644 index 0000000000..a959605fbf --- /dev/null +++ b/common/app/routes/Jobs/redux/fetch-jobs-saga.js @@ -0,0 +1,50 @@ +import { Observable } from 'rx'; +import { normalize, Schema, arrayOf } from 'normalizr'; + +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) { + if (action.type !== fetchJobs) { + return next(action); + } + + const { payload: id } = action; + const data = { service: 'jobs' }; + if (id) { + data.params = { id }; + } + + return services.readService$(data) + .map(jobs => { + if (!Array.isArray(jobs)) { + jobs = [jobs]; + } + + const { entities, result } = normalize( + { jobs }, + { jobs: arrayOf(job) } + ); + + + return fetchJobsCompleted( + result.jobs[0], + { + entities: entities.job, + 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 new file mode 100644 index 0000000000..fe3f432b3e --- /dev/null +++ b/common/app/routes/Jobs/redux/index.js @@ -0,0 +1,11 @@ +export actions from './actions'; +export reducer from './reducer'; +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, applyPromoSaga ]; 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..fb6ed8bc62 --- /dev/null +++ b/common/app/routes/Jobs/redux/jobs-form-normalizer.js @@ -0,0 +1,42 @@ +import normalizeUrl from 'normalize-url'; +import { + inHTMLData, + uriInSingleQuotedAttr +} from 'xss-filters'; + +const normalizeOptions = { + stripWWW: false +}; + +function ifDefinedNormalize(normalizer) { + return value => value ? normalizer(value) : value; +} + +function formatUrl(url) { + if ( + typeof url === 'string' && + url.length > 4 && + url.indexOf('.') !== -1 + ) { + // prevent trailing / from being stripped during typing + let lastChar = ''; + if (url.substring(url.length - 1) === '/') { + lastChar = '/'; + } + return normalizeUrl(url, normalizeOptions) + lastChar; + } + return url; +} + +export default { + NewJob: { + 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/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..e227f9c254 --- /dev/null +++ b/common/app/routes/Jobs/redux/reducer.js @@ -0,0 +1,85 @@ +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 = { + // used by NewJob form + initialValues: {}, + 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.fetchJobsCompleted]: (state, { payload: { jobs, currentJob } }) => ({ + ...state, + currentJob, + jobs + }), + [types.updatePromo]: (state, { payload }) => ({ + ...state, + promoCode: replace(payload) + }), + [types.saveCompleted]: (state, { payload: newJob }) => { + return { + ...state, + newJob + }; + }, + [types.loadSavedFormCompleted]: (state, { payload: initialValues }) => ({ + ...state, + initialValues + }), + [types.applyPromoCompleted]: (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..0bb3c2c562 --- /dev/null +++ b/common/app/routes/Jobs/redux/save-job-saga.js @@ -0,0 +1,32 @@ +import { push } from 'react-router-redux'; +import { Observable } from 'rx'; + +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 result; + } + const { payload: job } = action; + + return services.createService$({ + service: 'jobs', + params: { job } + }) + .retry(3) + .flatMap(job => Observable.of( + saveCompleted(job), + push('/jobs/new/check-out') + )) + .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 new file mode 100644 index 0000000000..9a387f58c7 --- /dev/null +++ b/common/app/routes/Jobs/redux/types.js @@ -0,0 +1,22 @@ +import createTypes from '../../../utils/create-types'; + +export default createTypes([ + 'fetchJobs', + 'fetchJobsCompleted', + + 'findJob', + 'saveJob', + 'saveForm', + + 'saveCompleted', + + 'clearForm', + + 'loadSavedForm', + 'loadSavedFormCompleted', + + 'clearPromo', + 'updatePromo', + 'applyPromo', + 'applyPromoCompleted' +], 'jobs'); diff --git a/common/app/sagas.js b/common/app/sagas.js new file mode 100644 index 0000000000..cfd486242b --- /dev/null +++ b/common/app/sagas.js @@ -0,0 +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, + ...jobsSagas +]; 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/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; + }, {}); +} diff --git a/common/app/utils/professor-x.js b/common/app/utils/professor-x.js new file mode 100644 index 0000000000..d4b3e5f1a9 --- /dev/null +++ b/common/app/utils/professor-x.js @@ -0,0 +1,192 @@ +import React, { PropTypes, createElement } from 'react'; +import { Observable, CompositeDisposable } from 'rx'; +import shouldComponentUpdate from 'react-pure-render/function'; +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 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 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) + ); + + return professor.fetchContext.push({ + name: options.fetchAction, + action, + actionArgs, + component: Component.displayName || 'Anon' + }); + } + + componentDidMount() { + if (isPrimed(this.props, this.context)) { + log('container is primed'); + return null; + } + if (!isActionable) { + log(`${Component.displayName} container is not actionable`); + return null; + } + const actionArgs = getActionArgs(this.props, this.context); + const fetch$ = action.apply(null, actionArgs); + if (__DEV__ && !Observable.isObservable(fetch$)) { + console.log(fetch$); + throw new Error( + `Action creator should return an Observable but got ${fetch$}. + Check the action creator for fetch action ${options.fetchAction}` + ); + } + + const subscription = fetch$.subscribe( + () => {}, + options.handleError + ); + return this.__subscriptions.add(subscription); + } + + componentWillReceiveProps(nextProps, nextContext) { + if ( + !isActionable || + !hasRefetcher || + !options.shouldRefetch( + this.props, + nextProps, + getChildContext(Component.contextTypes, this.context), + getChildContext(Component.contextTypes, nextContext) + ) + ) { + 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 = shouldComponentUpdate; + + 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..70560a722c --- /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..e57036a966 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') { @@ -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/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..c772884066 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({ @@ -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/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..fa12eac123 --- /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); + return 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(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..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", @@ -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", @@ -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-router": "^1.0.0", - "react-router-bootstrap": "https://github.com/FreeCodeCamp/react-router-bootstrap.git#freecodecamp", + "react-pure-render": "^1.0.2", + "react-redux": "^4.0.6", + "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", + "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..e43cdeff2b 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( @@ -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/a-react.js b/server/boot/a-react.js index e3d7711839..5d13e39b99 100644 --- a/server/boot/a-react.js +++ b/server/boot/a-react.js @@ -1,14 +1,13 @@ 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 { RouterContext } from 'react-router'; +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 +37,43 @@ export default function reactSubRouter(app) { app.use(router); function serveReactApp(req, res, next) { - const services = new Fetchr({ req }); - const location = createLocation(req.path); - - // returns a router wrapped app - app$({ location }) + const serviceOptions = { req }; + app$({ + location: req.path, + serviceOptions + }) // if react-router does not find a route send down the chain - .filter(function({ props }) { + .filter(({ redirect, props }) => { + if (!props && redirect) { + res.redirect(redirect.pathname + redirect.search); + } 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(RouterContext, 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..d793b9fbfd 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', @@ -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() {} @@ -525,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); @@ -585,7 +584,7 @@ module.exports = function(app) { alreadyCompleted }); } - res.sendStatus(200); + return res.sendStatus(200); } ); } @@ -597,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); @@ -652,7 +650,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 8d5805ee2a..5bb00e7ce7 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; @@ -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 8daa663b3a..62e4438fad 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'), @@ -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 516ce9e268..8857290bc0 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, @@ -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 bfb0ca6afa..8af387b323 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, @@ -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 bd4a6810e1..5a99bb5b3b 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); @@ -25,7 +25,7 @@ export default function hikesService(app) { if (err) { return cb(err); } - cb(null, hikes); + return cb(null, hikes.map(hike => hike.toJSON())); }); } }; diff --git a/server/services/job.js b/server/services/job.js index 48da7f4605..ad0bd35cea 100644 --- a/server/services/job.js +++ b/server/services/job.js @@ -22,18 +22,20 @@ export default function getJobServices(app) { isApproved: false }); - Job.create(job, (err, savedJob) => { - cb(err, savedJob); + return Job.create(job, (err, savedJob) => { + cb(err, savedJob.toJSON()); }); }, read(req, resource, params, config, cb) { const id = params ? params.id : null; 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); - }); + return Job.find(whereFilt) + .then(jobs => cb(null, jobs.map(job => job.toJSON()))) + .catch(cb); } }; } 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/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 68086d2563..891d24e9f3 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) { @@ -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(); }); }); }