diff --git a/client/index.js b/client/index.js index bf8fe8cb79..008e2a704b 100644 --- a/client/index.js +++ b/client/index.js @@ -26,7 +26,7 @@ const appLocation = createLocation( function location$(history) { return Rx.Observable.create(function(observer) { const dispose = history.listen(function(location) { - observer.onNext(location.pathname); + observer.onNext(location); }); return Rx.Disposable.create(() => { @@ -40,10 +40,9 @@ app$({ history, location: appLocation }) .flatMap( ({ AppCat }) => { // instantiate the cat with service - const appCat = AppCat(null, services); + const appCat = AppCat(null, services, history); // hydrate the stores - return hydrate(appCat, catState) - .map(() => appCat); + return hydrate(appCat, catState).map(() => appCat); }, // not using nextLocation at the moment but will be used for // redirects in the future @@ -51,12 +50,26 @@ app$({ history, location: appLocation }) ) .doOnNext(({ appCat }) => { const appActions = appCat.getActions('appActions'); + const appStore = appCat.getStore('appStore'); - location$(history) + const route$ = location$(history) .pluck('pathname') - .distinctUntilChanged() - .doOnNext(route => debug('route change', route)) - .subscribe(route => appActions.updateRoute(route)); + .distinctUntilChanged(); + + appStore + .pluck('route') + .filter(route => !!route) + .withLatestFrom( + route$, + (nextRoute, currentRoute) => ({ currentRoute, nextRoute }) + ) + // only continue when route change requested + .filter(({ currentRoute, nextRoute }) => currentRoute !== nextRoute) + .doOnNext(({ nextRoute }) => { + debug('route change', nextRoute); + history.pushState(history.state, nextRoute); + }) + .subscribeOnError(err => console.error(err)); appActions.goBack.subscribe(function() { history.goBack(); @@ -65,10 +78,11 @@ app$({ history, location: appLocation }) appActions .updateRoute .pluck('route') - .doOnNext(route => debug('update route', route)) - .subscribe(function(route) { - history.pushState(null, route); - }); + .doOnNext(route => { + debug('update route', route); + history.pushState(history.state, route); + }) + .subscribeOnError(err => console.error(err)); }) .flatMap(({ props, appCat }) => { props.history = history; diff --git a/common/app/Cat.js b/common/app/Cat.js index 2953c8aa5f..5b2040505e 100644 --- a/common/app/Cat.js +++ b/common/app/Cat.js @@ -2,14 +2,15 @@ import { Cat } from 'thundercats'; import stamp from 'stampit'; import { Disposable, Observable } from 'rx'; -import { postJSON$ } from '../utils/ajax-stream.js'; +import { post$, postJSON$ } from '../utils/ajax-stream.js'; import { AppActions, AppStore } from './flux'; import { HikesActions } from './routes/Hikes/flux'; import { JobActions, JobsStore} from './routes/Jobs/flux'; const ajaxStamp = stamp({ methods: { - postJSON$: postJSON$ + postJSON$, + post$ } }); diff --git a/common/app/flux/Store.js b/common/app/flux/Store.js index b052365a92..e63c76cbc4 100644 --- a/common/app/flux/Store.js +++ b/common/app/flux/Store.js @@ -8,8 +8,8 @@ const initValue = { points: 0, hikesApp: { hikes: [], - currentHikes: {}, - currentQuestion: 1, + // lecture state + currentHike: {}, showQuestion: false } }; @@ -22,13 +22,31 @@ export default Store({ init({ instance: appStore, args: [cat] }) { const { updateRoute, getUser, setTitle } = cat.getActions('appActions'); const register = createRegistrar(appStore); - const { toggleQuestions, fetchHikes } = cat.getActions('hikesActions'); + const { + toggleQuestions, + fetchHikes, + hideInfo, + grabQuestion, + releaseQuestion, + moveQuestion, + answer + } = cat.getActions('hikesActions'); // app register(setter(fromMany(getUser, setTitle, updateRoute))); // hikes - register(fromMany(fetchHikes, toggleQuestions)); + register( + fromMany( + toggleQuestions, + fetchHikes, + hideInfo, + grabQuestion, + releaseQuestion, + moveQuestion, + answer + ) + ); return appStore; } diff --git a/common/app/routes/Hikes/components/Questions.jsx b/common/app/routes/Hikes/components/Questions.jsx index 1a3160ef1e..e7d4cd9cd2 100644 --- a/common/app/routes/Hikes/components/Questions.jsx +++ b/common/app/routes/Hikes/components/Questions.jsx @@ -1,7 +1,6 @@ import React, { PropTypes } from 'react'; -import { Motion } from 'react-motion'; +import { spring, Motion } from 'react-motion'; import { contain } from 'thundercats-react'; -import debugFactory from 'debug'; import { Button, Col, @@ -10,19 +9,32 @@ import { Row } from 'react-bootstrap'; -const debug = debugFactory('freecc:hikes'); const ANSWER_THRESHOLD = 200; export default contain( { store: 'appStore', - actions: ['hikesAction'], - map(state) { - const { currentQuestion, currentHike } = state.hikesApp; - + actions: ['hikesActions'], + map({ hikesApp }) { + const { + currentHike, + currentQuestion = 1, + mouse = [0, 0], + isCorrect = false, + delta = [0, 0], + isPressed = false, + showInfo = false, + shake = false + } = hikesApp; return { hike: currentHike, - currentQuestion + currentQuestion, + mouse, + isCorrect, + delta, + isPressed, + showInfo, + shake }; } }, @@ -30,150 +42,89 @@ export default contain( displayName: 'Questions', propTypes: { - dashedName: PropTypes.string, - currentQuestion: PropTypes.number, hike: PropTypes.object, + currentQuestion: PropTypes.number, + mouse: PropTypes.array, + isCorrect: PropTypes.bool, + delta: PropTypes.array, + isPressed: PropTypes.bool, + showInfo: PropTypes.bool, + shake: PropTypes.bool, hikesActions: PropTypes.object }, - getInitialState: () => ({ - mouse: [0, 0], - correct: false, - delta: [0, 0], - isPressed: false, - showInfo: false, - shake: false - }), - - getTweenValues() { - const { mouse: [x, y] } = this.state; - return { - val: { x, y }, - config: [120, 10] - }; - }, - handleMouseDown({ pageX, pageY, touches }) { if (touches) { ({ pageX, pageY } = touches[0]); } - const { mouse: [pressX, pressY] } = this.state; - const dx = pageX - pressX; - const dy = pageY - pressY; - this.setState({ - isPressed: true, - delta: [dx, dy], - mouse: [pageX - dx, pageY - dy] - }); + const { mouse: [pressX, pressY], hikesActions } = this.props; + hikesActions.grabQuestion({ pressX, pressY, pageX, pageY }); }, handleMouseUp() { - const { correct } = this.state; - if (correct) { - return this.setState({ - isPressed: false, - delta: [0, 0] - }); + if (!this.props.isPressed) { + return null; } - this.setState({ - isPressed: false, - mouse: [0, 0], - delta: [0, 0] - }); + this.props.hikesActions.releaseQuestion(); }, handleMouseMove(answer) { + if (!this.props.isPressed) { + return () => {}; + } + return (e) => { let { pageX, pageY, touches } = e; if (touches) { e.preventDefault(); - // these reassins the values of pageX, pageY from touches + // these re-assigns the values of pageX, pageY from touches ({ pageX, pageY } = touches[0]); } - const { isPressed, delta: [dx, dy] } = this.state; - if (isPressed) { - const mouse = [pageX - dx, pageY - dy]; - if (mouse[0] >= ANSWER_THRESHOLD) { - this.handleMouseUp(); - return this.onAnswer(answer, true)(); - } - if (mouse[0] <= -ANSWER_THRESHOLD) { - this.handleMouseUp(); - return this.onAnswer(answer, false)(); - } - this.setState({ mouse }); + const { delta: [dx, dy], hikesActions } = this.props; + const mouse = [pageX - dx, pageY - dy]; + + if (mouse[0] >= ANSWER_THRESHOLD) { + return this.onAnswer(answer, true)(); } + + if (mouse[0] <= -ANSWER_THRESHOLD) { + return this.onAnswer(answer, false)(); + } + + return hikesActions.moveQuestion(mouse); }; }, - hideInfo() { - this.setState({ showInfo: false }); - }, - onAnswer(answer, userAnswer) { + const { hikesActions } = this.props; return (e) => { if (e && e.preventDefault) { e.preventDefault(); } - if (this.disposeTimeout) { - clearTimeout(this.disposeTimeout); - this.disposeTimeout = null; - } - - if (answer === userAnswer) { - debug('correct answer!'); - this.setState({ - correct: true, - mouse: [ userAnswer ? 1000 : -1000, 0] - }); - this.disposeTimeout = setTimeout(() => { - this.onCorrectAnswer(); - }, 1000); - return; - } - - debug('incorrect'); - this.setState({ - showInfo: true, - shake: true - }); - - this.disposeTimeout = setTimeout( - () => this.setState({ shake: false }), - 500 - ); + return hikesActions.answer({ answer, userAnswer, props: this.props }); }; }, - onCorrectAnswer() { - const { - hikesActions, - hike: { id, name } - } = this.props; - - hikesActions.completedHike({ id, name }); - }, - routerWillLeave(nextState, router, cb) { // TODO(berks): do animated transitions here stuff here this.setState({ showInfo: false, - correct: false, + isCorrect: false, mouse: [0, 0] }, cb); }, - renderInfo(showInfo, info) { + renderInfo(showInfo, info, hideInfo) { if (!info) { return null; } return (

@@ -184,7 +135,7 @@ export default contain( @@ -193,8 +144,7 @@ export default contain( }, renderQuestion(number, question, answer, shake) { - return ({ x: xFunc }) => { - const x = xFunc().val.x; + return ({ x }) => { const style = { WebkitTransform: `translate3d(${ x }px, 0, 0)`, transform: `translate3d(${ x }px, 0, 0)` @@ -219,10 +169,12 @@ export default contain( }, render() { - const { showInfo, shake } = this.state; + const { showInfo, shake } = this.props; const { hike: { tests = [] } = {}, - currentQuestion + mouse: [x], + currentQuestion, + hikesActions } = this.props; const [ question, answer, info ] = tests[currentQuestion - 1] || []; @@ -233,21 +185,21 @@ export default contain( xs={ 8 } xsOffset={ 2 }> - + { this.renderQuestion(currentQuestion, question, answer, shake) } - { this.renderInfo(showInfo, info) } + { this.renderInfo(showInfo, info, hikesActions.hideInfo) } diff --git a/common/app/routes/Hikes/flux/Actions.js b/common/app/routes/Hikes/flux/Actions.js index 56fe187350..1078f87e55 100644 --- a/common/app/routes/Hikes/flux/Actions.js +++ b/common/app/routes/Hikes/flux/Actions.js @@ -35,6 +35,20 @@ function findNextHike(hikes, id) { return hikes[currentIndex + 1] || hikes[0]; } +function releaseQuestion(state) { + const oldHikesApp = state.hikesApp; + const hikesApp = { + ...oldHikesApp, + isPressed: false, + delta: [0, 0], + mouse: oldHikesApp.isCorrect ? + oldHikesApp.mouse : + [0, 0] + }; + + return { ...state, hikesApp }; +} + export default Actions({ refs: { displayName: 'HikesActions' }, shouldBindMethods: true, @@ -74,14 +88,111 @@ export default Actions({ toggleQuestions() { return { transform(state) { - state.hikesApp.showQuestions = !state.hikesApp.showQuestions; - return Object.assign({}, state); + const hikesApp = { ...state.hikesApp, showQuestions: true }; + return { ...state, hikesApp }; } }; }, - completedHike(data = {}) { - return this.postJSON$('/completed-challenge', data) + hideInfo() { + return { + transform(state) { + const hikesApp = { ...state.hikesApp, showInfo: false }; + return { ...state, hikesApp }; + } + }; + }, + + grabQuestion({ pressX, pressY, pageX, pageY }) { + const dx = pageX - pressX; + const dy = pageY - pressY; + + const delta = [dx, dy]; + const mouse = [pageX - dx, pageY - dy]; + + return { + transform(state) { + const hikesApp = { ...state.hikesApp, isPressed: true, delta, mouse }; + return { ...state, hikesApp }; + } + }; + }, + + releaseQuestion() { + return { transform: releaseQuestion }; + }, + + moveQuestion(mouse) { + return { + transform(state) { + const hikesApp = { ...state.hikesApp, mouse }; + return { ...state, hikesApp }; + } + }; + }, + + answer({ + answer, + userAnswer, + props: { + hike: { id, name, tests, challengeType }, + currentQuestion + } + }) { + + // incorrect question + if (answer !== userAnswer) { + const startShake = { + transform(state) { + const hikesApp = { ...state.hikesApp, showInfo: true, shake: true }; + return { ...state, hikesApp }; + } + }; + + const removeShake = { + transform(state) { + const hikesApp = { ...state.hikesApp, shake: false }; + return { ...state, hikesApp }; + } + }; + + return Observable + .just(removeShake) + .delay(500) + .startWith({ transform: releaseQuestion }, startShake); + } + + // move to next question + if (tests[currentQuestion + 1]) { + + return { + transform(state) { + + const hikesApp = { + ...state.hikesApp, + currentQuestion: currentQuestion + 1 + }; + + return { ...state, hikesApp }; + } + }; + } + + // challenge completed + const correctAnswer = { + transform(state) { + const hikesApp = { + ...state.hikesApp, + isCorrect: true, + isPressed: false, + delta: [0, 0], + mouse: [ userAnswer ? 1000 : -1000, 0] + }; + return { ...state, hikesApp }; + } + }; + + return this.post$('/completed-challenge', { id, name, challengeType }) .map(() => { return { transform(state) { @@ -98,6 +209,7 @@ export default Actions({ } }; }) + .startWith(correctAnswer) .catch(err => { console.error(err); return Observable.just({ diff --git a/common/utils/ajax-stream.js b/common/utils/ajax-stream.js index 3bdcc2abdc..ba7b428b2a 100644 --- a/common/utils/ajax-stream.js +++ b/common/utils/ajax-stream.js @@ -17,7 +17,7 @@ */ import debugFactory from 'debug'; -import { AnonymousObservable, helpers } from 'rx'; +import { Observable, AnonymousObservable, helpers } from 'rx'; const debug = debugFactory('freecc:ajax$'); const root = typeof window !== 'undefined' ? window : {}; @@ -147,8 +147,12 @@ export function ajax$(options) { var processResponse = function(xhr, e) { var status = xhr.status === 1223 ? 204 : xhr.status; if ((status >= 200 && status <= 300) || status === 0 || status === '') { - observer.onNext(normalizeSuccess(e, xhr, settings)); - observer.onCompleted(); + try { + observer.onNext(normalizeSuccess(e, xhr, settings)); + observer.onCompleted(); + } catch (err) { + observer.onError(err); + } } else { observer.onError(normalizeError(e, xhr, 'error')); } @@ -228,8 +232,8 @@ export function ajax$(options) { settings.hasContent && settings.body ); xhr.send(settings.hasContent && settings.body || null); - } catch (e) { - observer.onError(e); + } catch (err) { + observer.onError(err); } return function() { @@ -247,13 +251,25 @@ export function ajax$(options) { * from the Ajax POST. */ export function post$(url, body) { + try { + body = JSON.stringify(body); + } catch (e) { + return Observable.throw(e); + } + return ajax$({ url, body, method: 'POST' }); } export function postJSON$(url, body) { + try { + body = JSON.stringify(body); + } catch (e) { + return Observable.throw(e); + } + return ajax$({ url, - body: JSON.stringify(body), + body, method: 'POST', responseType: 'json', headers: { 'Content-Type': 'application/json' }