diff --git a/common/app/Cat.js b/common/app/Cat.js index fb442e3db6..2953c8aa5f 100644 --- a/common/app/Cat.js +++ b/common/app/Cat.js @@ -2,10 +2,17 @@ import { Cat } from 'thundercats'; import stamp from 'stampit'; import { Disposable, Observable } from 'rx'; +import { 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$ + } +}); + export default Cat().init(({ instance: cat, args: [services] }) => { const serviceStamp = stamp({ methods: { @@ -30,7 +37,7 @@ export default Cat().init(({ instance: cat, args: [services] }) => { } }); - cat.register(HikesActions.compose(serviceStamp), null, services); + cat.register(HikesActions.compose(serviceStamp, ajaxStamp), null, services); cat.register(AppActions.compose(serviceStamp), null, services); cat.register(AppStore, null, cat); diff --git a/common/app/flux/Store.js b/common/app/flux/Store.js index 59f2f7e959..b052365a92 100644 --- a/common/app/flux/Store.js +++ b/common/app/flux/Store.js @@ -8,7 +8,9 @@ const initValue = { points: 0, hikesApp: { hikes: [], - currentHikes: {} + currentHikes: {}, + currentQuestion: 1, + showQuestion: false } }; @@ -20,13 +22,13 @@ export default Store({ init({ instance: appStore, args: [cat] }) { const { updateRoute, getUser, setTitle } = cat.getActions('appActions'); const register = createRegistrar(appStore); - const { fetchHikes } = cat.getActions('hikesActions'); + const { toggleQuestions, fetchHikes } = cat.getActions('hikesActions'); // app register(setter(fromMany(getUser, setTitle, updateRoute))); // hikes - register(fetchHikes); + register(fromMany(fetchHikes, toggleQuestions)); return appStore; } diff --git a/common/app/routes/Hikes/components/Hike.jsx b/common/app/routes/Hikes/components/Hike.jsx index de5367ed09..7edb724a92 100644 --- a/common/app/routes/Hikes/components/Hike.jsx +++ b/common/app/routes/Hikes/components/Hike.jsx @@ -12,38 +12,22 @@ export default React.createClass({ displayName: 'Hike', propTypes: { - dashedName: PropTypes.string, currentHike: PropTypes.object, showQuestions: PropTypes.bool }, - renderBody(showQuestions, currentHike, dashedName) { + renderBody(showQuestions) { if (showQuestions) { - return ( - - ); + return ; } - - const { - challengeSeed: [ id ] = ['1'], - description = [] - } = currentHike; - - return ( - - ); + return ; }, render() { const { - currentHike = {}, - dashedName, + currentHike: { title } = {}, showQuestions } = this.props; - const { title } = currentHike; const videoTitle =

{ title }

; @@ -53,7 +37,7 @@ export default React.createClass({ - { this.renderBody(showQuestions, currentHike, dashedName) } + { this.renderBody(showQuestions) } diff --git a/common/app/routes/Hikes/components/Hikes.jsx b/common/app/routes/Hikes/components/Hikes.jsx index 33cf820666..95aef149fd 100644 --- a/common/app/routes/Hikes/components/Hikes.jsx +++ b/common/app/routes/Hikes/components/Hikes.jsx @@ -31,7 +31,8 @@ export default contain( children: PropTypes.element, currentHike: PropTypes.object, hikes: PropTypes.array, - params: PropTypes.object + params: PropTypes.object, + showQuestions: PropTypes.bool }, componentWillMount() { @@ -45,15 +46,15 @@ export default contain( ); }, - renderChild(children, hikes, currentHike, dashedName) { + renderChild({ children, ...props }) { if (!children) { return null; } - return React.cloneElement(children, { hikes, currentHike, dashedName }); + return React.cloneElement(children, props); }, render() { - const { hikes, children, currentHike } = this.props; + const { hikes } = this.props; const { dashedName } = this.props.params; const preventOverflow = { overflow: 'hidden' }; return ( @@ -61,7 +62,7 @@ export default contain( { // render sub-route - this.renderChild(children, hikes, currentHike, dashedName) || + this.renderChild({ ...this.props, dashedName }) || // if no sub-route render hikes map this.renderMap(hikes) } diff --git a/common/app/routes/Hikes/components/Lecture.jsx b/common/app/routes/Hikes/components/Lecture.jsx index 86ab935c6f..be26aa540a 100644 --- a/common/app/routes/Hikes/components/Lecture.jsx +++ b/common/app/routes/Hikes/components/Lecture.jsx @@ -1,4 +1,5 @@ import React, { PropTypes } from 'react'; +import { contain } from 'thundercats-react'; import { Button, Col, Row } from 'react-bootstrap'; import { History } from 'react-router'; import Vimeo from 'react-vimeo'; @@ -6,53 +7,82 @@ import debugFactory from 'debug'; const debug = debugFactory('freecc:hikes'); -export default React.createClass({ - displayName: 'Lecture', - mixins: [History], +export default contain( + { + actions: ['hikesActions'], + store: 'appStore', + map(state) { + const { + currentHike: { + dashedName, + description, + challengeSeed: [id] = [0] + } = {} + } = state.hikesApp; - propTypes: { - dashedName: PropTypes.string, - description: PropTypes.array, - id: PropTypes.string + return { + dashedName, + description, + id + }; + } }, + React.createClass({ + displayName: 'Lecture', + mixins: [History], - handleError: debug, + propTypes: { + dashedName: PropTypes.string, + description: PropTypes.array, + id: PropTypes.string, + hikesActions: PropTypes.object + }, - handleFinish() { - debug('loading questions'); - }, + shouldComponentUpdate(nextProps) { + const { props } = this; + return nextProps.id !== props.id; + }, - renderTranscript(transcript, dashedName) { - return transcript.map((line, index) => ( -

{ line }

- )); - }, + handleError: debug, - render() { - const { - id = '1', - dashedName, - description = [] - } = this.props; + handleFinish(hikesActions) { + debug('loading questions'); + hikesActions.toggleQuestions(); + }, - return ( - - - - - - { this.renderTranscript(description, dashedName) } - - - - ); - } -}); + renderTranscript(transcript, dashedName) { + return transcript.map((line, index) => ( +

{ line }

+ )); + }, + + render() { + const { + id = '1', + description = [], + hikesActions + } = this.props; + const dashedName = 'foo'; + + return ( + + + + + + { this.renderTranscript(description, dashedName) } + + + + ); + } + }) +); diff --git a/common/app/routes/Hikes/components/Questions.jsx b/common/app/routes/Hikes/components/Questions.jsx index d426b14916..1a3160ef1e 100644 --- a/common/app/routes/Hikes/components/Questions.jsx +++ b/common/app/routes/Hikes/components/Questions.jsx @@ -1,6 +1,6 @@ import React, { PropTypes } from 'react'; import { Motion } from 'react-motion'; -import { History, Lifecycle } from 'react-router'; +import { contain } from 'thundercats-react'; import debugFactory from 'debug'; import { Button, @@ -10,269 +10,250 @@ import { Row } from 'react-bootstrap'; -import { postJSON$ } from '../../../../utils/ajax-stream.js'; - const debug = debugFactory('freecc:hikes'); const ANSWER_THRESHOLD = 200; -export default React.createClass({ - displayName: 'Questions', +export default contain( + { + store: 'appStore', + actions: ['hikesAction'], + map(state) { + const { currentQuestion, currentHike } = state.hikesApp; - mixins: [ - History, - Lifecycle - ], - - propTypes: { - currentHike: PropTypes.object, - dashedName: PropTypes.string, - hikes: PropTypes.array, - params: 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]); + return { + hike: currentHike, + currentQuestion + }; } - 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] - }); }, + React.createClass({ + displayName: 'Questions', - handleMouseUp() { - const { correct } = this.state; - if (correct) { - return this.setState({ - isPressed: false, - delta: [0, 0] - }); - } - this.setState({ - isPressed: false, + propTypes: { + dashedName: PropTypes.string, + currentQuestion: PropTypes.number, + hike: PropTypes.object, + hikesActions: PropTypes.object + }, + + getInitialState: () => ({ mouse: [0, 0], - delta: [0, 0] - }); - }, + correct: false, + delta: [0, 0], + isPressed: false, + showInfo: false, + shake: false + }), - handleMouseMove(answer) { - return (e) => { - let { pageX, pageY, touches } = e; + getTweenValues() { + const { mouse: [x, y] } = this.state; + return { + val: { x, y }, + config: [120, 10] + }; + }, + handleMouseDown({ pageX, pageY, touches }) { if (touches) { - e.preventDefault(); - // these reassins 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 }); - } - }; - }, - - hideInfo() { - this.setState({ showInfo: false }); - }, - - onAnswer(answer, userAnswer) { - 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'); + const { mouse: [pressX, pressY] } = this.state; + const dx = pageX - pressX; + const dy = pageY - pressY; this.setState({ - showInfo: true, - shake: true + isPressed: true, + delta: [dx, dy], + mouse: [pageX - dx, pageY - dy] }); + }, - this.disposeTimeout = setTimeout( - () => this.setState({ shake: false }), - 500 - ); - }; - }, - - onCorrectAnswer() { - const { hikes, currentHike } = this.props; - const { dashedName, number } = this.props.params; - const { id, name, difficulty, tests } = currentHike; - const nextQuestionIndex = +number; - - postJSON$('/completed-challenge', { id, name }).subscribeOnCompleted(() => { - if (tests[nextQuestionIndex]) { - return this.history.pushState( - null, - `/hikes/${ dashedName }/questions/${ nextQuestionIndex + 1 }` - ); + handleMouseUp() { + const { correct } = this.state; + if (correct) { + return this.setState({ + isPressed: false, + delta: [0, 0] + }); } - // next questions does not exist; - debug('finding next hike'); - const nextHike = [].slice.call(hikes) - // hikes is in oder of difficulty, lets get reverse order - .reverse() - // now lets find the hike with the difficulty right above this one - .reduce((lowerHike, hike) => { - if (hike.difficulty > difficulty) { - return hike; + this.setState({ + isPressed: false, + mouse: [0, 0], + delta: [0, 0] + }); + }, + + handleMouseMove(answer) { + return (e) => { + let { pageX, pageY, touches } = e; + + if (touches) { + e.preventDefault(); + // these reassins 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)(); } - return lowerHike; - }, null); - - if (nextHike) { - return this.history.pushState(null, `/hikes/${ nextHike.dashedName }`); - } - debug( - 'next Hike was not found, currentHike %s', - currentHike.dashedName - ); - this.history.pushState(null, '/hikes'); - }); - }, - - routerWillLeave(nextState, router, cb) { - // TODO(berks): do animated transitions here stuff here - this.setState({ - showInfo: false, - correct: false, - mouse: [0, 0] - }, cb); - }, - - renderInfo(showInfo, info) { - if (!info) { - return null; - } - return ( - - -

- { info } -

-
- - - -
- ); - }, - - renderQuestion(number, question, answer, shake) { - return ({ x: xFunc }) => { - const x = xFunc().val.x; - const style = { - WebkitTransform: `translate3d(${ x }px, 0, 0)`, - transform: `translate3d(${ x }px, 0, 0)` + if (mouse[0] <= -ANSWER_THRESHOLD) { + this.handleMouseUp(); + return this.onAnswer(answer, false)(); + } + this.setState({ mouse }); + } }; - const title =

Question { number }

; + }, + + hideInfo() { + this.setState({ showInfo: false }); + }, + + onAnswer(answer, userAnswer) { + 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 + ); + }; + }, + + 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, + mouse: [0, 0] + }, cb); + }, + + renderInfo(showInfo, info) { + if (!info) { + return null; + } return ( - -

{ question }

-
+ + +

+ { info } +

+
+ + + +
); - }; - }, + }, - render() { - const { showInfo, shake } = this.state; - const { currentHike: { tests = [] } } = this.props; - const { number = '1' } = this.props.params; - - const [question, answer, info] = tests[number - 1] || []; - - return ( - - - - { this.renderQuestion(number, question, answer, shake) } - - { this.renderInfo(showInfo, info) } - - - + renderQuestion(number, question, answer, shake) { + return ({ x: xFunc }) => { + const x = xFunc().val.x; + const style = { + WebkitTransform: `translate3d(${ x }px, 0, 0)`, + transform: `translate3d(${ x }px, 0, 0)` + }; + const title =

Question { number }

; + return ( + +

{ question }

-
- - ); - } -}); + ); + }; + }, + + render() { + const { showInfo, shake } = this.state; + const { + hike: { tests = [] } = {}, + currentQuestion + } = this.props; + + const [ question, answer, info ] = tests[currentQuestion - 1] || []; + + return ( + + + + { this.renderQuestion(currentQuestion, question, answer, shake) } + + { this.renderInfo(showInfo, info) } + + + + + + + ); + } + }) +); diff --git a/common/app/routes/Hikes/flux/Actions.js b/common/app/routes/Hikes/flux/Actions.js index 3f5e262f31..56fe187350 100644 --- a/common/app/routes/Hikes/flux/Actions.js +++ b/common/app/routes/Hikes/flux/Actions.js @@ -1,3 +1,5 @@ +import _ from 'lodash'; +import { Observable } from 'rx'; import { Actions } from 'thundercats'; import debugFactory from 'debug'; @@ -24,6 +26,15 @@ function getCurrentHike(hikes = [{}], dashedName, currentHike) { }, currentHike || {}); } +function findNextHike(hikes, id) { + if (!id) { + debug('find next hike no id provided'); + return hikes[0]; + } + const currentIndex = _.findIndex(hikes, ({ id: _id }) => _id === id); + return hikes[currentIndex + 1] || hikes[0]; +} + export default Actions({ refs: { displayName: 'HikesActions' }, shouldBindMethods: true, @@ -47,19 +58,53 @@ export default Actions({ return this.readService$('hikes', null, null) .map(hikes => { - const hikesApp = { - hikes, - currentHike: getCurrentHike(hikes, dashedName) - }; - + const currentHike = getCurrentHike(hikes, dashedName); return { - transform(oldState) { - return Object.assign({}, oldState, { hikesApp }); + transform(state) { + const hikesApp = { ...state.hikesApp, currentHike, hikes }; + return { ...state, hikesApp }; } }; }) .catch(err => { console.error(err); }); + }, + + toggleQuestions() { + return { + transform(state) { + state.hikesApp.showQuestions = !state.hikesApp.showQuestions; + return Object.assign({}, state); + } + }; + }, + + completedHike(data = {}) { + return this.postJSON$('/completed-challenge', data) + .map(() => { + return { + transform(state) { + const { hikes, currentHike: { id } } = state.hikesApp; + const currentHike = findNextHike(hikes, id); + + // go to next route + state.route = currentHike && currentHike.dashedName ? + `/hikes/${ currentHike.dashedName }` : + '/hikes'; + + const hikesApp = { ...state.hikesApp, currentHike }; + return { ...state, hikesApp }; + } + }; + }) + .catch(err => { + console.error(err); + return Observable.just({ + set: { + error: err + } + }); + }); } });