Skip to content
Browse files

Initial move to redux

  • Loading branch information...
1 parent 2863efe commit 8ef3fdb6a0ad2db5f2496de60ddd5162044f007e @BerkeleyTrue BerkeleyTrue committed
Showing with 1,511 additions and 651 deletions.
  1. +1 −1 .eslintrc
  2. +0 −9 client/err-saga.js
  3. +0 −69 client/history-saga.js
  4. +46 −74 client/index.js
  5. 0 client/sagas/README.md
  6. +19 −0 client/sagas/err-saga.js
  7. +4 −0 client/sagas/index.js
  8. +16 −0 client/sagas/title-saga.js
  9. +75 −67 common/app/App.jsx
  10. +0 −17 common/app/app-stream.jsx
  11. +1 −0 common/app/components/Footer/README.md
  12. +11 −11 common/app/components/Nav/Nav.jsx
  13. +11 −7 common/app/components/NotFound/index.jsx
  14. +79 −0 common/app/create-app.jsx
  15. +12 −0 common/app/create-reducer.js
  16. +0 −103 common/app/flux/Store.js
  17. +0 −2 common/app/flux/index.js
  18. +1 −1 common/app/index.js
  19. 0 common/app/middlewares.js
  20. +11 −0 common/app/provide-Store.js
  21. +21 −0 common/app/redux/actions.js
  22. +39 −0 common/app/redux/fetch-user-saga.js
  23. +6 −0 common/app/redux/index.js
  24. 0 common/app/{flux/Actions.js → redux/oldActions.js}
  25. +38 −0 common/app/redux/reducer.js
  26. +14 −0 common/app/redux/types.js
  27. +0 −1 common/app/routes/FAVS/README.md
  28. +67 −56 common/app/routes/Hikes/components/Hike.jsx
  29. +71 −62 common/app/routes/Hikes/components/Hikes.jsx
  30. +80 −82 common/app/routes/Hikes/components/Lecture.jsx
  31. +0 −1 common/app/routes/Hikes/flux/index.js
  32. +0 −5 common/app/routes/Hikes/index.js
  33. +54 −0 common/app/routes/Hikes/redux/actions.js
  34. +128 −0 common/app/routes/Hikes/redux/answer-saga.js
  35. +46 −0 common/app/routes/Hikes/redux/fetch-hikes-saga.js
  36. +8 −0 common/app/routes/Hikes/redux/index.js
  37. +1 −1 common/app/routes/Hikes/{flux/Actions.js → redux/oldActions.js}
  38. +88 −0 common/app/routes/Hikes/redux/reducer.js
  39. +23 −0 common/app/routes/Hikes/redux/types.js
  40. +74 −0 common/app/routes/Hikes/redux/utils.js
  41. +1 −1 common/app/routes/Jobs/components/NewJob.jsx
  42. +6 −0 common/app/sagas.js
  43. +0 −25 common/app/{Cat.js → temp.js}
  44. +42 −0 common/app/utils/Professor-Context.js
  45. +196 −0 common/app/utils/professor-x.js
  46. +52 −0 common/app/utils/render-to-string.js
  47. +26 −0 common/app/utils/render.js
  48. +37 −0 common/app/utils/shallow-equals.js
  49. +1 −1 common/models/User-Identity.js
  50. +1 −1 common/models/promo.js
  51. +1 −1 common/models/user.js
  52. +1 −1 common/utils/ajax-stream.js
  53. +48 −0 common/utils/services-creator.js
  54. +7 −4 gulpfile.js
  55. +8 −0 package.json
  56. +1 −1 server/boot/a-extendUser.js
  57. +24 −33 server/boot/a-react.js
  58. +1 −1 server/boot/certificate.js
  59. +1 −1 server/boot/challenge.js
  60. +1 −1 server/boot/commit.js
  61. +1 −1 server/boot/randomAPIs.js
  62. +1 −1 server/boot/story.js
  63. +1 −1 server/boot/user.js
  64. +5 −5 server/services/hikes.js
  65. +1 −1 server/services/user.js
  66. +1 −1 server/utils/commit.js
  67. +1 −1 server/utils/rx.js
