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 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.
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 { 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); |
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 { 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 }); |
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); |
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); |
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