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 (
-
-
-
- { this.props.children }
-
-
-
- );
- }
+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 (
+
+
+
+ { this.props.children }
+
+
+
+ );
+ }
+}
+
+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 (
-
-
-
-
-
-
- { 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 (
+
+
+
+
+
+
+ { 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) }
-
- this.handleFinish(hikesActions) }>
- Take me to the Questions
-
-
-
- );
- }
- })
+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) }
+
+
+ Take me to the Questions
+
+
+
+ );
+ }
+}
+
+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 }
-
-
-
-
-
- false
-
-
- true
-
-
-
-
- );
- }
- })
+ 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 }
+
+
+
+
+
+ false
+
+
+ true
+
+
+
+
+ );
+ }
+}
+
+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?
-
-
-
-
-
-
-
- {
- jobActions.applyCode({
- id,
- code: promoCode,
- type: isHighlighted ? 'isHighlighted' : null
- });
- }}>
- Apply 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() }
-
-
-
-
-
-
-
-
-
-
-
- );
- }
- })
-);
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?
+
+
+
+
+
+
+
+ {
+ const subscription = applyPromo({
+ id,
+ code: promoCode,
+ type: isHighlighted ? 'isHighlighted' : null
+ }).subscribe();
+ this._subscriptions.add(subscription);
+ }}>
+ Apply 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() }
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+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.
+
+
+
+
+
+
+ Post a job: $1,000
+
+
+
+
+
+
-
- Hire a JavaScript engineer who's experienced in HTML5,
- Node.js, MongoDB, and Agile Development.
-
-
-
-
- {
- appActions.goTo('/jobs/new');
- }}>
- Post a job: $1,000
-
-
-
-
-
-
-
-
-
-
-
-
- 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 (
-
-
-
-
+ );
+ }
+}
+
+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 (
-
-
-
-
-
-
-
- {
- jobActions.clearSavedForm();
- jobActions.saveJobToDb({
- goTo: '/jobs/new/check-out',
- job
- });
- }}>
-
- Looks great! Let's Check Out
-
- appActions.goBack() } >
- Head back and make edits
-
-
-
-
-
- );
+ if (!job || !job.position || !job.description) {
+ return ;
}
- })
-);
+
+ return (
+
+
+
+
+
+
+
+ this.handleJobSubmit() }>
+
+ Looks great! Let's Check Out
+
+
+ Head back and make edits
+
+
+
+
+
+ );
+ }
+}
+
+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();
});
});
}