View
2 .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,
View
9 client/err-saga.js
@@ -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));
-}
View
69 client/history-saga.js
@@ -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(() => {});
-}
View
120 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);
-// 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');
-
- const {
- toast,
- updateLocation,
- goTo,
- goBack
- } = appCat.getActions('appActions');
-
-
- const routerState$ = appStore$
- .map(({ location }) => location)
- .filter(location => !!location);
+const devTools = window.devToolsExtension ? window.devToolsExtension() : f => f;
+const shouldRouterListenForReplays = !!window.devToolsExtension;
- // set page title
- appStore$
- .pluck('title')
- .distinctUntilChanged()
- .doOnNext(title => document.title = title)
- .subscribe(() => {});
+const clientSagaOptions = { doc: document };
- historySaga(
- history,
- updateLocation,
- goTo,
- goBack,
- routerState$
- );
-
- const err$ = appStore$
- .pluck('err')
- .filter(err => !!err)
- .distinctUntilChanged();
+// returns an observable
+app$({
+ location: appLocation,
+ history,
+ serviceOptions,
+ initialState,
+ middlewares: [
+ routingMiddleware,
+ ...sagas.map(saga => saga(clientSagaOptions))
+ ],
+ reducers: { routing },
+ enhancers: [ devTools ]
+})
+ .flatMap(({ props, store }) => {
- 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')
);
0 client/sagas/README.md
No changes.
View
19 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`
+ }
+ });
+ };
+};
View
4 client/sagas/index.js
@@ -0,0 +1,4 @@
+import errSaga from './err-saga';
+import titleSaga from './title-saga';
+
+export default [errSaga, titleSaga];
View
16 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;
+ };
+};
View
142 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',
+const mapStateToProps = createSelector(
+ state => state.app,
+ ({
+ username,
+ points,
+ picture,
+ toast
+ }) => ({
+ username,
+ points,
+ picture,
+ toast
+ })
+);
+
+const fetchContainerOptions = {
+ fetchAction: 'fetchUser',
+ isPrimed({ username }) {
+ return !!username;
+ }
+};
- propTypes: {
- appActions: PropTypes.object,
- children: PropTypes.node,
- username: PropTypes.string,
- points: PropTypes.number,
- picture: PropTypes.string,
- toast: PropTypes.object
- },
+// export plain class for testing
+export class FreeCodeCamp extends React.Component {
+ static displayName = 'FreeCodeCamp';
- 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
- }
- );
- }
- },
+ static propTypes = {
+ children: PropTypes.node,
+ username: PropTypes.string,
+ points: PropTypes.number,
+ picture: PropTypes.string,
+ toast: PropTypes.object
+ };
- render() {
- const { username, points, picture } = this.props;
- const navProps = { username, points, picture };
- return (
- <div>
- <Nav
- { ...navProps }/>
- <Row>
- { this.props.children }
- </Row>
- <ToastContainer
- className='toast-bottom-right'
- ref='toaster'
- toastMessageFactory={ toastMessageFactory } />
- </div>
+ 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 (
+ <div>
+ <Nav { ...navProps }/>
+ <Row>
+ { this.props.children }
+ </Row>
+ <ToastContainer
+ className='toast-bottom-right'
+ ref='toaster'
+ toastMessageFactory={ toastMessageFactory } />
+ </div>
+ );
+ }
+}
+
+const wrapComponent = compose(
+ // connect Component to Redux Store
+ connect(mapStateToProps, { fetchUser }),
+ // handles prefetching data
+ contain(fetchContainerOptions)
);
+
+export default wrapComponent(FreeCodeCamp);
View
17 common/app/app-stream.jsx
@@ -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 };
- });
-}
View
1 common/app/components/Footer/README.md
@@ -0,0 +1 @@
+Currently not used
View
22 common/app/components/Nav/Nav.jsx
@@ -23,20 +23,20 @@ const logoElement = (
);
const toggleButtonChild = (
- <Col xs={ 12 }>
- <span className='hamburger-text'>Menu</span>
- </Col>
+ <Col xs={ 12 }>
+ <span className='hamburger-text'>Menu</span>
+ </Col>
);
-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({
</NavItem>
);
});
- },
+ }
renderPoints(username, points) {
if (!username) {
@@ -76,7 +76,7 @@ export default React.createClass({
[ { points } ]
</FCCNavItem>
);
- },
+ }
renderSignin(username, picture) {
if (username) {
@@ -100,7 +100,7 @@ export default React.createClass({
</NavItem>
);
}
- },
+ }
render() {
const { username, points, picture } = this.props;
@@ -124,4 +124,4 @@ export default React.createClass({
</Navbar>
);
}
-});
+}
View
18 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 <span></span>;
}
-});
+}
View
79 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
+ }));
+}
View
12 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
+ });
+}
View
103 common/app/flux/Store.js
@@ -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
- )
- );
- }
-});
View
2 common/app/flux/index.js
@@ -1,2 +0,0 @@
-export AppActions from './Actions';
-export AppStore from './Store';
View
2 common/app/index.js
@@ -1 +1 @@
-export default from './app-stream.jsx';
+export default from './create-app.jsx';
View
0 common/app/middlewares.js
No changes.
View
11 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
+ );
+}
View
21 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);
View
39 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);
+ };
+};
+
View
6 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 ];
View
0 common/app/flux/Actions.js → common/app/redux/oldActions.js
File renamed without changes.
View
38 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
+ }
+);
View
14 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}` }), {});
View
1 common/app/routes/FAVS/README.md
@@ -1 +0,0 @@
-future home of FAVS app
View
123 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 <Questions />;
- }
- return <Lecture />;
- },
-
- render() {
- const {
- currentHike: { title } = {},
- showQuestions
- } = this.props;
-
- return (
- <Col xs={ 12 }>
- <Row>
- <header className='text-center'>
- <h4>{ title }</h4>
- </header>
- <hr />
- <div className='spacer' />
- <section
- className={ 'text-center' }
- title={ title }>
- { this.renderBody(showQuestions) }
- </section>
- </Row>
- </Col>
- );
- }
- })
+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 <Questions />;
+ }
+ return <Lecture />;
+ }
+
+ render() {
+ const {
+ title,
+ showQuestions
+ } = this.props;
+
+ return (
+ <Col xs={ 12 }>
+ <Row>
+ <header className='text-center'>
+ <h4>{ title }</h4>
+ </header>
+ <hr />
+ <div className='spacer' />
+ <section
+ className={ 'text-center' }
+ title={ title }>
+ { this.renderBody(showQuestions) }
+ </section>
+ </Row>
+ </Col>
+ );
+ }
+}
+
+// export redux aware component
+export default connect(mapStateToProps, { resetHike });
View
133 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',
+ return {
+ hikes: hikes.results.map(dashedName => hikes.enitites[dashedName])
+ };
+ }
+);
+const fetchOptions = {
+ fetchAction: 'fetchHikes',
- propTypes: {
- appActions: PropTypes.object,
- children: PropTypes.element,
- currentHike: PropTypes.object,
- hikes: PropTypes.array,
- params: PropTypes.object,
- showQuestions: PropTypes.bool
- },
+ isPrimed: ({ hikes }) => hikes && !!hikes.length,
+ getPayload: ({ params: { dashedName } }) => dashedName,
+ shouldContainerFetch(props, nextProps) {
+ return props.params.dashedName !== nextProps.params.dashedName;
+ }
+};
- componentWillMount() {
- const { appActions } = this.props;
- appActions.setTitle('Videos');
- },
+export class Hikes extends React.Component {
+ static displayName = 'Hikes';
- renderMap(hikes) {
- return (
- <HikesMap hikes={ hikes }/>
- );
- },
+ static propTypes = {
+ children: PropTypes.element,
+ hikes: PropTypes.array,
+ params: PropTypes.object,
+ updateTitle: PropTypes.func
+ };
- renderChild({ children, ...props }) {
- if (!children) {
- return null;
- }
- return React.cloneElement(children, props);
- },
+ componentWillMount() {
+ const { updateTitle } = this.props;
+ updateTitle('Hikes');
+ }
- render() {
- const { hikes } = this.props;
- const { dashedName } = this.props.params;
- const preventOverflow = { overflow: 'hidden' };
- return (
- <div>
- <Row style={ preventOverflow }>
- {
- // render sub-route
- this.renderChild({ ...this.props, dashedName }) ||
- // if no sub-route render hikes map
- this.renderMap(hikes)
- }
- </Row>
- </div>
- );
- }
- })
-);
+ shouldComponentUpdate = shouldComponentUpdate;
+
+ renderMap(hikes) {
+ return (
+ <HikesMap hikes={ hikes }/>
+ );
+ }
+
+ render() {
+ const { hikes } = this.props;
+ const preventOverflow = { overflow: 'hidden' };
+ return (
+ <div>
+ <Row style={ preventOverflow }>
+ {
+ // render sub-route
+ this.props.children ||
+ // if no sub-route render hikes map
+ this.renderMap(hikes)
+ }
+ </Row>
+ </div>
+ );
+ }
+}
+
+// export redux and fetch aware component
+export default compose(
+ connect(mapStateToProps, { fetchHikes, updateTitle }),
+ contain(fetchOptions)
+)(Hikes);
View
162 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;
+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
+ };
+ }
+);
- return {
- dashedName,
- description,
- id
- };
- }
- },
- React.createClass({
- displayName: 'Lecture',
- mixins: [History],
+export class Lecture extends React.Component {
+ static displayName = 'Lecture';
- propTypes: {
- dashedName: PropTypes.string,
- description: PropTypes.array,
- id: PropTypes.string,
- hikesActions: PropTypes.object
- },
+ static propTypes = {
+ dashedName: PropTypes.string,
+ description: PropTypes.array,
+ id: PropTypes.string,
+ hikesActions: PropTypes.object
+ };
- shouldComponentUpdate(nextProps) {
- const { props } = this;
- return nextProps.id !== props.id;
- },
+ shouldComponentUpdate(nextProps) {
+ const { props } = this;
+ return nextProps.id !== props.id;
+ }
- handleError: debug,
+ handleError: log;
- handleFinish(hikesActions) {
- debug('loading questions');
- hikesActions.toggleQuestions();
- },
+ handleFinish(hikesActions) {
+ debug('loading questions');
+ hikesActions.toggleQuestions();
+ }
- renderTranscript(transcript, dashedName) {
- return transcript.map((line, index) => (
- <p
- className='lead text-left'
- key={ dashedName + index }>
- { line }
- </p>
- ));
- },
+ renderTranscript(transcript, dashedName) {
+ return transcript.map((line, index) => (
+ <p
+ className='lead text-left'
+ key={ dashedName + index }>
+ { line }
+ </p>
+ ));
+ }
- render() {
- const {
- id = '1',
- description = [],
- hikesActions
- } = this.props;
- const dashedName = 'foo';
+ render() {
+ const {
+ id = '1',
+ description = [],
+ hikesActions
+ } = this.props;
+ const dashedName = 'foo';
- return (
- <Col xs={ 12 }>
- <Row>
- <Vimeo
- onError={ this.handleError }
- onFinish= { () => this.handleFinish(hikesActions) }
- videoId={ id } />
- </Row>
- <Row>
- <article>
- { this.renderTranscript(description, dashedName) }
- </article>
- <Button
- block={ true }
- bsSize='large'
- bsStyle='primary'
- onClick={ () => this.handleFinish(hikesActions) }>
- Take me to the Questions
- </Button>
- </Row>
- </Col>
- );
- }
- })
-);
+ return (
+ <Col xs={ 12 }>
+ <Row>
+ <Vimeo
+ onError={ this.handleError }
+ onFinish= { () => this.handleFinish(hikesActions) }
+ videoId={ id } />
+ </Row>
+ <Row>
+ <article>
+ { this.renderTranscript(description, dashedName) }
+ </article>
+ <Button
+ block={ true }
+ bsSize='large'
+ bsStyle='primary'
+ onClick={ () => this.handleFinish(hikesActions) }>
+ Take me to the Questions
+ </Button>
+ </Row>
+ </Col>
+ );
+ }
+}
+
+export default connect(mapStateToProps, { })(Lecture);
View
1 common/app/routes/Hikes/flux/index.js
@@ -1 +0,0 @@
-export default from './Actions';
View
5 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,
View
54 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);
View
128 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
+ }));
+ };
+};
View
46 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);
+ };
+};
View
8 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 ];
View
2 common/app/routes/Hikes/flux/Actions.js → 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) {
View
88 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
+);
View
23 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}` }), {});
View
74 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];
+}
View
2 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',
View
6 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
+];
View
25 common/app/Cat.js → 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);
-});
View
42 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;
View
196 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
+ );
+ }
+ };
+}
View
52 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
+ };
+ });
+}
View
26 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);
+ });
+ });
+}
View
37 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;
+}
View
2 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') {
View
2 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') {
View
2 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({
View
2 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
View
48 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());
+ });
+ }
+ }
+});
View
11 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() {
View
8 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",
View
2 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(
View
57 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');
-
- // primes store to observe action changes
- // cleaned up by cat.dispose further down
- store.subscribe(() => {});
+ .flatMap(({ props, store }) => {
+ log('render react markup and pre-fetch data');
- 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
);
}
View
2 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.'
);
View
2 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',
View
2 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;
View
2 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'),
View
2 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,
View
2 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,
View
10 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);
View
2 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,
View
2 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 };
View
2 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) {

0 comments on commit 8ef3fdb

Please sign in to comment.
Something went wrong with that request. Please try again.