diff --git a/.eslintrc b/.eslintrc
index 0e95beca2c..073f6010bc 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -232,7 +232,7 @@
"react/jsx-uses-vars": 1,
"react/no-did-mount-set-state": 2,
"react/no-did-update-set-state": 2,
- "react/no-multi-comp": 2,
+ "react/no-multi-comp": [2, { "ignoreStateless": true } ],
"react/prop-types": 2,
"react/react-in-jsx-scope": 1,
"react/self-closing-comp": 1,
diff --git a/client/err-saga.js b/client/err-saga.js
deleted file mode 100644
index d681e4cfcc..0000000000
--- a/client/err-saga.js
+++ /dev/null
@@ -1,9 +0,0 @@
-export default function toastSaga(err$, toast) {
- err$
- .doOnNext(() => toast({
- type: 'error',
- title: 'Oops, something went wrong',
- message: `Something went wrong, please try again later`
- }))
- .subscribe(err => console.error(err));
-}
diff --git a/client/history-saga.js b/client/history-saga.js
deleted file mode 100644
index f73576d394..0000000000
--- a/client/history-saga.js
+++ /dev/null
@@ -1,69 +0,0 @@
-import { Disposable, Observable } from 'rx';
-
-export function location$(history) {
- return Observable.create(function(observer) {
- const dispose = history.listen(function(location) {
- observer.onNext(location);
- });
-
- return Disposable.create(() => {
- dispose();
- });
- });
-}
-
-const emptyLocation = {
- pathname: '',
- search: '',
- hash: ''
-};
-
-let prevKey;
-let isSyncing = false;
-export default function historySaga(
- history,
- updateLocation,
- goTo,
- goBack,
- routerState$
-) {
- routerState$.subscribe(
- location => {
-
- if (!location) {
- return null;
- }
-
- // store location has changed, update history
- if (!location.key || location.key !== prevKey) {
- isSyncing = true;
- history.transitionTo({ ...emptyLocation, ...location });
- isSyncing = false;
- }
- }
- );
-
- location$(history)
- .doOnNext(location => {
- prevKey = location.key;
-
- if (isSyncing) {
- return null;
- }
-
- return updateLocation(location);
- })
- .subscribe(() => {});
-
- goTo
- .doOnNext((route = '/') => {
- history.push(route);
- })
- .subscribe(() => {});
-
- goBack
- .doOnNext(() => {
- history.goBack();
- })
- .subscribe(() => {});
-}
diff --git a/client/index.js b/client/index.js
index 7f14635285..92d4d9c972 100644
--- a/client/index.js
+++ b/client/index.js
@@ -1,99 +1,71 @@
-import unused from './es6-shims'; // eslint-disable-line
+import './es6-shims';
import Rx from 'rx';
import React from 'react';
-import Fetchr from 'fetchr';
-import debugFactory from 'debug';
+import debug from 'debug';
import { Router } from 'react-router';
+import { routeReducer as routing, syncHistory } from 'react-router-redux';
import { createLocation, createHistory } from 'history';
-import { hydrate } from 'thundercats';
-import { render$ } from 'thundercats-react';
import app$ from '../common/app';
-import historySaga from './history-saga';
-import errSaga from './err-saga';
+import provideStore from '../common/app/provide-store';
-const debug = debugFactory('fcc:client');
+// client specific sagas
+import sagas from './sagas';
+
+// render to observable
+import render from '../common/app/utils/render';
+
+const log = debug('fcc:client');
const DOMContianer = document.getElementById('fcc');
-const catState = window.__fcc__.data || {};
-const services = new Fetchr({
- xhrPath: '/services'
-});
+const initialState = window.__fcc__.data;
+
+const serviceOptions = { xhrPath: '/services' };
Rx.config.longStackSupport = !!debug.enabled;
const history = createHistory();
const appLocation = createLocation(
location.pathname + location.search
);
+const routingMiddleware = syncHistory(history);
+
+const devTools = window.devToolsExtension ? window.devToolsExtension() : f => f;
+const shouldRouterListenForReplays = !!window.devToolsExtension;
+
+const clientSagaOptions = { doc: document };
// returns an observable
-app$({ history, location: appLocation })
- .flatMap(
- ({ AppCat }) => {
- // instantiate the cat with service
- const appCat = AppCat(null, services, history);
- // hydrate the stores
- return hydrate(appCat, catState).map(() => appCat);
- },
- // not using nextLocation at the moment but will be used for
- // redirects in the future
- ({ nextLocation, props }, appCat) => ({ nextLocation, props, appCat })
- )
- .doOnNext(({ appCat }) => {
- const appStore$ = appCat.getStore('appStore');
+app$({
+ location: appLocation,
+ history,
+ serviceOptions,
+ initialState,
+ middlewares: [
+ routingMiddleware,
+ ...sagas.map(saga => saga(clientSagaOptions))
+ ],
+ reducers: { routing },
+ enhancers: [ devTools ]
+})
+ .flatMap(({ props, store }) => {
- const {
- toast,
- updateLocation,
- goTo,
- goBack
- } = appCat.getActions('appActions');
-
-
- const routerState$ = appStore$
- .map(({ location }) => location)
- .filter(location => !!location);
-
- // set page title
- appStore$
- .pluck('title')
- .distinctUntilChanged()
- .doOnNext(title => document.title = title)
- .subscribe(() => {});
-
- historySaga(
- history,
- updateLocation,
- goTo,
- goBack,
- routerState$
- );
-
- const err$ = appStore$
- .pluck('err')
- .filter(err => !!err)
- .distinctUntilChanged();
-
- errSaga(err$, toast);
- })
- // allow store subscribe to subscribe to actions
- .delay(10)
- .flatMap(({ props, appCat }) => {
+ // because of weirdness in react-routers match function
+ // we replace the wrapped returned in props with the first one
+ // we passed in. This might be fixed in react-router 2.0
props.history = history;
- return render$(
- appCat,
- React.createElement(Router, props),
+ if (shouldRouterListenForReplays && store) {
+ log('routing middleware listening for replays');
+ routingMiddleware.listenForReplays(store);
+ }
+
+ log('rendering');
+ return render(
+ provideStore(React.createElement(Router, props), store),
DOMContianer
);
})
.subscribe(
- () => {
- debug('react rendered');
- },
- err => {
- throw err;
- },
- () => {
- debug('react closed subscription');
- }
+ () => debug('react rendered'),
+ err => { throw err; },
+ () => debug('react closed subscription')
);
diff --git a/client/sagas/README.md b/client/sagas/README.md
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/client/sagas/err-saga.js b/client/sagas/err-saga.js
new file mode 100644
index 0000000000..72a80d4c5d
--- /dev/null
+++ b/client/sagas/err-saga.js
@@ -0,0 +1,19 @@
+// () =>
+// (store: Store) =>
+// (next: (action: Action) => Object) =>
+// errSaga(action: Action) => Object|Void
+export default () => ({ dispatch }) => next => {
+ return function errorSaga(action) {
+ if (!action.error) { return next(action); }
+
+ console.error(action.error);
+ dispatch({
+ type: 'app.makeToast',
+ payload: {
+ type: 'error',
+ title: 'Oops, something went wrong',
+ message: `Something went wrong, please try again later`
+ }
+ });
+ };
+};
diff --git a/client/sagas/index.js b/client/sagas/index.js
new file mode 100644
index 0000000000..fc72193446
--- /dev/null
+++ b/client/sagas/index.js
@@ -0,0 +1,4 @@
+import errSaga from './err-saga';
+import titleSaga from './title-saga';
+
+export default [errSaga, titleSaga];
diff --git a/client/sagas/title-saga.js b/client/sagas/title-saga.js
new file mode 100644
index 0000000000..59f357f274
--- /dev/null
+++ b/client/sagas/title-saga.js
@@ -0,0 +1,16 @@
+// (doc: Object) =>
+// () =>
+// (next: (action: Action) => Object) =>
+// titleSage(action: Action) => Object|Void
+export default (doc) => () => next => {
+ return function titleSage(action) {
+ // get next state
+ const result = next(action);
+ if (action !== 'updateTitle') {
+ return result;
+ }
+ const newTitle = result.app.title;
+ doc.title = newTitle;
+ return result;
+ };
+};
diff --git a/common/app/App.jsx b/common/app/App.jsx
index e3c3fa1f72..0afb196a5b 100644
--- a/common/app/App.jsx
+++ b/common/app/App.jsx
@@ -1,81 +1,89 @@
import React, { PropTypes } from 'react';
import { Row } from 'react-bootstrap';
import { ToastMessage, ToastContainer } from 'react-toastr';
-import { contain } from 'thundercats-react';
+import { compose } from 'redux';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+
+import { fetchUser } from './redux/actions';
+import contain from './utils/professor-x';
import Nav from './components/Nav';
const toastMessageFactory = React.createFactory(ToastMessage.animation);
-export default contain(
- {
- actions: ['appActions'],
- store: 'appStore',
- fetchAction: 'appActions.getUser',
- isPrimed({ username }) {
- return !!username;
- },
- map({
- username,
- points,
- picture,
- toast
- }) {
- return {
- username,
- points,
- picture,
- toast
- };
- },
- getPayload(props) {
- return {
- isPrimed: !!props.username
- };
- }
- },
- React.createClass({
- displayName: 'FreeCodeCamp',
-
- propTypes: {
- appActions: PropTypes.object,
- children: PropTypes.node,
- username: PropTypes.string,
- points: PropTypes.number,
- picture: PropTypes.string,
- toast: PropTypes.object
- },
-
- componentWillReceiveProps({ toast: nextToast = {} }) {
- const { toast = {} } = this.props;
- if (toast.id !== nextToast.id) {
- this.refs.toaster[nextToast.type || 'success'](
- nextToast.message,
- nextToast.title,
- {
- closeButton: true,
- timeOut: 10000
- }
- );
- }
- },
-
- render() {
- const { username, points, picture } = this.props;
- const navProps = { username, points, picture };
- return (
-
-
-
- { 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/app-stream.jsx b/common/app/app-stream.jsx
deleted file mode 100644
index 82d5568c09..0000000000
--- a/common/app/app-stream.jsx
+++ /dev/null
@@ -1,17 +0,0 @@
-import Rx from 'rx';
-import { match } from 'react-router';
-import App from './App.jsx';
-import AppCat from './Cat';
-
-import childRoutes from './routes';
-
-const route$ = Rx.Observable.fromNodeCallback(match);
-
-const routes = Object.assign({ components: App }, childRoutes);
-
-export default function app$({ location, history }) {
- return route$({ routes, location, history })
- .map(([nextLocation, props]) => {
- return { nextLocation, props, AppCat };
- });
-}
diff --git a/common/app/components/Footer/README.md b/common/app/components/Footer/README.md
new file mode 100644
index 0000000000..a8f04d0a40
--- /dev/null
+++ b/common/app/components/Footer/README.md
@@ -0,0 +1 @@
+Currently not used
diff --git a/common/app/components/Nav/Nav.jsx b/common/app/components/Nav/Nav.jsx
index 205cb26bcf..922c05081b 100644
--- a/common/app/components/Nav/Nav.jsx
+++ b/common/app/components/Nav/Nav.jsx
@@ -23,20 +23,20 @@ const logoElement = (
);
const toggleButtonChild = (
-
- Menu
-
+
+ Menu
+
);
-export default React.createClass({
- displayName: 'Nav',
+export default class extends React.Component {
+ static displayName = 'Nav';
- propTypes: {
+ static propTypes = {
points: PropTypes.number,
picture: PropTypes.string,
signedIn: PropTypes.bool,
username: PropTypes.string
- },
+ };
renderLinks() {
return navLinks.map(({ content, link, react, target }, index) => {
@@ -63,7 +63,7 @@ export default React.createClass({
);
});
- },
+ }
renderPoints(username, points) {
if (!username) {
@@ -76,7 +76,7 @@ export default React.createClass({
[ { points } ]
);
- },
+ }
renderSignin(username, picture) {
if (username) {
@@ -100,7 +100,7 @@ export default React.createClass({
);
}
- },
+ }
render() {
const { username, points, picture } = this.props;
@@ -124,4 +124,4 @@ export default React.createClass({
);
}
-});
+}
diff --git a/common/app/components/NotFound/index.jsx b/common/app/components/NotFound/index.jsx
index eb634a2f0d..0032277c7e 100644
--- a/common/app/components/NotFound/index.jsx
+++ b/common/app/components/NotFound/index.jsx
@@ -6,17 +6,21 @@ function goToServer(path) {
win.location = '/' + path;
}
-export default React.createClass({
- displayName: 'NotFound',
- propTypes: {
+export default class extends React.Component {
+ static displayName = 'NotFound';
+
+ static propTypes = {
params: PropTypes.object
- },
+ };
+
componentWillMount() {
goToServer(this.props.params.splat);
- },
+ }
+
componentDidMount() {
- },
+ }
+
render() {
return ;
}
-});
+}
diff --git a/common/app/create-app.jsx b/common/app/create-app.jsx
new file mode 100644
index 0000000000..27a6cd23b0
--- /dev/null
+++ b/common/app/create-app.jsx
@@ -0,0 +1,79 @@
+import { Observable } from 'rx';
+import { match } from 'react-router';
+import { compose, createStore, applyMiddleware } from 'redux';
+
+// main app
+import App from './App.jsx';
+// app routes
+import childRoutes from './routes';
+
+// redux
+import createReducer from './create-reducer';
+import middlewares from './middlewares';
+import sagas from './sagas';
+
+// general utils
+import servicesCreator from '../utils/services-creator';
+
+const createRouteProps = Observable.fromNodeCallback(match);
+
+const routes = { components: App, ...childRoutes };
+
+//
+// createApp(settings: {
+// location?: Location,
+// history?: History,
+// initialState?: Object|Void,
+// serviceOptions?: Object,
+// middlewares?: Function[],
+// sideReducers?: Object
+// enhancers?: Function[],
+// sagas?: Function[],
+// }) => Observable
+//
+// Either location or history must be defined
+export default function createApp({
+ location,
+ history,
+ initialState,
+ serviceOptions = {},
+ middlewares: sideMiddlewares = [],
+ enhancers: sideEnhancers = [],
+ reducers: sideReducers = {},
+ sagas: sideSagas = []
+}) {
+ const sagaOptions = {
+ services: servicesCreator(null, serviceOptions)
+ };
+
+ const enhancers = [
+ applyMiddleware(
+ ...middlewares,
+ ...sideMiddlewares,
+ ...[ ...sagas, ...sideSagas].map(saga => saga(sagaOptions)),
+ ),
+ // enhancers must come after middlewares
+ // on client side these are things like Redux DevTools
+ ...sideEnhancers
+ ];
+ const reducer = createReducer(sideReducers);
+
+ // create composed store enhancer
+ // use store enhancer function to enhance `createStore` function
+ // call enhanced createStore function with reducer and initialState
+ // to create store
+ const store = compose(...enhancers)(createStore)(reducer, initialState);
+
+ // createRouteProps({
+ // location: LocationDescriptor,
+ // history: History,
+ // routes: Object
+ // }) => Observable
+ return createRouteProps({ routes, location, history })
+ .map(([ nextLocation, props ]) => ({
+ nextLocation,
+ props,
+ reducer,
+ store
+ }));
+}
diff --git a/common/app/create-reducer.js b/common/app/create-reducer.js
new file mode 100644
index 0000000000..ee7478011e
--- /dev/null
+++ b/common/app/create-reducer.js
@@ -0,0 +1,12 @@
+import { combineReducers } from 'redux';
+
+import { reducer as app } from './redux';
+import { reducer as hikesApp } from './routes/Hikes/redux';
+
+export default function createReducer(sideReducers = {}) {
+ return combineReducers({
+ ...sideReducers,
+ app,
+ hikesApp
+ });
+}
diff --git a/common/app/flux/Store.js b/common/app/flux/Store.js
deleted file mode 100644
index 49f7cb342d..0000000000
--- a/common/app/flux/Store.js
+++ /dev/null
@@ -1,103 +0,0 @@
-import { Store } from 'thundercats';
-
-const { createRegistrar, setter, fromMany } = Store;
-const initValue = {
- title: 'Learn To Code | Free Code Camp',
- username: null,
- picture: null,
- points: 0,
- hikesApp: {
- hikes: [],
- // lecture state
- currentHike: {},
- showQuestions: false
- },
- jobsApp: {
- showModal: false
- }
-};
-
-export default Store({
- refs: {
- displayName: 'AppStore',
- value: initValue
- },
- init({ instance: store, args: [cat] }) {
- const register = createRegistrar(store);
- // app
- const {
- updateLocation,
- getUser,
- setTitle,
- toast
- } = cat.getActions('appActions');
-
- register(
- fromMany(
- setter(
- fromMany(
- getUser,
- setTitle
- )
- ),
- updateLocation,
- toast
- )
- );
-
- // hikes
- const {
- toggleQuestions,
- fetchHikes,
- resetHike,
- grabQuestion,
- releaseQuestion,
- moveQuestion,
- answer
- } = cat.getActions('hikesActions');
-
- register(
- fromMany(
- toggleQuestions,
- fetchHikes,
- resetHike,
- grabQuestion,
- releaseQuestion,
- moveQuestion,
- answer
- )
- );
-
-
- // jobs
- const {
- findJob,
- saveJobToDb,
- getJob,
- getJobs,
- openModal,
- closeModal,
- handleForm,
- getSavedForm,
- setPromoCode,
- applyCode,
- clearPromo
- } = cat.getActions('JobActions');
-
- register(
- fromMany(
- findJob,
- saveJobToDb,
- getJob,
- getJobs,
- openModal,
- closeModal,
- handleForm,
- getSavedForm,
- setPromoCode,
- applyCode,
- clearPromo
- )
- );
- }
-});
diff --git a/common/app/flux/index.js b/common/app/flux/index.js
deleted file mode 100644
index a07e138ae6..0000000000
--- a/common/app/flux/index.js
+++ /dev/null
@@ -1,2 +0,0 @@
-export AppActions from './Actions';
-export AppStore from './Store';
diff --git a/common/app/index.js b/common/app/index.js
index 99924acf83..1a3fead824 100644
--- a/common/app/index.js
+++ b/common/app/index.js
@@ -1 +1 @@
-export default from './app-stream.jsx';
+export default from './create-app.jsx';
diff --git a/common/app/middlewares.js b/common/app/middlewares.js
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/common/app/provide-Store.js b/common/app/provide-Store.js
new file mode 100644
index 0000000000..a569f64a5f
--- /dev/null
+++ b/common/app/provide-Store.js
@@ -0,0 +1,11 @@
+/* eslint-disable react/display-name */
+import React from 'react';
+import { Provider } from 'react-redux';
+
+export default function provideStore(element, store) {
+ return React.createElement(
+ Provider,
+ { store },
+ element
+ );
+}
diff --git a/common/app/redux/actions.js b/common/app/redux/actions.js
new file mode 100644
index 0000000000..cdc221a97b
--- /dev/null
+++ b/common/app/redux/actions.js
@@ -0,0 +1,21 @@
+import { createAction } from 'redux-actions';
+import types from './types';
+
+// updateTitle(title: String) => Action
+export const updateTitle = createAction(types.updateTitle);
+
+// makeToast({ type?: String, message: String, title: String }) => Action
+export const makeToast = createAction(
+ types.makeToast,
+ toast => toast.type ? toast : (toast.type = 'info', toast)
+);
+
+// fetchUser() => Action
+// used in combination with fetch-user-saga
+export const fetchUser = createAction(types.fetchUser);
+
+// setUser(userInfo: Object) => Action
+export const setUser = createAction(types.setUser);
+
+// updatePoints(points: Number) => Action
+export const updatePoints = createAction(types.updatePoints);
diff --git a/common/app/redux/fetch-user-saga.js b/common/app/redux/fetch-user-saga.js
new file mode 100644
index 0000000000..4f01aea99c
--- /dev/null
+++ b/common/app/redux/fetch-user-saga.js
@@ -0,0 +1,39 @@
+import { Observable } from 'rx';
+import { handleError, setUser, fetchUser } from './types';
+
+export default ({ services }) => ({ dispatch }) => next => {
+ return function getUserSaga(action) {
+ if (action.type !== fetchUser) {
+ return next(action);
+ }
+
+ return services.readService$({ service: 'user' })
+ .map(({
+ username,
+ picture,
+ progressTimestamps = [],
+ isFrontEndCert,
+ isBackEndCert,
+ isFullStackCert
+ }) => {
+ return {
+ type: setUser,
+ payload: {
+ username,
+ picture,
+ points: progressTimestamps.length,
+ isFrontEndCert,
+ isBackEndCert,
+ isFullStackCert,
+ isSignedIn: true
+ }
+ };
+ })
+ .catch(error => Observable.just({
+ type: handleError,
+ error
+ }))
+ .doOnNext(dispatch);
+ };
+};
+
diff --git a/common/app/redux/index.js b/common/app/redux/index.js
new file mode 100644
index 0000000000..d0e8381658
--- /dev/null
+++ b/common/app/redux/index.js
@@ -0,0 +1,6 @@
+export { default as reducer } from './reducer';
+export { default as actions } from './actions';
+export { default as types } from './types';
+
+import fetchUserSaga from './fetch-user-saga';
+export const sagas = [ fetchUserSaga ];
diff --git a/common/app/flux/Actions.js b/common/app/redux/oldActions.js
similarity index 100%
rename from common/app/flux/Actions.js
rename to common/app/redux/oldActions.js
diff --git a/common/app/redux/reducer.js b/common/app/redux/reducer.js
new file mode 100644
index 0000000000..1aaece38f7
--- /dev/null
+++ b/common/app/redux/reducer.js
@@ -0,0 +1,38 @@
+import { handleActions } from 'redux-actions';
+import types from './types';
+
+export default handleActions(
+ {
+ [types.updateTitle]: (state, { payload = 'Learn To Code' }) => ({
+ ...state,
+ title: payload + ' | Free Code Camp'
+ }),
+
+ [types.makeToast]: (state, { payload: toast }) => ({
+ ...state,
+ toast: {
+ ...toast,
+ id: state.toast && state.toast.id ? state.toast.id : 1
+ }
+ }),
+
+ [types.setUser]: (state, { payload: user }) => ({ ...state, ...user }),
+
+ [types.challengeSaved]: (state, { payload: { points = 0 } }) => ({
+ ...state,
+ points
+ }),
+
+ [types.updatePoints]: (state, { payload: points }) => ({
+ ...state,
+ points
+ })
+ },
+ {
+ title: 'Learn To Code | Free Code Camp',
+ username: null,
+ picture: null,
+ points: 0,
+ isSignedIn: false
+ }
+);
diff --git a/common/app/redux/types.js b/common/app/redux/types.js
new file mode 100644
index 0000000000..16122457f5
--- /dev/null
+++ b/common/app/redux/types.js
@@ -0,0 +1,14 @@
+const types = [
+ 'updateTitle',
+
+ 'fetchUser',
+ 'setUser',
+
+ 'makeToast',
+ 'updatePoints',
+ 'handleError'
+];
+
+export default types
+ // make into object with signature { type: nameSpace[type] };
+ .reduce((types, type) => ({ ...types, [type]: `app.${type}` }), {});
diff --git a/common/app/routes/FAVS/README.md b/common/app/routes/FAVS/README.md
deleted file mode 100644
index c02922da56..0000000000
--- a/common/app/routes/FAVS/README.md
+++ /dev/null
@@ -1 +0,0 @@
-future home of FAVS app
diff --git a/common/app/routes/Hikes/components/Hike.jsx b/common/app/routes/Hikes/components/Hike.jsx
index 85ebdb487f..6f75ae7f9b 100644
--- a/common/app/routes/Hikes/components/Hike.jsx
+++ b/common/app/routes/Hikes/components/Hike.jsx
@@ -1,63 +1,74 @@
import React, { PropTypes } from 'react';
-import { contain } from 'thundercats-react';
+import { connect } from 'react-redux';
import { Col, Row } from 'react-bootstrap';
+import { createSelector } from 'reselect';
import Lecture from './Lecture.jsx';
import Questions from './Questions.jsx';
+import { resetHike } from '../redux/actions';
-export default contain(
- {
- actions: ['hikesActions']
- },
- React.createClass({
- displayName: 'Hike',
-
- propTypes: {
- currentHike: PropTypes.object,
- hikesActions: PropTypes.object,
- params: PropTypes.object,
- showQuestions: PropTypes.bool
- },
-
- componentWillUnmount() {
- this.props.hikesActions.resetHike();
- },
-
- componentWillReceiveProps({ params: { dashedName } }) {
- if (this.props.params.dashedName !== dashedName) {
- this.props.hikesActions.resetHike();
- }
- },
-
- renderBody(showQuestions) {
- if (showQuestions) {
- return ;
- }
- return ;
- },
-
- render() {
- const {
- currentHike: { title } = {},
- showQuestions
- } = this.props;
-
- return (
-
-
-
-
-
-
- { this.renderBody(showQuestions) }
-
-
-
- );
- }
- })
+const mapStateToProps = createSelector(
+ state => state.hikesApp.hikes.entities,
+ state => state.hikesApp.currentHike,
+ (hikes, currentHikeDashedName) => {
+ const currentHike = hikes[currentHikeDashedName];
+ return {
+ title: currentHike.title
+ };
+ }
);
+// export plain component for testing
+export class Hike extends React.Component {
+ static displayName = 'Hike';
+
+ static propTypes = {
+ title: PropTypes.object,
+ params: PropTypes.object,
+ resetHike: PropTypes.func,
+ showQuestions: PropTypes.bool
+ };
+
+ componentWillUnmount() {
+ this.props.resetHike();
+ }
+
+ componentWillReceiveProps({ params: { dashedName } }) {
+ if (this.props.params.dashedName !== dashedName) {
+ this.props.resetHike();
+ }
+ }
+
+ renderBody(showQuestions) {
+ if (showQuestions) {
+ return ;
+ }
+ return ;
+ }
+
+ render() {
+ const {
+ title,
+ showQuestions
+ } = this.props;
+
+ return (
+
+
+
+
+
+
+ { this.renderBody(showQuestions) }
+
+
+
+ );
+ }
+}
+
+// export redux aware component
+export default connect(mapStateToProps, { resetHike });
diff --git a/common/app/routes/Hikes/components/Hikes.jsx b/common/app/routes/Hikes/components/Hikes.jsx
index ba5a324447..17c027a4de 100644
--- a/common/app/routes/Hikes/components/Hikes.jsx
+++ b/common/app/routes/Hikes/components/Hikes.jsx
@@ -1,74 +1,83 @@
import React, { PropTypes } from 'react';
+import { compose } from 'redux';
+import { connect } from 'react-redux';
import { Row } from 'react-bootstrap';
-import { contain } from 'thundercats-react';
-// import debugFactory from 'debug';
+import shouldComponentUpdate from 'react-pure-render/function';
+import { createSelector } from 'reselect';
+// import debug from 'debug';
import HikesMap from './Map.jsx';
+import { updateTitle } from '../../../redux/actions';
+import { fetchHikes } from '../redux/actions';
-// const debug = debugFactory('freecc:hikes');
+import contain from '../../../utils/professor-x';
-export default contain(
- {
- store: 'appStore',
- map(state) {
- return state.hikesApp;
- },
- actions: ['appActions'],
- fetchAction: 'hikesActions.fetchHikes',
- getPayload: ({ hikes, params }) => ({
- isPrimed: (hikes && !!hikes.length),
- dashedName: params.dashedName
- }),
- shouldContainerFetch(props, nextProps) {
- return props.params.dashedName !== nextProps.params.dashedName;
+// const log = debug('fcc:hikes');
+
+const mapStateToProps = createSelector(
+ state => state.hikesApp.hikes,
+ hikes => {
+ if (!hikes || !hikes.entities || !hikes.results) {
+ return { hikes: [] };
}
- },
- React.createClass({
- displayName: 'Hikes',
-
- propTypes: {
- appActions: PropTypes.object,
- children: PropTypes.element,
- currentHike: PropTypes.object,
- hikes: PropTypes.array,
- params: PropTypes.object,
- showQuestions: PropTypes.bool
- },
-
- componentWillMount() {
- const { appActions } = this.props;
- appActions.setTitle('Videos');
- },
-
- renderMap(hikes) {
- return (
-
- );
- },
-
- renderChild({ children, ...props }) {
- if (!children) {
- return null;
- }
- return React.cloneElement(children, props);
- },
-
- render() {
- const { hikes } = this.props;
- const { dashedName } = this.props.params;
- const preventOverflow = { overflow: 'hidden' };
- return (
-
-
- {
- // render sub-route
- this.renderChild({ ...this.props, dashedName }) ||
- // if no sub-route render hikes map
- this.renderMap(hikes)
- }
-
-
- );
- }
- })
+ return {
+ hikes: hikes.results.map(dashedName => hikes.enitites[dashedName])
+ };
+ }
);
+const fetchOptions = {
+ fetchAction: 'fetchHikes',
+
+ isPrimed: ({ hikes }) => hikes && !!hikes.length,
+ getPayload: ({ params: { dashedName } }) => dashedName,
+ shouldContainerFetch(props, nextProps) {
+ return props.params.dashedName !== nextProps.params.dashedName;
+ }
+};
+
+export class Hikes extends React.Component {
+ static displayName = 'Hikes';
+
+ static propTypes = {
+ children: PropTypes.element,
+ hikes: PropTypes.array,
+ params: PropTypes.object,
+ updateTitle: PropTypes.func
+ };
+
+ componentWillMount() {
+ const { updateTitle } = this.props;
+ updateTitle('Hikes');
+ }
+
+ shouldComponentUpdate = shouldComponentUpdate;
+
+ renderMap(hikes) {
+ return (
+
+ );
+ }
+
+ render() {
+ const { hikes } = this.props;
+ const preventOverflow = { overflow: 'hidden' };
+ return (
+
+
+ {
+ // render sub-route
+ this.props.children ||
+ // if no sub-route render hikes map
+ this.renderMap(hikes)
+ }
+
+
+ );
+ }
+}
+
+// export redux and fetch aware component
+export default compose(
+ connect(mapStateToProps, { fetchHikes, updateTitle }),
+ contain(fetchOptions)
+)(Hikes);
diff --git a/common/app/routes/Hikes/components/Lecture.jsx b/common/app/routes/Hikes/components/Lecture.jsx
index 09cea7a998..1aac07f492 100644
--- a/common/app/routes/Hikes/components/Lecture.jsx
+++ b/common/app/routes/Hikes/components/Lecture.jsx
@@ -1,95 +1,93 @@
import React, { PropTypes } from 'react';
-import { contain } from 'thundercats-react';
+import { connect } from 'react-redux';
import { Button, Col, Row } from 'react-bootstrap';
-import { History } from 'react-router';
import Vimeo from 'react-vimeo';
-import debugFactory from 'debug';
+import { createSelector } from 'reselect';
+import debug from 'debug';
-const debug = debugFactory('freecc:hikes');
+const log = debug('fcc:hikes');
-export default contain(
- {
- actions: ['hikesActions'],
- store: 'appStore',
- map(state) {
- const {
- currentHike: {
- dashedName,
- description,
- challengeSeed: [id] = [0]
- } = {}
- } = state.hikesApp;
-
- return {
- dashedName,
- description,
- id
- };
- }
- },
- React.createClass({
- displayName: 'Lecture',
- mixins: [History],
-
- propTypes: {
- dashedName: PropTypes.string,
- description: PropTypes.array,
- id: PropTypes.string,
- hikesActions: PropTypes.object
- },
-
- shouldComponentUpdate(nextProps) {
- const { props } = this;
- return nextProps.id !== props.id;
- },
-
- handleError: debug,
-
- handleFinish(hikesActions) {
- debug('loading questions');
- hikesActions.toggleQuestions();
- },
-
- renderTranscript(transcript, dashedName) {
- return transcript.map((line, index) => (
-
- { line }
-
- ));
- },
-
- render() {
- const {
- id = '1',
- description = [],
- hikesActions
- } = this.props;
- const dashedName = 'foo';
-
- return (
-
-
- this.handleFinish(hikesActions) }
- videoId={ id } />
-
-
-
- { this.renderTranscript(description, dashedName) }
-
-
-
-
- );
- }
- })
+const mapStateToProps = createSelector(
+ state => state.hikesApp.hikes.entities,
+ state => state.hikesApp.currentHike,
+ (hikes, currentHikeDashedName) => {
+ const currentHike = hikes[currentHikeDashedName];
+ const {
+ dashedName,
+ description,
+ challengeSeed: [id] = [0]
+ } = currentHike || {};
+ return {
+ id,
+ dashedName,
+ description
+ };
+ }
);
+
+export class Lecture extends React.Component {
+ static displayName = 'Lecture';
+
+ static propTypes = {
+ dashedName: PropTypes.string,
+ description: PropTypes.array,
+ id: PropTypes.string,
+ hikesActions: PropTypes.object
+ };
+
+ shouldComponentUpdate(nextProps) {
+ const { props } = this;
+ return nextProps.id !== props.id;
+ }
+
+ handleError: log;
+
+ handleFinish(hikesActions) {
+ debug('loading questions');
+ hikesActions.toggleQuestions();
+ }
+
+ renderTranscript(transcript, dashedName) {
+ return transcript.map((line, index) => (
+
+ { line }
+
+ ));
+ }
+
+ render() {
+ const {
+ id = '1',
+ description = [],
+ hikesActions
+ } = this.props;
+ const dashedName = 'foo';
+
+ return (
+
+
+ this.handleFinish(hikesActions) }
+ videoId={ id } />
+
+
+
+ { this.renderTranscript(description, dashedName) }
+
+
+
+
+ );
+ }
+}
+
+export default connect(mapStateToProps, { })(Lecture);
diff --git a/common/app/routes/Hikes/flux/index.js b/common/app/routes/Hikes/flux/index.js
deleted file mode 100644
index 0936f320ae..0000000000
--- a/common/app/routes/Hikes/flux/index.js
+++ /dev/null
@@ -1 +0,0 @@
-export default from './Actions';
diff --git a/common/app/routes/Hikes/index.js b/common/app/routes/Hikes/index.js
index fa8ba7863d..b759608fa9 100644
--- a/common/app/routes/Hikes/index.js
+++ b/common/app/routes/Hikes/index.js
@@ -1,11 +1,6 @@
import Hikes from './components/Hikes.jsx';
import Hike from './components/Hike.jsx';
-/*
- * show video /hikes/someVideo
- * show question /hikes/someVideo/question1
- */
-
export default {
path: 'videos',
component: Hikes,
diff --git a/common/app/routes/Hikes/redux/actions.js b/common/app/routes/Hikes/redux/actions.js
new file mode 100644
index 0000000000..a2aec2358a
--- /dev/null
+++ b/common/app/routes/Hikes/redux/actions.js
@@ -0,0 +1,54 @@
+import { createAction } from 'redux-actions';
+
+import types from './types';
+import { getMouse } from './utils';
+
+
+// fetchHikes(dashedName?: String) => Action
+// used with fetchHikesSaga
+export const fetchHikes = createAction(types.fetchHikes);
+// fetchHikesCompleted(hikes: Object) => Action
+// hikes is a normalized response from server
+// called within fetchHikesSaga
+export const fetchHikesCompleted = createAction(
+ types.fetchHikesCompleted,
+ (hikes, currentHike) => ({ hikes, currentHike })
+);
+
+export const toggleQuestion = createAction(types.toggleQuestion);
+
+export const grabQuestions = createAction(types.grabQuestions, e => {
+ let { pageX, pageY, touches } = e;
+ if (touches) {
+ e.preventDefault();
+ // these re-assigns the values of pageX, pageY from touches
+ ({ pageX, pageY } = touches[0]);
+ }
+ const delta = [pageX, pageY];
+ const mouse = [0, 0];
+
+ return { delta, mouse };
+});
+
+export const releaseQuestion = createAction(types.releaseQuestions);
+export const moveQuestion = createAction(
+ types.moveQuestion,
+ ({ e, delta }) => getMouse(e, delta)
+);
+
+// answer({
+// e: Event,
+// answer: Boolean,
+// userAnswer: Boolean,
+// info: String,
+// threshold: Number
+// }) => Action
+export const answer = createAction(types.answer);
+
+export const startShake = createAction(types.startShake);
+export const endShake = createAction(types.primeNextQuestion);
+
+export const goToNextQuestion = createAction(types.goToNextQuestion);
+
+export const hikeCompleted = createAction(types.hikeCompleted);
+export const goToNextHike = createAction(types.goToNextHike);
diff --git a/common/app/routes/Hikes/redux/answer-saga.js b/common/app/routes/Hikes/redux/answer-saga.js
new file mode 100644
index 0000000000..0c4cd39211
--- /dev/null
+++ b/common/app/routes/Hikes/redux/answer-saga.js
@@ -0,0 +1,128 @@
+import { Observable } from 'rx';
+// import { routeActions } from 'react-simple-router';
+
+import types from './types';
+import { getMouse } from './utils';
+
+import { makeToast, updatePoints } from '../../../redux/actions';
+import { hikeCompleted, goToNextHike } from './actions';
+import { postJSON$ } from '../../../../utils/ajax-stream';
+
+export default () => ({ getState, dispatch }) => next => {
+ return function answerSaga(action) {
+ if (types.answer !== action.type) {
+ return next(action);
+ }
+
+ const {
+ e,
+ answer,
+ userAnswer,
+ info,
+ threshold
+ } = action.payload;
+
+ const {
+ app: { isSignedIn },
+ hikesApp: {
+ currentQuestion,
+ currentHike: { id, name, challengeType },
+ tests = [],
+ delta = [ 0, 0 ]
+ }
+ } = getState();
+
+ let finalAnswer;
+ // drag answer, compute response
+ if (typeof userAnswer === 'undefined') {
+ const [positionX] = getMouse(e, delta);
+
+ // question released under threshold
+ if (Math.abs(positionX) < threshold) {
+ return next(action);
+ }
+
+ if (positionX >= threshold) {
+ finalAnswer = true;
+ }
+
+ if (positionX <= -threshold) {
+ finalAnswer = false;
+ }
+ } else {
+ finalAnswer = userAnswer;
+ }
+
+ // incorrect question
+ if (answer !== finalAnswer) {
+ if (info) {
+ dispatch({
+ type: 'makeToast',
+ payload: {
+ title: 'Hint',
+ message: info,
+ type: 'info'
+ }
+ });
+ }
+
+ return Observable
+ .just({ type: types.removeShake })
+ .delay(500)
+ .startWith({ type: types.startShake })
+ .doOnNext(dispatch);
+ }
+
+ if (tests[currentQuestion]) {
+ return Observable
+ .just({ type: types.goToNextQuestion })
+ .delay(300)
+ .startWith({ type: types.primeNextQuestion });
+ }
+
+ let updateUser$;
+ if (isSignedIn) {
+ const body = { id, name, challengeType };
+ updateUser$ = postJSON$('/completed-challenge', body)
+ // if post fails, will retry once
+ .retry(3)
+ .flatMap(({ alreadyCompleted, points }) => {
+ return Observable.of(
+ makeToast({
+ message:
+ 'Challenge saved.' +
+ (alreadyCompleted ? '' : ' First time Completed!'),
+ title: 'Saved',
+ type: 'info'
+ }),
+ updatePoints(points),
+ );
+ })
+ .catch(error => {
+ return Observable.just({
+ type: 'error',
+ error
+ });
+ });
+ } else {
+ updateUser$ = Observable.empty();
+ }
+
+ const challengeCompleted$ = Observable.of(
+ goToNextHike(),
+ makeToast({
+ title: 'Congratulations!',
+ message: 'Hike completed.' + (isSignedIn ? ' Saving...' : ''),
+ type: 'success'
+ })
+ );
+
+ return Observable.merge(challengeCompleted$, updateUser$)
+ .delay(300)
+ .startWith(hikeCompleted(finalAnswer))
+ .catch(error => Observable.just({
+ type: 'error',
+ error
+ }));
+ };
+};
diff --git a/common/app/routes/Hikes/redux/fetch-hikes-saga.js b/common/app/routes/Hikes/redux/fetch-hikes-saga.js
new file mode 100644
index 0000000000..07d482f358
--- /dev/null
+++ b/common/app/routes/Hikes/redux/fetch-hikes-saga.js
@@ -0,0 +1,46 @@
+import { Observable } from 'rx';
+import { normalize, Schema, arrayOf } from 'normalizr';
+// import debug from 'debug';
+
+import types from './types';
+import { fetchHikesCompleted } from './actions';
+import { handleError } from '../../../redux/types';
+
+import { getCurrentHike } from './utils';
+
+// const log = debug('fcc:fetch-hikes-saga');
+const hike = new Schema('hike', { idAttribute: 'dashedName' });
+
+export default ({ services }) => ({ dispatch }) => next => {
+ return function fetchHikesSaga(action) {
+ if (action.type !== types.fetchHikes) {
+ return next(action);
+ }
+
+ const dashedName = action.payload;
+ return services.readService$({ service: 'hikes' })
+ .map(hikes => {
+ const { entities, result } = normalize(
+ { hikes },
+ { hikes: arrayOf(hike) }
+ );
+
+ hikes = {
+ entities: entities.hike,
+ results: result.hikes
+ };
+
+ const currentHike = getCurrentHike(hikes, dashedName);
+
+ console.log('foo', currentHike);
+ return fetchHikesCompleted(hikes, currentHike);
+ })
+ .catch(error => {
+ return Observable.just({
+ type: handleError,
+ error
+ });
+ })
+ .doOnNext(dispatch);
+ };
+};
diff --git a/common/app/routes/Hikes/redux/index.js b/common/app/routes/Hikes/redux/index.js
new file mode 100644
index 0000000000..8d94299b34
--- /dev/null
+++ b/common/app/routes/Hikes/redux/index.js
@@ -0,0 +1,8 @@
+export actions from './actions';
+export reducer from './reducer';
+export types from './types';
+
+import answerSaga from './answer-saga';
+import fetchHikesSaga from './fetch-hikes-saga';
+
+export const sagas = [ answerSaga, fetchHikesSaga ];
diff --git a/common/app/routes/Hikes/flux/Actions.js b/common/app/routes/Hikes/redux/oldActions.js
similarity index 99%
rename from common/app/routes/Hikes/flux/Actions.js
rename to common/app/routes/Hikes/redux/oldActions.js
index 5395c23602..281306feec 100644
--- a/common/app/routes/Hikes/flux/Actions.js
+++ b/common/app/routes/Hikes/redux/oldActions.js
@@ -3,7 +3,7 @@ import { Observable } from 'rx';
import { Actions } from 'thundercats';
import debugFactory from 'debug';
-const debug = debugFactory('freecc:hikes:actions');
+const debug = debugFactory('fcc:hikes:actions');
const noOp = { transform: () => {} };
function getCurrentHike(hikes = [{}], dashedName, currentHike) {
diff --git a/common/app/routes/Hikes/redux/reducer.js b/common/app/routes/Hikes/redux/reducer.js
new file mode 100644
index 0000000000..e6a49f0964
--- /dev/null
+++ b/common/app/routes/Hikes/redux/reducer.js
@@ -0,0 +1,88 @@
+import { handleActions } from 'redux-actions';
+import types from './types';
+import { findNextHike } from './utils';
+
+const initialState = {
+ hikes: {
+ results: [],
+ entities: {}
+ },
+ // lecture state
+ currentHike: '',
+ showQuestions: false
+};
+
+export default handleActions(
+ {
+ [types.toggleQuestion]: state => ({
+ ...state,
+ showQuestions: !state.showQuestions,
+ currentQuestion: 1
+ }),
+
+ [types.grabQuestion]: (state, { payload: { delta, mouse } }) => ({
+ ...state,
+ isPressed: true,
+ delta,
+ mouse
+ }),
+
+ [types.releaseQuestion]: state => ({
+ ...state,
+ isPressed: false,
+ mouse: [ 0, 0 ]
+ }),
+
+ [types.moveQuestion]: (state, { payload: mouse }) => ({ ...state, mouse }),
+
+ [types.resetHike]: state => ({
+ ...state,
+ currentQuestion: 1,
+ showQuestions: false,
+ mouse: [0, 0],
+ delta: [0, 0]
+ }),
+
+ [types.startShake]: state => ({ ...state, shake: true }),
+ [types.endShake]: state => ({ ...state, shake: false }),
+
+ [types.primeNextQuestion]: (state, { payload: userAnswer }) => ({
+ ...state,
+ currentQuestion: state.currentQuestion + 1,
+ mouse: [ userAnswer ? 1000 : -1000, 0],
+ isPressed: false
+ }),
+
+ [types.goToNextQuestion]: state => ({
+ ...state,
+ mouse: [ 0, 0 ]
+ }),
+
+ [types.hikeCompleted]: (state, { payload: userAnswer } ) => ({
+ ...state,
+ isCorrect: true,
+ isPressed: false,
+ delta: [ 0, 0 ],
+ mouse: [ userAnswer ? 1000 : -1000, 0]
+ }),
+
+ [types.goToNextHike]: state => ({
+ ...state,
+ currentHike: findNextHike(state.hikes, state.currentHike.id),
+ showQuestions: false,
+ currentQuestion: 1,
+ mouse: [ 0, 0 ]
+ }),
+
+ [types.fetchHikesCompleted]: (state, { payload }) => {
+ const { hikes, currentHike } = payload;
+
+ return {
+ ...state,
+ hikes,
+ currentHike
+ };
+ }
+ },
+ initialState
+);
diff --git a/common/app/routes/Hikes/redux/types.js b/common/app/routes/Hikes/redux/types.js
new file mode 100644
index 0000000000..c96ae0e8e5
--- /dev/null
+++ b/common/app/routes/Hikes/redux/types.js
@@ -0,0 +1,23 @@
+const types = [
+ 'fetchHikes',
+ 'fetchHikesCompleted',
+
+ 'toggleQuestionView',
+ 'grabQuestion',
+ 'releaseQuestion',
+ 'moveQuestion',
+
+ 'answer',
+
+ 'startShake',
+ 'endShake',
+
+ 'primeNextQuestion',
+ 'goToNextQuestion',
+
+ 'hikeCompleted',
+ 'goToNextHike'
+];
+
+export default types
+ .reduce((types, type) => ({ ...types, [type]: `videos.${type}` }), {});
diff --git a/common/app/routes/Hikes/redux/utils.js b/common/app/routes/Hikes/redux/utils.js
new file mode 100644
index 0000000000..d03b33dc9a
--- /dev/null
+++ b/common/app/routes/Hikes/redux/utils.js
@@ -0,0 +1,74 @@
+import debug from 'debug';
+import _ from 'lodash';
+
+const log = debug('fcc:hikes:utils');
+
+function getFirstHike(hikes) {
+ return hikes.results[0];
+}
+
+// interface Hikes {
+// results: String[],
+// entities: {
+// hikeId: Challenge
+// }
+// }
+//
+// findCurrentHike({
+// hikes: Hikes,
+// dashedName: String
+// }) => String
+export function findCurrentHike(hikes = {}, dashedName) {
+ if (!dashedName) {
+ return getFirstHike(hikes) || {};
+ }
+
+ const filterRegex = new RegExp(dashedName, 'i');
+
+ return hikes
+ .results
+ .filter(dashedName => {
+ return filterRegex.test(dashedName);
+ })
+ .reduce((throwAway, hike) => {
+ return hike;
+ }, {});
+}
+
+export function getCurrentHike(hikes = {}, dashedName) {
+ if (!dashedName) {
+ return getFirstHike(hikes) || {};
+ }
+ return hikes.entities[dashedName];
+}
+
+export function findNextHike({ entities, results }, dashedName) {
+ if (!dashedName) {
+ log('find next hike no id provided');
+ return entities[results[0]];
+ }
+ const currentIndex = _.findIndex(
+ results,
+ ({ dashedName: _dashedName }) => _dashedName === dashedName
+ );
+
+ if (currentIndex >= results.length) {
+ return '';
+ }
+
+ return entities[results[currentIndex + 1]];
+}
+
+
+export function getMouse(e, [dx, dy]) {
+ let { pageX, pageY, touches, changedTouches } = e;
+
+ // touches can be empty on touchend
+ if (touches || changedTouches) {
+ e.preventDefault();
+ // these re-assigns the values of pageX, pageY from touches
+ ({ pageX, pageY } = touches[0] || changedTouches[0]);
+ }
+
+ return [pageX - dx, pageY - dy];
+}
diff --git a/common/app/routes/Jobs/components/NewJob.jsx b/common/app/routes/Jobs/components/NewJob.jsx
index 83aba5ee5b..43f2dfb6ef 100644
--- a/common/app/routes/Jobs/components/NewJob.jsx
+++ b/common/app/routes/Jobs/components/NewJob.jsx
@@ -26,7 +26,7 @@ import {
isURL
} from 'validator';
-const debug = debugFactory('freecc:jobs:newForm');
+const debug = debugFactory('fcc:jobs:newForm');
const checkValidity = [
'position',
diff --git a/common/app/sagas.js b/common/app/sagas.js
new file mode 100644
index 0000000000..fc40bc384c
--- /dev/null
+++ b/common/app/sagas.js
@@ -0,0 +1,6 @@
+import { sagas as appSagas } from './redux';
+import { sagas as hikesSagas} from './routes/Hikes/redux';
+export default [
+ ...appSagas,
+ ...hikesSagas
+];
diff --git a/common/app/Cat.js b/common/app/temp.js
similarity index 61%
rename from common/app/Cat.js
rename to common/app/temp.js
index 8673cb2d8a..4ed6ed821d 100644
--- a/common/app/Cat.js
+++ b/common/app/temp.js
@@ -1,20 +1,6 @@
-import { Cat } from 'thundercats';
import stamp from 'stampit';
-import { Disposable, Observable } from 'rx';
-
import { post$, postJSON$ } from '../utils/ajax-stream.js';
-import { AppActions, AppStore } from './flux';
-import HikesActions from './routes/Hikes/flux';
-import JobActions from './routes/Jobs/flux';
-const ajaxStamp = stamp({
- methods: {
- postJSON$,
- post$
- }
-});
-
-export default Cat().init(({ instance: cat, args: [services] }) => {
const serviceStamp = stamp({
methods: {
readService$(resource, params, config) {
@@ -52,14 +38,3 @@ export default Cat().init(({ instance: cat, args: [services] }) => {
}
}
});
-
- cat.register(HikesActions.compose(serviceStamp, ajaxStamp), null, services);
- cat.register(AppActions.compose(serviceStamp), null, services);
- cat.register(
- JobActions.compose(serviceStamp, ajaxStamp),
- null,
- cat,
- services
- );
- cat.register(AppStore, null, cat);
-});
diff --git a/common/app/utils/Professor-Context.js b/common/app/utils/Professor-Context.js
new file mode 100644
index 0000000000..0ba3133ff3
--- /dev/null
+++ b/common/app/utils/Professor-Context.js
@@ -0,0 +1,42 @@
+import React, { Children, PropTypes } from 'react';
+
+class ProfessorContext extends React.Component {
+ constructor(props) {
+ super(props);
+ this.professor = props.professor;
+ }
+ static displayName = 'ProfessorContext';
+
+ static propTypes = {
+ professor: PropTypes.object,
+ children: PropTypes.element.isRequired
+ };
+
+ static childContextTypes = {
+ professor: PropTypes.object
+ };
+
+ getChildContext() {
+ return { professor: this.professor };
+ }
+
+ render() {
+ return Children.only(this.props.children);
+ }
+}
+
+/* eslint-disable react/display-name, react/prop-types */
+ProfessorContext.wrap = function wrap(Component, professor) {
+ const props = {};
+ if (professor) {
+ props.professor = professor;
+ }
+
+ return React.createElement(
+ ProfessorContext,
+ props,
+ Component
+ );
+};
+
+export default ProfessorContext;
diff --git a/common/app/utils/professor-x.js b/common/app/utils/professor-x.js
new file mode 100644
index 0000000000..ca1522ac0c
--- /dev/null
+++ b/common/app/utils/professor-x.js
@@ -0,0 +1,196 @@
+import React, { PropTypes, createElement } from 'react';
+import { Observable, CompositeDisposable } from 'rx';
+import debug from 'debug';
+
+// interface contain {
+// (options?: Object, Component: ReactComponent) => ReactComponent
+// (options?: Object) => (Component: ReactComponent) => ReactComponent
+// }
+//
+// Action: { type: String, payload: Any, ...meta }
+//
+// ActionCreator(...args) => Observable
+//
+// interface options {
+// fetchAction?: ActionCreator,
+// getActionArgs?(props: Object, context: Object) => [],
+// isPrimed?(props: Object, context: Object) => Boolean,
+// handleError?(err) => Void
+// shouldRefetch?(
+// props: Object,
+// nextProps: Object,
+// context: Object,
+// nextContext: Object
+// ) => Boolean,
+// }
+
+
+const log = debug('fcc:professerx');
+
+function getChildContext(childContextTypes, currentContext) {
+
+ const compContext = { ...currentContext };
+ // istanbul ignore else
+ if (!childContextTypes || !childContextTypes.professor) {
+ delete compContext.professor;
+ }
+ return compContext;
+}
+
+const __DEV__ = process.env.NODE_ENV !== 'production';
+
+export default function contain(options = {}, Component) {
+ /* istanbul ignore else */
+ if (!Component) {
+ return contain.bind(null, options);
+ }
+
+ let action;
+ let isActionable = false;
+ let hasRefetcher = typeof options.shouldRefetch === 'function';
+
+ const getActionArgs = typeof options.getActionArgs === 'function' ?
+ options.getActionArgs :
+ (() => []);
+
+ const isPrimed = typeof typeof options.isPrimed === 'function' ?
+ options.isPrimed :
+ (() => false);
+
+
+ return class Container extends React.Component {
+ constructor(props, context) {
+ super(props, context);
+ this.__subscriptions = new CompositeDisposable();
+ }
+
+ static displayName = `Container(${Component.displayName})`;
+ static propTypes = Component.propTypes;
+
+ static contextTypes = {
+ ...Component.contextTypes,
+ professor: PropTypes.object
+ };
+
+ componentWillMount() {
+ const { professor } = this.context;
+ const { props } = this;
+ if (!options.fetchAction) {
+ log(`${Component.displayName} has no fetch action defined`);
+ return null;
+ }
+
+ action = props[options.fetchAction];
+ isActionable = typeof action === 'function';
+
+ if (__DEV__ && typeof action !== 'function') {
+ throw new Error(
+ `${options.fetchAction} should return a function but got ${action}.
+ Check the fetch options for ${Component.displayName}.`
+ );
+ }
+
+ if (
+ !professor ||
+ !professor.fetchContext
+ ) {
+ log(
+ `${Component.displayName} did not have professor defined on context`
+ );
+ return null;
+ }
+
+
+ const actionArgs = getActionArgs(
+ props,
+ getChildContext(Component.contextTypes, this.context)
+ );
+
+ professor.fetchContext.push({
+ name: options.fetchAction,
+ action,
+ actionArgs,
+ component: Component.displayName || 'Anon'
+ });
+ }
+
+ componentDidMount() {
+ if (isPrimed(this.props, this.context)) {
+ log('container is primed');
+ return null;
+ }
+ if (!isActionable) {
+ log(`${Component.displayName} container is not actionable`);
+ return null;
+ }
+ const actionArgs = getActionArgs(this.props, this.context);
+ const fetch$ = action.apply(null, actionArgs);
+ if (__DEV__ && !Observable.isObservable(fetch$)) {
+ console.log(fetch$);
+ throw new Error(
+ `Action creator should return an Observable but got ${fetch$}.
+ Check the action creator for fetch action ${options.fetchAction}`
+ );
+ }
+
+ const subscription = fetch$.subscribe(
+ () => {},
+ options.handleError
+ );
+ this.__subscriptions.add(subscription);
+ }
+
+ componentWillReceiveProps(nextProps, nextContext) {
+ if (
+ !isActionable ||
+ !hasRefetcher ||
+ !options.shouldRefetch(
+ this.props,
+ nextProps,
+ getChildContext(Component.contextTypes, this.context),
+ getChildContext(Component.contextTypes, nextContext)
+ )
+ ) {
+ return;
+ }
+ const actionArgs = getActionArgs(
+ this.props,
+ getChildContext(Component.contextTypes, this.context)
+ );
+
+ const fetch$ = action.apply(null, actionArgs);
+ if (__DEV__ && Observable.isObservable(fetch$)) {
+ throw new Error(
+ 'fetch action should return observable'
+ );
+ }
+
+ const subscription = fetch$.subscribe(
+ () => {},
+ options.errorHandler
+ );
+
+ this.__subscriptions.add(subscription);
+ }
+
+ componentWillUnmount() {
+ if (this.__subscriptions) {
+ this.__subscriptions.dispose();
+ }
+ }
+
+ shouldComponentUpdate() {
+ // props should be immutable
+ return false;
+ }
+
+ render() {
+ const { props } = this;
+
+ return createElement(
+ Component,
+ props
+ );
+ }
+ };
+}
diff --git a/common/app/utils/render-to-string.js b/common/app/utils/render-to-string.js
new file mode 100644
index 0000000000..b19b11bf55
--- /dev/null
+++ b/common/app/utils/render-to-string.js
@@ -0,0 +1,52 @@
+import { Observable, Scheduler } from 'rx';
+import ReactDOM from 'react-dom/server';
+import debug from 'debug';
+
+import ProfessorContext from './Professor-Context';
+
+const log = debug('fcc:professor');
+
+export function fetch({ fetchContext = [] }) {
+ if (fetchContext.length === 0) {
+ log('empty fetch context found');
+ return Observable.just(fetchContext);
+ }
+ return Observable.from(fetchContext, null, null, Scheduler.default)
+ .doOnNext(({ name }) => log(`calling ${name} action creator`))
+ .map(({ action, actionArgs }) => action.apply(null, actionArgs))
+ .doOnNext(fetch$ => {
+ if (!Observable.isObservable(fetch$)) {
+ throw new Error(
+ `action creator should return an observable`
+ );
+ }
+ })
+ .map(fetch$ => fetch$.doOnNext(action => log('action', action.type)))
+ .mergeAll()
+ .doOnCompleted(() => log('all fetch observables completed'));
+}
+
+
+export default function renderToString(Component) {
+ const fetchContext = [];
+ const professor = { fetchContext };
+ let ContextedComponent;
+ try {
+ ContextedComponent = ProfessorContext.wrap(Component, professor);
+ log('initiating fetcher registration');
+ ReactDOM.renderToStaticMarkup(ContextedComponent);
+ log('fetcher registration completed');
+ } catch (e) {
+ return Observable.throw(e);
+ }
+ return fetch(professor)
+ .last()
+ .delay(0)
+ .map(() => {
+ const markup = ReactDOM.renderToString(Component);
+ return {
+ markup,
+ fetchContext
+ };
+ });
+}
diff --git a/common/app/utils/render.js b/common/app/utils/render.js
new file mode 100644
index 0000000000..c30c682740
--- /dev/null
+++ b/common/app/utils/render.js
@@ -0,0 +1,26 @@
+import ReactDOM from 'react-dom';
+import { Disposable, Observable } from 'rx';
+import ProfessorContext from './Professor-Context';
+
+export default function render(Component, DOMContainer) {
+ let ContextedComponent;
+ try {
+ ContextedComponent = ProfessorContext.wrap(Component);
+ } catch (e) {
+ return Observable.throw(e);
+ }
+
+ return Observable.create(observer => {
+ try {
+ ReactDOM.render(ContextedComponent, DOMContainer, function() {
+ observer.onNext(this);
+ });
+ } catch (e) {
+ return observer.onError(e);
+ }
+
+ return Disposable.create(() => {
+ return ReactDOM.unmountComponentAtNode(DOMContainer);
+ });
+ });
+}
diff --git a/common/app/utils/shallow-equals.js b/common/app/utils/shallow-equals.js
new file mode 100644
index 0000000000..3f8ba3912f
--- /dev/null
+++ b/common/app/utils/shallow-equals.js
@@ -0,0 +1,37 @@
+// original sourc
+// https://github.com/rackt/react-redux/blob/master/src/utils/shallowEqual.js
+// MIT license
+export default function shallowEqual(objA, objB) {
+ if (objA === objB) {
+ return true;
+ }
+
+ if (
+ typeof objA !== 'object' ||
+ objA === null ||
+ typeof objB !== 'object' ||
+ objB === null
+ ) {
+ return false;
+ }
+
+ var keysA = Object.keys(objA);
+ var keysB = Object.keys(objB);
+
+ if (keysA.length !== keysB.length) {
+ return false;
+ }
+
+ // Test for A's keys different from B.
+ var bHasOwnProperty = Object.prototype.hasOwnProperty.bind(objB);
+ for (var i = 0; i < keysA.length; i++) {
+ if (
+ !bHasOwnProperty(keysA[i]) ||
+ objA[keysA[i]] !== objB[keysA[i]]
+ ) {
+ return false;
+ }
+ }
+
+ return true;
+}
diff --git a/common/models/User-Identity.js b/common/models/User-Identity.js
index a6c5241043..2d1c357c35 100644
--- a/common/models/User-Identity.js
+++ b/common/models/User-Identity.js
@@ -10,7 +10,7 @@ import {
const { defaultProfileImage } = require('../utils/constantStrings.json');
const githubRegex = (/github/i);
-const debug = debugFactory('freecc:models:userIdent');
+const debug = debugFactory('fcc:models:userIdent');
function createAccessToken(user, ttl, cb) {
if (arguments.length === 2 && typeof ttl === 'function') {
diff --git a/common/models/promo.js b/common/models/promo.js
index 9a423bdd6b..4dedb9a940 100644
--- a/common/models/promo.js
+++ b/common/models/promo.js
@@ -1,7 +1,7 @@
import { isAlphanumeric, isHexadecimal } from 'validator';
import debug from 'debug';
-const log = debug('freecc:models:promo');
+const log = debug('fcc:models:promo');
export default function promo(Promo) {
Promo.getButton = function getButton(id, code, type = 'isNot') {
diff --git a/common/models/user.js b/common/models/user.js
index 6d45beb6c2..952e665fad 100644
--- a/common/models/user.js
+++ b/common/models/user.js
@@ -7,7 +7,7 @@ import debugFactory from 'debug';
import { saveUser, observeMethod } from '../../server/utils/rx';
import { blacklistedUsernames } from '../../server/utils/constants';
-const debug = debugFactory('freecc:user:remote');
+const debug = debugFactory('fcc:user:remote');
const BROWNIEPOINTS_TIMEOUT = [1, 'hour'];
function getAboutProfile({
diff --git a/common/utils/ajax-stream.js b/common/utils/ajax-stream.js
index 665e23348c..1a95784a53 100644
--- a/common/utils/ajax-stream.js
+++ b/common/utils/ajax-stream.js
@@ -19,7 +19,7 @@
import debugFactory from 'debug';
import { Observable, AnonymousObservable, helpers } from 'rx';
-const debug = debugFactory('freecc:ajax$');
+const debug = debugFactory('fcc:ajax$');
const root = typeof window !== 'undefined' ? window : {};
// Gets the proper XMLHttpRequest for support for older IE
diff --git a/common/utils/services-creator.js b/common/utils/services-creator.js
new file mode 100644
index 0000000000..aa7e8bfe97
--- /dev/null
+++ b/common/utils/services-creator.js
@@ -0,0 +1,48 @@
+import{ Observable, Disposable } from 'rx';
+import Fetchr from 'fetchr';
+import stampit from 'stampit';
+
+function callbackObserver(observer) {
+ return (err, res) => {
+ if (err) {
+ return observer.onError(err);
+ }
+
+ observer.onNext(res);
+ observer.onCompleted();
+ };
+}
+
+
+export default stampit({
+ init({ args: [ options ] }) {
+ this.services = new Fetchr(options);
+ },
+ methods: {
+ readService$({ service: resource, params, config }) {
+ return Observable.create(observer => {
+ this.services.read(
+ resource,
+ params,
+ config,
+ callbackObserver(observer)
+ );
+
+ return Disposable.create(() => observer.dispose());
+ });
+ },
+ createService$({ service: resource, params, body, config }) {
+ return Observable.create(function(observer) {
+ this.services.create(
+ resource,
+ params,
+ body,
+ config,
+ callbackObserver(observer)
+ );
+
+ return Disposable.create(() => observer.dispose());
+ });
+ }
+ }
+});
diff --git a/gulpfile.js b/gulpfile.js
index 470c7add7e..86d1afc098 100644
--- a/gulpfile.js
+++ b/gulpfile.js
@@ -1,5 +1,5 @@
// enable debug for gulp
-process.env.DEBUG = process.env.DEBUG || 'freecc:*';
+process.env.DEBUG = process.env.DEBUG || 'fcc:*';
require('babel-core/register');
var Rx = require('rx'),
@@ -12,7 +12,7 @@ var Rx = require('rx'),
gutil = require('gulp-util'),
reduce = require('gulp-reduce-file'),
sortKeys = require('sort-keys'),
- debug = require('debug')('freecc:gulp'),
+ debug = require('debug')('fcc:gulp'),
yargs = require('yargs'),
concat = require('gulp-concat'),
uglify = require('gulp-uglify'),
@@ -98,7 +98,10 @@ var paths = {
'public/bower_components/bootstrap/dist/js/bootstrap.min.js',
'public/bower_components/d3/d3.min.js',
'public/bower_components/moment/min/moment.min.js',
- 'public/bower_components/moment-timezone/builds/moment-timezone-with-data.min.js',
+
+ 'public/bower_components/' +
+ 'moment-timezone/builds/moment-timezone-with-data.min.js',
+
'public/bower_components/mousetrap/mousetrap.min.js',
'public/bower_components/lightbox2/dist/js/lightbox.min.js',
'public/bower_components/rxjs/dist/rx.all.min.js'
@@ -194,7 +197,7 @@ gulp.task('serve', ['build-manifest'], function(cb) {
exec: path.join(__dirname, 'node_modules/.bin/babel-node'),
env: {
'NODE_ENV': process.env.NODE_ENV || 'development',
- 'DEBUG': process.env.DEBUG || 'freecc:*'
+ 'DEBUG': process.env.DEBUG || 'fcc:*'
}
})
.on('start', function() {
diff --git a/package.json b/package.json
index 8efd96aab2..15e4d5a240 100644
--- a/package.json
+++ b/package.json
@@ -92,6 +92,7 @@
"node-uuid": "^1.4.3",
"nodemailer": "^2.1.0",
"normalize-url": "^1.3.1",
+ "normalizr": "^2.0.0",
"object.assign": "^4.0.3",
"passport-facebook": "^2.0.0",
"passport-github": "^1.0.0",
@@ -105,11 +106,18 @@
"react-bootstrap": "~0.28.1",
"react-dom": "~0.14.3",
"react-motion": "~0.4.2",
+ "react-pure-render": "^1.0.2",
+ "react-redux": "^4.0.6",
"react-router": "^1.0.0",
"react-router-bootstrap": "https://github.com/FreeCodeCamp/react-router-bootstrap.git#freecodecamp",
"react-toastr": "^2.4.0",
+ "react-router-redux": "^2.1.0",
"react-vimeo": "~0.1.0",
+ "redux": "^3.0.5",
+ "redux-actions": "^0.9.1",
+ "redux-form": "^4.1.4",
"request": "^2.65.0",
+ "reselect": "^2.0.2",
"rev-del": "^1.0.5",
"rx": "^4.0.0",
"sanitize-html": "^1.11.1",
diff --git a/server/boot/a-extendUser.js b/server/boot/a-extendUser.js
index b212550a8a..d1f58cd319 100644
--- a/server/boot/a-extendUser.js
+++ b/server/boot/a-extendUser.js
@@ -1,7 +1,7 @@
import { Observable } from 'rx';
import debugFactory from 'debug';
-const debug = debugFactory('freecc:user:remote');
+const debug = debugFactory('fcc:user:remote');
function destroyAllRelated(id, Model) {
return Observable.fromNodeCallback(
diff --git a/server/boot/a-react.js b/server/boot/a-react.js
index e3d7711839..009e585361 100644
--- a/server/boot/a-react.js
+++ b/server/boot/a-react.js
@@ -1,14 +1,14 @@
import React from 'react';
import { RoutingContext } from 'react-router';
-import Fetchr from 'fetchr';
import { createLocation } from 'history';
-import debugFactory from 'debug';
-import { dehydrate } from 'thundercats';
-import { renderToString$ } from 'thundercats-react';
+import debug from 'debug';
+
+import renderToString from '../../common/app/utils/render-to-string';
+import provideStore from '../../common/app/provide-store';
import app$ from '../../common/app';
-const debug = debugFactory('freecc:react-server');
+const log = debug('fcc:react-server');
// add routes here as they slowly get reactified
// remove their individual controllers
@@ -38,52 +38,43 @@ export default function reactSubRouter(app) {
app.use(router);
function serveReactApp(req, res, next) {
- const services = new Fetchr({ req });
+ const serviceOptions = { req };
const location = createLocation(req.path);
// returns a router wrapped app
- app$({ location })
+ app$({
+ location,
+ serviceOptions
+ })
// if react-router does not find a route send down the chain
- .filter(function({ props }) {
+ .filter(({ props }) => {
if (!props) {
- debug('react tried to find %s but got 404', location.pathname);
+ log(`react tried to find ${location.pathname} but got 404`);
return next();
}
return !!props;
})
- .flatMap(function({ props, AppCat }) {
- const cat = AppCat(null, services);
- debug('render react markup and pre-fetch data');
- const store = cat.getStore('appStore');
+ .flatMap(({ props, store }) => {
+ log('render react markup and pre-fetch data');
- // primes store to observe action changes
- // cleaned up by cat.dispose further down
- store.subscribe(() => {});
-
- return renderToString$(
- cat,
- React.createElement(RoutingContext, props)
+ return renderToString(
+ provideStore(React.createElement(RoutingContext, props), store)
)
- .flatMap(
- dehydrate(cat),
- ({ markup }, data) => ({ markup, data, cat })
- );
+ .map(({ markup }) => ({ markup, store }));
})
- .flatMap(function({ data, markup, cat }) {
- debug('react markup rendered, data fetched');
- cat.dispose();
- const { title } = data.AppStore;
- res.expose(data, 'data');
+ .flatMap(function({ markup, store }) {
+ log('react markup rendered, data fetched');
+ const state = store.getState();
+ const { title } = state.app.title;
+ res.expose(state, 'data');
return res.render$(
'layout-react',
{ markup, title }
);
})
+ .doOnNext(markup => res.send(markup))
.subscribe(
- function(markup) {
- debug('html rendered and ready to send');
- res.send(markup);
- },
+ () => log('html rendered and ready to send'),
next
);
}
diff --git a/server/boot/certificate.js b/server/boot/certificate.js
index e4082e5f74..6775edd3a0 100644
--- a/server/boot/certificate.js
+++ b/server/boot/certificate.js
@@ -22,7 +22,7 @@ import {
import certTypes from '../utils/certTypes.json';
-const log = debug('freecc:certification');
+const log = debug('fcc:certification');
const sendMessageToNonUser = ifNoUserSend(
'must be logged in to complete.'
);
diff --git a/server/boot/challenge.js b/server/boot/challenge.js
index 957483f279..1555566dc0 100644
--- a/server/boot/challenge.js
+++ b/server/boot/challenge.js
@@ -26,7 +26,7 @@ import badIdMap from '../utils/bad-id-map';
const isDev = process.env.NODE_ENV !== 'production';
const isBeta = !!process.env.BETA;
-const log = debug('freecc:challenges');
+const log = debug('fcc:challenges');
const challengesRegex = /^(bonfire|waypoint|zipline|basejump|checkpoint)/i;
const challengeView = {
0: 'challenges/showHTML',
diff --git a/server/boot/commit.js b/server/boot/commit.js
index 8d5805ee2a..4df3af2f3d 100644
--- a/server/boot/commit.js
+++ b/server/boot/commit.js
@@ -34,7 +34,7 @@ const sendNonUserToCommit = ifNoUserRedirectTo(
'info'
);
-const debug = debugFactory('freecc:commit');
+const debug = debugFactory('fcc:commit');
function findNonprofit(name) {
let nonprofit;
diff --git a/server/boot/randomAPIs.js b/server/boot/randomAPIs.js
index 8daa663b3a..ed8e886f08 100644
--- a/server/boot/randomAPIs.js
+++ b/server/boot/randomAPIs.js
@@ -2,7 +2,7 @@ var Rx = require('rx'),
async = require('async'),
moment = require('moment'),
request = require('request'),
- debug = require('debug')('freecc:cntr:resources'),
+ debug = require('debug')('fcc:cntr:resources'),
constantStrings = require('../utils/constantStrings.json'),
labs = require('../resources/labs.json'),
testimonials = require('../resources/testimonials.json'),
diff --git a/server/boot/story.js b/server/boot/story.js
index 516ce9e268..0f43aef34e 100755
--- a/server/boot/story.js
+++ b/server/boot/story.js
@@ -2,7 +2,7 @@ var Rx = require('rx'),
assign = require('object.assign'),
sanitizeHtml = require('sanitize-html'),
moment = require('moment'),
- debug = require('debug')('freecc:cntr:story'),
+ debug = require('debug')('fcc:cntr:story'),
utils = require('../utils'),
observeMethod = require('../utils/rx').observeMethod,
saveUser = require('../utils/rx').saveUser,
diff --git a/server/boot/user.js b/server/boot/user.js
index bfb0ca6afa..5eebc4dcb8 100644
--- a/server/boot/user.js
+++ b/server/boot/user.js
@@ -19,7 +19,7 @@ import {
calcLongestStreak
} from '../utils/user-stats';
-const debug = debugFactory('freecc:boot:user');
+const debug = debugFactory('fcc:boot:user');
const sendNonUserToMap = ifNoUserRedirectTo('/map');
const certIds = {
[certTypes.frontEnd]: frontEndChallengeId,
diff --git a/server/services/hikes.js b/server/services/hikes.js
index bd4a6810e1..e672d857f3 100644
--- a/server/services/hikes.js
+++ b/server/services/hikes.js
@@ -1,23 +1,23 @@
import debugFactory from 'debug';
import assign from 'object.assign';
-const debug = debugFactory('freecc:services:hikes');
+const debug = debugFactory('fcc:services:hikes');
export default function hikesService(app) {
const Challenge = app.models.Challenge;
return {
name: 'hikes',
- read: (req, resource, params, config, cb) => {
+ read: (req, resource, { dashedName } = {}, config, cb) => {
const query = {
where: { challengeType: '6' },
order: ['order ASC', 'suborder ASC' ]
};
- debug('params', params);
- if (params) {
+ debug('dashedName', dashedName);
+ if (dashedName) {
assign(query.where, {
- dashedName: { like: params.dashedName, options: 'i' }
+ dashedName: { like: dashedName, options: 'i' }
});
}
debug('query', query);
diff --git a/server/services/user.js b/server/services/user.js
index 3fa4dc40c1..a2816e818c 100644
--- a/server/services/user.js
+++ b/server/services/user.js
@@ -2,7 +2,7 @@ import debugFactory from 'debug';
import assign from 'object.assign';
const censor = '**********************:P********';
-const debug = debugFactory('freecc:services:user');
+const debug = debugFactory('fcc:services:user');
const protectedUserFields = {
id: censor,
password: censor,
diff --git a/server/utils/commit.js b/server/utils/commit.js
index 02ce7ab5dc..0a9474c40a 100644
--- a/server/utils/commit.js
+++ b/server/utils/commit.js
@@ -3,7 +3,7 @@ import debugFactory from 'debug';
import { Observable } from 'rx';
import commitGoals from './commit-goals.json';
-const debug = debugFactory('freecc:utils/commit');
+const debug = debugFactory('fcc:utils/commit');
export { commitGoals };
diff --git a/server/utils/rx.js b/server/utils/rx.js
index 68086d2563..0900cc7bfc 100644
--- a/server/utils/rx.js
+++ b/server/utils/rx.js
@@ -1,7 +1,7 @@
import Rx from 'rx';
import debugFactory from 'debug';
-const debug = debugFactory('freecc:rxUtils');
+const debug = debugFactory('fcc:rxUtils');
export function saveInstance(instance) {
return new Rx.Observable.create(function(observer) {