Permalink
Please sign in to comment.
Showing
with
1,511 additions
and 651 deletions.
- +1 −1 .eslintrc
- +0 −9 client/err-saga.js
- +0 −69 client/history-saga.js
- +46 −74 client/index.js
- 0 client/sagas/README.md
- +19 −0 client/sagas/err-saga.js
- +4 −0 client/sagas/index.js
- +16 −0 client/sagas/title-saga.js
- +75 −67 common/app/App.jsx
- +0 −17 common/app/app-stream.jsx
- +1 −0 common/app/components/Footer/README.md
- +11 −11 common/app/components/Nav/Nav.jsx
- +11 −7 common/app/components/NotFound/index.jsx
- +79 −0 common/app/create-app.jsx
- +12 −0 common/app/create-reducer.js
- +0 −103 common/app/flux/Store.js
- +0 −2 common/app/flux/index.js
- +1 −1 common/app/index.js
- 0 common/app/middlewares.js
- +11 −0 common/app/provide-Store.js
- +21 −0 common/app/redux/actions.js
- +39 −0 common/app/redux/fetch-user-saga.js
- +6 −0 common/app/redux/index.js
- 0 common/app/{flux/Actions.js → redux/oldActions.js}
- +38 −0 common/app/redux/reducer.js
- +14 −0 common/app/redux/types.js
- +0 −1 common/app/routes/FAVS/README.md
- +67 −56 common/app/routes/Hikes/components/Hike.jsx
- +71 −62 common/app/routes/Hikes/components/Hikes.jsx
- +80 −82 common/app/routes/Hikes/components/Lecture.jsx
- +0 −1 common/app/routes/Hikes/flux/index.js
- +0 −5 common/app/routes/Hikes/index.js
- +54 −0 common/app/routes/Hikes/redux/actions.js
- +128 −0 common/app/routes/Hikes/redux/answer-saga.js
- +46 −0 common/app/routes/Hikes/redux/fetch-hikes-saga.js
- +8 −0 common/app/routes/Hikes/redux/index.js
- +1 −1 common/app/routes/Hikes/{flux/Actions.js → redux/oldActions.js}
- +88 −0 common/app/routes/Hikes/redux/reducer.js
- +23 −0 common/app/routes/Hikes/redux/types.js
- +74 −0 common/app/routes/Hikes/redux/utils.js
- +1 −1 common/app/routes/Jobs/components/NewJob.jsx
- +6 −0 common/app/sagas.js
- +0 −25 common/app/{Cat.js → temp.js}
- +42 −0 common/app/utils/Professor-Context.js
- +196 −0 common/app/utils/professor-x.js
- +52 −0 common/app/utils/render-to-string.js
- +26 −0 common/app/utils/render.js
- +37 −0 common/app/utils/shallow-equals.js
- +1 −1 common/models/User-Identity.js
- +1 −1 common/models/promo.js
- +1 −1 common/models/user.js
- +1 −1 common/utils/ajax-stream.js
- +48 −0 common/utils/services-creator.js
- +7 −4 gulpfile.js
- +8 −0 package.json
- +1 −1 server/boot/a-extendUser.js
- +24 −33 server/boot/a-react.js
- +1 −1 server/boot/certificate.js
- +1 −1 server/boot/challenge.js
- +1 −1 server/boot/commit.js
- +1 −1 server/boot/randomAPIs.js
- +1 −1 server/boot/story.js
- +1 −1 server/boot/user.js
- +5 −5 server/services/hikes.js
- +1 −1 server/services/user.js
- +1 −1 server/utils/commit.js
- +1 −1 server/utils/rx.js
2
.eslintrc
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)); | |||
-} |
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(() => {}); | |||
-} |
120
client/index.js
@@ -1,99 +1,71 @@ | |||
-import unused from './es6-shims'; // eslint-disable-line | +import './es6-shims'; | ||
import Rx from 'rx'; | import Rx from 'rx'; | ||
import React from 'react'; | import React from 'react'; | ||
-import Fetchr from 'fetchr'; | +import debug from 'debug'; | ||
-import debugFactory from 'debug'; | |||
import { Router } from 'react-router'; | import { Router } from 'react-router'; | ||
+import { routeReducer as routing, syncHistory } from 'react-router-redux'; | |||
import { createLocation, createHistory } from 'history'; | import { createLocation, createHistory } from 'history'; | ||
-import { hydrate } from 'thundercats'; | |||
-import { render$ } from 'thundercats-react'; | |||
import app$ from '../common/app'; | import app$ from '../common/app'; | ||
-import historySaga from './history-saga'; | +import provideStore from '../common/app/provide-store'; | ||
-import errSaga from './err-saga'; | |||
-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 DOMContianer = document.getElementById('fcc'); | ||
-const catState = window.__fcc__.data || {}; | +const initialState = window.__fcc__.data; | ||
-const services = new Fetchr({ | + | ||
- xhrPath: '/services' | +const serviceOptions = { xhrPath: '/services' }; | ||
-}); | |||
Rx.config.longStackSupport = !!debug.enabled; | Rx.config.longStackSupport = !!debug.enabled; | ||
const history = createHistory(); | const history = createHistory(); | ||
const appLocation = createLocation( | const appLocation = createLocation( | ||
location.pathname + location.search | location.pathname + location.search | ||
); | ); | ||
+const routingMiddleware = syncHistory(history); | |||
-// returns an observable | +const devTools = window.devToolsExtension ? window.devToolsExtension() : f => f; | ||
-app$({ history, location: appLocation }) | +const shouldRouterListenForReplays = !!window.devToolsExtension; | ||
- .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); | |||
- // set page title | +const clientSagaOptions = { doc: document }; | ||
- appStore$ | |||
- .pluck('title') | |||
- .distinctUntilChanged() | |||
- .doOnNext(title => document.title = title) | |||
- .subscribe(() => {}); | |||
- historySaga( | +// returns an observable | ||
- history, | +app$({ | ||
- updateLocation, | + location: appLocation, | ||
- goTo, | + history, | ||
- goBack, | + serviceOptions, | ||
- routerState$ | + initialState, | ||
- ); | + middlewares: [ | ||
- | + routingMiddleware, | ||
- const err$ = appStore$ | + ...sagas.map(saga => saga(clientSagaOptions)) | ||
- .pluck('err') | + ], | ||
- .filter(err => !!err) | + reducers: { routing }, | ||
- .distinctUntilChanged(); | + enhancers: [ devTools ] | ||
+}) | |||
+ .flatMap(({ props, store }) => { | |||
- errSaga(err$, toast); | + // because of weirdness in react-routers match function | ||
- }) | + // we replace the wrapped returned in props with the first one | ||
- // allow store subscribe to subscribe to actions | + // we passed in. This might be fixed in react-router 2.0 | ||
- .delay(10) | |||
- .flatMap(({ props, appCat }) => { | |||
props.history = history; | props.history = history; | ||
- return render$( | + if (shouldRouterListenForReplays && store) { | ||
- appCat, | + log('routing middleware listening for replays'); | ||
- React.createElement(Router, props), | + routingMiddleware.listenForReplays(store); | ||
+ } | |||
+ | |||
+ log('rendering'); | |||
+ return render( | |||
+ provideStore(React.createElement(Router, props), store), | |||
DOMContianer | DOMContianer | ||
); | ); | ||
}) | }) | ||
.subscribe( | .subscribe( | ||
- () => { | + () => debug('react rendered'), | ||
- debug('react rendered'); | + err => { throw err; }, | ||
- }, | + () => debug('react closed subscription') | ||
- err => { | |||
- throw err; | |||
- }, | |||
- () => { | |||
- debug('react closed subscription'); | |||
- } | |||
); | ); |
0
client/sagas/README.md
No changes.
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` | |||
+ } | |||
+ }); | |||
+ }; | |||
+}; |
4
client/sagas/index.js
@@ -0,0 +1,4 @@ | |||
+import errSaga from './err-saga'; | |||
+import titleSaga from './title-saga'; | |||
+ | |||
+export default [errSaga, titleSaga]; |
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; | |||
+ }; | |||
+}; |
142
common/app/App.jsx
@@ -1,81 +1,89 @@ | |||
import React, { PropTypes } from 'react'; | import React, { PropTypes } from 'react'; | ||
import { Row } from 'react-bootstrap'; | import { Row } from 'react-bootstrap'; | ||
import { ToastMessage, ToastContainer } from 'react-toastr'; | 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'; | import Nav from './components/Nav'; | ||
const toastMessageFactory = React.createFactory(ToastMessage.animation); | const toastMessageFactory = React.createFactory(ToastMessage.animation); | ||
-export default contain( | +const mapStateToProps = createSelector( | ||
- { | + state => state.app, | ||
- actions: ['appActions'], | + ({ | ||
- store: 'appStore', | + username, | ||
- fetchAction: 'appActions.getUser', | + points, | ||
- isPrimed({ username }) { | + picture, | ||
- return !!username; | + toast | ||
- }, | + }) => ({ | ||
- map({ | + username, | ||
- username, | + points, | ||
- points, | + picture, | ||
- picture, | + toast | ||
- toast | + }) | ||
- }) { | +); | ||
- return { | + | ||
- username, | +const fetchContainerOptions = { | ||
- points, | + fetchAction: 'fetchUser', | ||
- picture, | + isPrimed({ username }) { | ||
- toast | + return !!username; | ||
- }; | + } | ||
- }, | +}; | ||
- getPayload(props) { | |||
- return { | |||
- isPrimed: !!props.username | |||
- }; | |||
- } | |||
- }, | |||
- React.createClass({ | |||
- displayName: 'FreeCodeCamp', | |||
- propTypes: { | +// export plain class for testing | ||
- appActions: PropTypes.object, | +export class FreeCodeCamp extends React.Component { | ||
- children: PropTypes.node, | + static displayName = 'FreeCodeCamp'; | ||
- username: PropTypes.string, | |||
- points: PropTypes.number, | |||
- picture: PropTypes.string, | |||
- toast: PropTypes.object | |||
- }, | |||
- componentWillReceiveProps({ toast: nextToast = {} }) { | + static propTypes = { | ||
- const { toast = {} } = this.props; | + children: PropTypes.node, | ||
- if (toast.id !== nextToast.id) { | + username: PropTypes.string, | ||
- this.refs.toaster[nextToast.type || 'success']( | + points: PropTypes.number, | ||
- nextToast.message, | + picture: PropTypes.string, | ||
- nextToast.title, | + toast: PropTypes.object | ||
- { | + }; | ||
- closeButton: true, | |||
- timeOut: 10000 | |||
- } | |||
- ); | |||
- } | |||
- }, | |||
- render() { | + componentWillReceiveProps({ toast: nextToast = {} }) { | ||
- const { username, points, picture } = this.props; | + const { toast = {} } = this.props; | ||
- const navProps = { username, points, picture }; | + if (toast.id !== nextToast.id) { | ||
- return ( | + this.refs.toaster[nextToast.type || 'success']( | ||
- <div> | + nextToast.message, | ||
- <Nav | + nextToast.title, | ||
- { ...navProps }/> | + { | ||
- <Row> | + closeButton: true, | ||
- { this.props.children } | + timeOut: 10000 | ||
- </Row> | + } | ||
- <ToastContainer | |||
- className='toast-bottom-right' | |||
- ref='toaster' | |||
- toastMessageFactory={ toastMessageFactory } /> | |||
- </div> | |||
); | ); | ||
} | } | ||
- }) | + } | ||
+ | |||
+ 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); |
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 }; | |||
- }); | |||
-} |
1
common/app/components/Footer/README.md
@@ -0,0 +1 @@ | |||
+Currently not used |
22
common/app/components/Nav/Nav.jsx
18
common/app/components/NotFound/index.jsx
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 | |||
+ })); | |||
+} |
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 | |||
+ }); | |||
+} |
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 | |||
- ) | |||
- ); | |||
- } | |||
-}); |
2
common/app/flux/index.js
@@ -1,2 +0,0 @@ | |||
-export AppActions from './Actions'; | |||
-export AppStore from './Store'; |
2
common/app/index.js
@@ -1 +1 @@ | |||
-export default from './app-stream.jsx'; | +export default from './create-app.jsx'; |
0
common/app/middlewares.js
No changes.
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 | |||
+ ); | |||
+} |
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); |
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); | |||
+ }; | |||
+}; | |||
+ |
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 ]; |
0
common/app/flux/Actions.js → common/app/redux/oldActions.js
File renamed without changes.
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 | |||
+ } | |||
+); |
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}` }), {}); |
1
common/app/routes/FAVS/README.md
@@ -1 +0,0 @@ | |||
-future home of FAVS app |
123
common/app/routes/Hikes/components/Hike.jsx
@@ -1,63 +1,74 @@ | |||
import React, { PropTypes } from 'react'; | import React, { PropTypes } from 'react'; | ||
-import { contain } from 'thundercats-react'; | +import { connect } from 'react-redux'; | ||
import { Col, Row } from 'react-bootstrap'; | import { Col, Row } from 'react-bootstrap'; | ||
+import { createSelector } from 'reselect'; | |||
import Lecture from './Lecture.jsx'; | import Lecture from './Lecture.jsx'; | ||
import Questions from './Questions.jsx'; | import Questions from './Questions.jsx'; | ||
+import { resetHike } from '../redux/actions'; | |||
-export default contain( | +const mapStateToProps = createSelector( | ||
- { | + state => state.hikesApp.hikes.entities, | ||
- actions: ['hikesActions'] | + state => state.hikesApp.currentHike, | ||
- }, | + (hikes, currentHikeDashedName) => { | ||
- React.createClass({ | + const currentHike = hikes[currentHikeDashedName]; | ||
- displayName: 'Hike', | + return { | ||
- | + title: currentHike.title | ||
- 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> | |||
- ); | |||
- } | |||
- }) | |||
); | ); | ||
+// 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 }); |
133
common/app/routes/Hikes/components/Hikes.jsx
@@ -1,74 +1,83 @@ | |||
import React, { PropTypes } from 'react'; | import React, { PropTypes } from 'react'; | ||
+import { compose } from 'redux'; | |||
+import { connect } from 'react-redux'; | |||
import { Row } from 'react-bootstrap'; | import { Row } from 'react-bootstrap'; | ||
-import { contain } from 'thundercats-react'; | +import shouldComponentUpdate from 'react-pure-render/function'; | ||
-// import debugFactory from 'debug'; | +import { createSelector } from 'reselect'; | ||
+// import debug from 'debug'; | |||
import HikesMap from './Map.jsx'; | 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( | +// const log = debug('fcc:hikes'); | ||
- { | + | ||
- store: 'appStore', | +const mapStateToProps = createSelector( | ||
- map(state) { | + state => state.hikesApp.hikes, | ||
- return state.hikesApp; | + hikes => { | ||
- }, | + if (!hikes || !hikes.entities || !hikes.results) { | ||
- actions: ['appActions'], | + return { hikes: [] }; | ||
- fetchAction: 'hikesActions.fetchHikes', | |||
- getPayload: ({ hikes, params }) => ({ | |||
- isPrimed: (hikes && !!hikes.length), | |||
- dashedName: params.dashedName | |||
- }), | |||
- shouldContainerFetch(props, nextProps) { | |||
- return props.params.dashedName !== nextProps.params.dashedName; | |||
} | } | ||
- }, | + return { | ||
- React.createClass({ | + hikes: hikes.results.map(dashedName => hikes.enitites[dashedName]) | ||
- displayName: 'Hikes', | + }; | ||
+ } | |||
+); | |||
+const fetchOptions = { | |||
+ fetchAction: 'fetchHikes', | |||
- propTypes: { | + isPrimed: ({ hikes }) => hikes && !!hikes.length, | ||
- appActions: PropTypes.object, | + getPayload: ({ params: { dashedName } }) => dashedName, | ||
- children: PropTypes.element, | + shouldContainerFetch(props, nextProps) { | ||
- currentHike: PropTypes.object, | + return props.params.dashedName !== nextProps.params.dashedName; | ||
- hikes: PropTypes.array, | + } | ||
- params: PropTypes.object, | +}; | ||
- showQuestions: PropTypes.bool | |||
- }, | |||
- componentWillMount() { | +export class Hikes extends React.Component { | ||
- const { appActions } = this.props; | + static displayName = 'Hikes'; | ||
- appActions.setTitle('Videos'); | |||
- }, | |||
- renderMap(hikes) { | + static propTypes = { | ||
- return ( | + children: PropTypes.element, | ||
- <HikesMap hikes={ hikes }/> | + hikes: PropTypes.array, | ||
- ); | + params: PropTypes.object, | ||
- }, | + updateTitle: PropTypes.func | ||
+ }; | |||
- renderChild({ children, ...props }) { | + componentWillMount() { | ||
- if (!children) { | + const { updateTitle } = this.props; | ||
- return null; | + updateTitle('Hikes'); | ||
- } | + } | ||
- return React.cloneElement(children, props); | |||
- }, | |||
- render() { | + shouldComponentUpdate = shouldComponentUpdate; | ||
- const { hikes } = this.props; | + | ||
- const { dashedName } = this.props.params; | + renderMap(hikes) { | ||
- const preventOverflow = { overflow: 'hidden' }; | + return ( | ||
- return ( | + <HikesMap hikes={ hikes }/> | ||
- <div> | + ); | ||
- <Row style={ preventOverflow }> | + } | ||
- { | + | ||
- // render sub-route | + render() { | ||
- this.renderChild({ ...this.props, dashedName }) || | + const { hikes } = this.props; | ||
- // if no sub-route render hikes map | + const preventOverflow = { overflow: 'hidden' }; | ||
- this.renderMap(hikes) | + return ( | ||
- } | + <div> | ||
- </Row> | + <Row style={ preventOverflow }> | ||
- </div> | + { | ||
- ); | + // 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); |
162
common/app/routes/Hikes/components/Lecture.jsx
@@ -1,95 +1,93 @@ | |||
import React, { PropTypes } from 'react'; | import React, { PropTypes } from 'react'; | ||
-import { contain } from 'thundercats-react'; | +import { connect } from 'react-redux'; | ||
import { Button, Col, Row } from 'react-bootstrap'; | import { Button, Col, Row } from 'react-bootstrap'; | ||
-import { History } from 'react-router'; | |||
import Vimeo from 'react-vimeo'; | 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( | +const mapStateToProps = createSelector( | ||
- { | + state => state.hikesApp.hikes.entities, | ||
- actions: ['hikesActions'], | + state => state.hikesApp.currentHike, | ||
- store: 'appStore', | + (hikes, currentHikeDashedName) => { | ||
- map(state) { | + const currentHike = hikes[currentHikeDashedName]; | ||
- const { | + const { | ||
- currentHike: { | + dashedName, | ||
- dashedName, | + description, | ||
- description, | + challengeSeed: [id] = [0] | ||
- challengeSeed: [id] = [0] | + } = currentHike || {}; | ||
- } = {} | + return { | ||
- } = state.hikesApp; | + id, | ||
+ dashedName, | |||
+ description | |||
+ }; | |||
+ } | |||
+); | |||
- return { | +export class Lecture extends React.Component { | ||
- dashedName, | + static displayName = 'Lecture'; | ||
- description, | |||
- id | |||
- }; | |||
- } | |||
- }, | |||
- React.createClass({ | |||
- displayName: 'Lecture', | |||
- mixins: [History], | |||
- propTypes: { | + static propTypes = { | ||
- dashedName: PropTypes.string, | + dashedName: PropTypes.string, | ||
- description: PropTypes.array, | + description: PropTypes.array, | ||
- id: PropTypes.string, | + id: PropTypes.string, | ||
- hikesActions: PropTypes.object | + hikesActions: PropTypes.object | ||
- }, | + }; | ||
- shouldComponentUpdate(nextProps) { | + shouldComponentUpdate(nextProps) { | ||
- const { props } = this; | + const { props } = this; | ||
- return nextProps.id !== props.id; | + return nextProps.id !== props.id; | ||
- }, | + } | ||
- handleError: debug, | + handleError: log; | ||
- handleFinish(hikesActions) { | + handleFinish(hikesActions) { | ||
- debug('loading questions'); | + debug('loading questions'); | ||
- hikesActions.toggleQuestions(); | + hikesActions.toggleQuestions(); | ||
- }, | + } | ||
- renderTranscript(transcript, dashedName) { | + renderTranscript(transcript, dashedName) { | ||
- return transcript.map((line, index) => ( | + return transcript.map((line, index) => ( | ||
- <p | + <p | ||
- className='lead text-left' | + className='lead text-left' | ||
- key={ dashedName + index }> | + key={ dashedName + index }> | ||
- { line } | + { line } | ||
- </p> | + </p> | ||
- )); | + )); | ||
- }, | + } | ||
- render() { | + render() { | ||
- const { | + const { | ||
- id = '1', | + id = '1', | ||
- description = [], | + description = [], | ||
- hikesActions | + hikesActions | ||
- } = this.props; | + } = this.props; | ||
- const dashedName = 'foo'; | + const dashedName = 'foo'; | ||
- return ( | + return ( | ||
- <Col xs={ 12 }> | + <Col xs={ 12 }> | ||
- <Row> | + <Row> | ||
- <Vimeo | + <Vimeo | ||
- onError={ this.handleError } | + onError={ this.handleError } | ||
- onFinish= { () => this.handleFinish(hikesActions) } | + onFinish= { () => this.handleFinish(hikesActions) } | ||
- videoId={ id } /> | + videoId={ id } /> | ||
- </Row> | + </Row> | ||
- <Row> | + <Row> | ||
- <article> | + <article> | ||
- { this.renderTranscript(description, dashedName) } | + { this.renderTranscript(description, dashedName) } | ||
- </article> | + </article> | ||
- <Button | + <Button | ||
- block={ true } | + block={ true } | ||
- bsSize='large' | + bsSize='large' | ||
- bsStyle='primary' | + bsStyle='primary' | ||
- onClick={ () => this.handleFinish(hikesActions) }> | + onClick={ () => this.handleFinish(hikesActions) }> | ||
- Take me to the Questions | + Take me to the Questions | ||
- </Button> | + </Button> | ||
- </Row> | + </Row> | ||
- </Col> | + </Col> | ||
- ); | + ); | ||
- } | + } | ||
- }) | +} | ||
-); | + | ||
+export default connect(mapStateToProps, { })(Lecture); |
1
common/app/routes/Hikes/flux/index.js
@@ -1 +0,0 @@ | |||
-export default from './Actions'; |
5
common/app/routes/Hikes/index.js
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); |
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 | |||
+ })); | |||
+ }; | |||
+}; |
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); | |||
+ }; | |||
+}; |
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 ]; |
2
common/app/routes/Hikes/flux/Actions.js → common/app/routes/Hikes/redux/oldActions.js
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 | |||
+); |
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}` }), {}); |
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]; | |||
+} |
2
common/app/routes/Jobs/components/NewJob.jsx
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 | |||
+]; |
25
common/app/Cat.js → common/app/temp.js
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; |
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 | |||
+ ); | |||
+ } | |||
+ }; | |||
+} |
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 | |||
+ }; | |||
+ }); | |||
+} |
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); | |||
+ }); | |||
+ }); | |||
+} |
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; | |||
+} |
2
common/models/User-Identity.js
2
common/models/promo.js
2
common/models/user.js
2
common/utils/ajax-stream.js
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()); | |||
+ }); | |||
+ } | |||
+ } | |||
+}); |
11
gulpfile.js
8
package.json
2
server/boot/a-extendUser.js
57
server/boot/a-react.js
2
server/boot/certificate.js
2
server/boot/challenge.js
2
server/boot/commit.js
2
server/boot/randomAPIs.js
2
server/boot/story.js
2
server/boot/user.js
10
server/services/hikes.js
2
server/services/user.js
2
server/utils/commit.js
2
server/utils/rx.js
0 comments on commit
8ef3fdb