Merge pull request #5871 from FreeCodeCamp/feature/hikes

Release hikes
This commit is contained in:
Logan Tegman
2016-01-07 11:02:32 -08:00
33 changed files with 1197 additions and 783 deletions

View File

@ -6,9 +6,10 @@ import debugFactory from 'debug';
import { Router } from 'react-router'; import { Router } from 'react-router';
import { createLocation, createHistory } from 'history'; import { createLocation, createHistory } from 'history';
import { hydrate } from 'thundercats'; import { hydrate } from 'thundercats';
import { Render } from 'thundercats-react'; import { render$ } from 'thundercats-react';
import { app$ } from '../common/app'; import { app$ } from '../common/app';
import synchroniseHistory from './synchronise-history';
const debug = debugFactory('fcc:client'); const debug = debugFactory('fcc:client');
const DOMContianer = document.getElementById('fcc'); const DOMContianer = document.getElementById('fcc');
@ -23,56 +24,55 @@ const appLocation = createLocation(
location.pathname + location.search location.pathname + location.search
); );
function location$(history) {
return Rx.Observable.create(function(observer) {
const dispose = history.listen(function(location) {
observer.onNext(location.pathname);
});
return Rx.Disposable.create(() => {
dispose();
});
});
}
// returns an observable // returns an observable
app$({ history, location: appLocation }) app$({ history, location: appLocation })
.flatMap( .flatMap(
({ AppCat }) => { ({ AppCat }) => {
// instantiate the cat with service // instantiate the cat with service
const appCat = AppCat(null, services); const appCat = AppCat(null, services, history);
// hydrate the stores // hydrate the stores
return hydrate(appCat, catState) return hydrate(appCat, catState).map(() => appCat);
.map(() => appCat);
}, },
// not using nextLocation at the moment but will be used for // not using nextLocation at the moment but will be used for
// redirects in the future // redirects in the future
({ nextLocation, props }, appCat) => ({ nextLocation, props, appCat }) ({ nextLocation, props }, appCat) => ({ nextLocation, props, appCat })
) )
.doOnNext(({ appCat }) => { .doOnNext(({ appCat }) => {
const appActions = appCat.getActions('appActions'); const { updateLocation, goTo, goBack } = appCat.getActions('appActions');
const appStore$ = appCat.getStore('appStore');
location$(history) const routerState$ = appStore$
.pluck('pathname') .map(({ location }) => location)
.distinctUntilChanged(
location => location && location.key ? location.key : location
);
// set page title
appStore$
.pluck('title')
.doOnNext(title => document.title = title)
.subscribe(() => {});
appStore$
.pluck('err')
.filter(err => !!err)
.distinctUntilChanged() .distinctUntilChanged()
.doOnNext(route => debug('route change', route)) .subscribe(err => console.error(err));
.subscribe(route => appActions.updateRoute(route));
appActions.goBack.subscribe(function() { synchroniseHistory(
history.goBack(); history,
}); updateLocation,
goTo,
appActions goBack,
.updateRoute routerState$
.pluck('route') );
.doOnNext(route => debug('update route', route))
.subscribe(function(route) {
history.pushState(null, route);
});
}) })
// allow store subscribe to subscribe to actions
.delay(10)
.flatMap(({ props, appCat }) => { .flatMap(({ props, appCat }) => {
props.history = history; props.history = history;
return Render(
return render$(
appCat, appCat,
React.createElement(Router, props), React.createElement(Router, props),
DOMContianer DOMContianer

View File

@ -0,0 +1,69 @@
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 synchroniseHistory(
history,
updateLocation,
goTo,
goBack,
routerState$
) {
routerState$.subscribe(
location => {
if (!location) {
return null;
}
// store location has changed, update history
if (location.key !== prevKey) {
isSyncing = true;
history.transitionTo({ ...emptyLocation, ...location });
isSyncing = false;
}
}
);
location$(history)
.doOnNext(location => {
prevKey = location.key;
if (isSyncing) {
return null;
}
return updateLocation(location);
})
.subscribe(() => {});
goTo
.doOnNext((route = '/') => {
history.push(route);
})
.subscribe(() => {});
goBack
.doOnNext(() => {
history.goBack();
})
.subscribe(() => {});
}

View File

@ -8,6 +8,9 @@ export default contain(
{ {
store: 'appStore', store: 'appStore',
fetchAction: 'appActions.getUser', fetchAction: 'appActions.getUser',
isPrimed({ username }) {
return !!username;
},
getPayload(props) { getPayload(props) {
return { return {
isPrimed: !!props.username isPrimed: !!props.username
@ -25,22 +28,6 @@ export default contain(
username: PropTypes.string username: PropTypes.string
}, },
componentDidMount() {
const title = this.props.title;
this.setTitle(title);
},
componentWillReceiveProps(nextProps) {
if (nextProps.title !== this.props.title) {
this.setTitle(nextProps.title);
}
},
setTitle(title) {
const doc = typeof document !== 'undefined' ? document : {};
doc.title = title;
},
render() { render() {
const { username, points, picture } = this.props; const { username, points, picture } = this.props;
const navProps = { username, points, picture }; const navProps = { username, points, picture };

View File

@ -1,17 +1,65 @@
import { Cat } from 'thundercats'; import { Cat } from 'thundercats';
import stamp from 'stampit';
import { Disposable, Observable } from 'rx';
import { post$, postJSON$ } from '../utils/ajax-stream.js';
import { AppActions, AppStore } from './flux'; import { AppActions, AppStore } from './flux';
import { HikesActions, HikesStore } from './routes/Hikes/flux'; import { HikesActions } from './routes/Hikes/flux';
import { JobActions, JobsStore} from './routes/Jobs/flux'; import { JobActions } from './routes/Jobs/flux';
export default Cat() const ajaxStamp = stamp({
.init(({ instance: cat, args: [services] }) => { methods: {
cat.register(AppActions, null, services); postJSON$,
cat.register(AppStore, null, cat); post$
}
});
cat.register(HikesActions, null, services); export default Cat().init(({ instance: cat, args: [services] }) => {
cat.register(HikesStore, null, cat); const serviceStamp = stamp({
methods: {
readService$(resource, params, config) {
cat.register(JobActions, null, cat, services); return Observable.create(function(observer) {
cat.register(JobsStore, null, cat); services.read(resource, params, config, (err, res) => {
if (err) {
return observer.onError(err);
}
observer.onNext(res);
observer.onCompleted();
}); });
return Disposable.create(function() {
observer.dispose();
});
});
},
createService$(resource, params, body, config) {
return Observable.create(function(observer) {
services.create(resource, params, body, config, (err, res) => {
if (err) {
return observer.onError(err);
}
observer.onNext(res);
observer.onCompleted();
});
return Disposable.create(function() {
observer.dispose();
});
});
}
}
});
cat.register(HikesActions.compose(serviceStamp, ajaxStamp), null, services);
cat.register(AppActions.compose(serviceStamp), null, services);
cat.register(
JobActions.compose(serviceStamp, ajaxStamp),
null,
cat,
services
);
cat.register(AppStore, null, cat);
});

View File

@ -1,49 +1,45 @@
import { Actions } from 'thundercats'; import { Actions } from 'thundercats';
import debugFactory from 'debug'; import { Observable } from 'rx';
const debug = debugFactory('freecc:app:actions');
export default Actions({ export default Actions({
shouldBindMethods: true,
refs: { displayName: 'AppActions' },
setTitle(title = 'Learn To Code') { setTitle(title = 'Learn To Code') {
return { title: title + '| Free Code Camp' }; return { title: title + ' | Free Code Camp' };
}, },
setUser({ getUser() {
return this.readService$('user', null, null)
.map(({
username, username,
picture, picture,
progressTimestamps = [], progressTimestamps = [],
isFrontEndCert, isFrontEndCert,
isBackEndCert,
isFullStackCert isFullStackCert
}) { }) => {
return { return {
username, username,
picture, picture,
points: progressTimestamps.length, points: progressTimestamps.length,
isFrontEndCert, isFrontEndCert,
isBackEndCert,
isFullStackCert isFullStackCert
}; };
})
.catch(err => Observable.just({ err }));
}, },
getUser: null, // routing
updateRoute(route) { goTo: null,
return { route }; goBack: null,
}, updateLocation(location) {
goBack: null return {
}) transform(state) {
.refs({ displayName: 'AppActions' }) return { ...state, location };
.init(({ instance: appActions, args: [services] }) => {
appActions.getUser.subscribe(({ isPrimed }) => {
if (isPrimed) {
debug('isPrimed');
return;
} }
services.read('user', null, null, (err, user) => { };
if (err) {
return debug('user service error');
} }
debug('user service returned successful'); });
return appActions.setUser(user);
});
});
return appActions;
});

View File

@ -5,7 +5,16 @@ const initValue = {
title: 'Learn To Code | Free Code Camp', title: 'Learn To Code | Free Code Camp',
username: null, username: null,
picture: null, picture: null,
points: 0 points: 0,
hikesApp: {
hikes: [],
// lecture state
currentHike: {},
showQuestions: false
},
jobsApp: {
showModal: false
}
}; };
export default Store({ export default Store({
@ -13,12 +22,82 @@ export default Store({
displayName: 'AppStore', displayName: 'AppStore',
value: initValue value: initValue
}, },
init({ instance: appStore, args: [cat] }) { init({ instance: store, args: [cat] }) {
const { updateRoute, setUser, setTitle } = cat.getActions('appActions'); const register = createRegistrar(store);
const register = createRegistrar(appStore); // app
const {
updateLocation,
getUser,
setTitle
} = cat.getActions('appActions');
register(setter(fromMany(setUser, setTitle, updateRoute))); register(
fromMany(
setter(
fromMany(
getUser,
setTitle
)
),
updateLocation
)
);
return appStore; // hikes
const {
toggleQuestions,
fetchHikes,
hideInfo,
resetHike,
grabQuestion,
releaseQuestion,
moveQuestion,
answer
} = cat.getActions('hikesActions');
register(
fromMany(
toggleQuestions,
fetchHikes,
hideInfo,
resetHike,
grabQuestion,
releaseQuestion,
moveQuestion,
answer
)
);
// jobs
const {
findJob,
saveJobToDb,
getJob,
getJobs,
openModal,
closeModal,
handleForm,
getSavedForm,
setPromoCode,
applyCode,
clearPromo
} = cat.getActions('JobActions');
register(
fromMany(
findJob,
saveJobToDb,
getJob,
getJobs,
openModal,
closeModal,
handleForm,
getSavedForm,
setPromoCode,
applyCode,
clearPromo
)
);
} }
}); });

View File

@ -0,0 +1,65 @@
import React, { PropTypes } from 'react';
import { contain } from 'thundercats-react';
import {
Col,
Panel,
Row
} from 'react-bootstrap';
import Lecture from './Lecture.jsx';
import Questions from './Questions.jsx';
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;
const videoTitle = <h4>{ title }</h4>;
return (
<Col xs={ 12 }>
<Row>
<Panel
className={ 'text-center' }
header={ videoTitle }
title={ title }>
{ this.renderBody(showQuestions) }
</Panel>
</Row>
</Col>
);
}
})
);

View File

@ -9,7 +9,10 @@ import HikesMap from './Map.jsx';
export default contain( export default contain(
{ {
store: 'hikesStore', store: 'appStore',
map(state) {
return state.hikesApp;
},
actions: ['appActions'], actions: ['appActions'],
fetchAction: 'hikesActions.fetchHikes', fetchAction: 'hikesActions.fetchHikes',
getPayload: ({ hikes, params }) => ({ getPayload: ({ hikes, params }) => ({
@ -27,7 +30,9 @@ export default contain(
appActions: PropTypes.object, appActions: PropTypes.object,
children: PropTypes.element, children: PropTypes.element,
currentHike: PropTypes.object, currentHike: PropTypes.object,
hikes: PropTypes.array hikes: PropTypes.array,
params: PropTypes.object,
showQuestions: PropTypes.bool
}, },
componentWillMount() { componentWillMount() {
@ -41,21 +46,26 @@ export default contain(
); );
}, },
renderChild(children, hikes, currentHike) { renderChild({ children, ...props }) {
if (!children) { if (!children) {
return null; return null;
} }
return React.cloneElement(children, { hikes, currentHike }); return React.cloneElement(children, props);
}, },
render() { render() {
const { hikes, children, currentHike } = this.props; const { hikes } = this.props;
const { dashedName } = this.props.params;
const preventOverflow = { overflow: 'hidden' }; const preventOverflow = { overflow: 'hidden' };
return ( return (
<div> <div>
<Row style={ preventOverflow }> <Row style={ preventOverflow }>
{ this.renderChild(children, hikes, currentHike) || {
this.renderMap(hikes) } // render sub-route
this.renderChild({ ...this.props, dashedName }) ||
// if no sub-route render hikes map
this.renderMap(hikes)
}
</Row> </Row>
</div> </div>
); );

View File

@ -1,26 +1,53 @@
import React, { PropTypes } from 'react'; import React, { PropTypes } from 'react';
import { Button, Col, Row, Panel } from 'react-bootstrap'; import { contain } from 'thundercats-react';
import { Button, Col, Row } from 'react-bootstrap';
import { History } from 'react-router'; import { History } from 'react-router';
import Vimeo from 'react-vimeo'; import Vimeo from 'react-vimeo';
import debugFactory from 'debug'; import debugFactory from 'debug';
const debug = debugFactory('freecc:hikes'); const debug = debugFactory('freecc:hikes');
export default React.createClass({ export default contain(
{
actions: ['hikesActions'],
store: 'appStore',
map(state) {
const {
currentHike: {
dashedName,
description,
challengeSeed: [id] = [0]
} = {}
} = state.hikesApp;
return {
dashedName,
description,
id
};
}
},
React.createClass({
displayName: 'Lecture', displayName: 'Lecture',
mixins: [History], mixins: [History],
propTypes: { propTypes: {
currentHike: PropTypes.object, dashedName: PropTypes.string,
params: PropTypes.object description: PropTypes.array,
id: PropTypes.string,
hikesActions: PropTypes.object
},
shouldComponentUpdate(nextProps) {
const { props } = this;
return nextProps.id !== props.id;
}, },
handleError: debug, handleError: debug,
handleFinish() { handleFinish(hikesActions) {
debug('loading questions'); debug('loading questions');
const { dashedName } = this.props.params; hikesActions.toggleQuestions();
this.history.pushState(null, `/hikes/${dashedName}/questions/1`);
}, },
renderTranscript(transcript, dashedName) { renderTranscript(transcript, dashedName) {
@ -31,41 +58,31 @@ export default React.createClass({
render() { render() {
const { const {
title, id = '1',
challengeSeed = ['1'], description = [],
description = [] hikesActions
} = this.props.currentHike; } = this.props;
const { dashedName } = this.props.params; const dashedName = 'foo';
const [ id ] = challengeSeed;
const videoTitle = <h2>{ title }</h2>;
return ( return (
<Col xs={ 12 }> <Col xs={ 12 }>
<Row> <Row>
<Panel className={ 'text-center' } title={ videoTitle }>
<Vimeo <Vimeo
onError={ this.handleError } onError={ this.handleError }
onFinish= { this.handleFinish } onFinish= { () => this.handleFinish(hikesActions) }
videoId={ id } /> videoId={ id } />
</Panel>
</Row> </Row>
<Row> <Row>
<Col xs={ 12 }>
<Panel>
{ this.renderTranscript(description, dashedName) } { this.renderTranscript(description, dashedName) }
</Panel>
<Panel>
<Button <Button
block={ true } block={ true }
bsSize='large' bsSize='large'
onClick={ this.handleFinish }> onClick={ () => this.handleFinish(hikesActions) }>
Take me to the Questions Take me to the Questions
</Button> </Button>
</Panel>
</Col>
</Row> </Row>
</Col> </Col>
); );
} }
}); })
);

View File

@ -11,7 +11,7 @@ export default React.createClass({
render() { render() {
const { const {
hikes hikes = [{}]
} = this.props; } = this.props;
const vidElements = hikes.map(({ title, dashedName}) => { const vidElements = hikes.map(({ title, dashedName}) => {

View File

@ -1,278 +0,0 @@
import React, { PropTypes } from 'react';
import { Motion } from 'react-motion';
import { History, Lifecycle } from 'react-router';
import debugFactory from 'debug';
import {
Button,
Col,
Modal,
Panel,
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: 'Question',
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]);
}
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]
});
},
handleMouseUp() {
const { correct } = this.state;
if (correct) {
return this.setState({
isPressed: false,
delta: [0, 0]
});
}
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)();
}
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');
this.setState({
showInfo: true,
shake: true
});
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 }`
);
}
// 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;
}
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 (
<Modal
backdrop={ true }
onHide={ this.hideInfo }
show={ showInfo }>
<Modal.Body>
<h3>
{ info }
</h3>
</Modal.Body>
<Modal.Footer>
<Button
block={ true }
bsSize='large'
onClick={ this.hideInfo }>
hide
</Button>
</Modal.Footer>
</Modal>
);
},
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 = <h4>Question { number }</h4>;
return (
<Panel
className={ shake ? 'animated swing shake' : '' }
header={ title }
onMouseDown={ this.handleMouseDown }
onMouseLeave={ this.handleMouseUp }
onMouseMove={ this.handleMouseMove(answer) }
onMouseUp={ this.handleMouseUp }
onTouchEnd={ this.handleMouseUp }
onTouchMove={ this.handleMouseMove(answer) }
onTouchStart={ this.handleMouseDown }
style={ style }>
<p>{ question }</p>
</Panel>
);
};
},
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 (
<Col
onMouseUp={ this.handleMouseUp }
xs={ 8 }
xsOffset={ 2 }>
<Row>
<Motion style={{ x: this.getTweenValues }}>
{ this.renderQuestion(number, question, answer, shake) }
</Motion>
{ this.renderInfo(showInfo, info) }
<Panel>
<Button
bsSize='large'
className='pull-left'
onClick={ this.onAnswer(answer, false, info) }>
false
</Button>
<Button
bsSize='large'
className='pull-right'
onClick={ this.onAnswer(answer, true, info) }>
true
</Button>
</Panel>
</Row>
</Col>
);
}
});

View File

@ -0,0 +1,217 @@
import React, { PropTypes } from 'react';
import { spring, Motion } from 'react-motion';
import { contain } from 'thundercats-react';
import {
Button,
Col,
Modal,
Panel,
Row
} from 'react-bootstrap';
const ANSWER_THRESHOLD = 200;
export default contain(
{
store: 'appStore',
actions: ['hikesActions'],
map({ hikesApp, username }) {
const {
currentHike,
currentQuestion = 1,
mouse = [0, 0],
isCorrect = false,
delta = [0, 0],
isPressed = false,
showInfo = false,
shake = false
} = hikesApp;
return {
hike: currentHike,
currentQuestion,
mouse,
isCorrect,
delta,
isPressed,
showInfo,
shake,
username
};
}
},
React.createClass({
displayName: 'Questions',
propTypes: {
hike: PropTypes.object,
currentQuestion: PropTypes.number,
mouse: PropTypes.array,
isCorrect: PropTypes.bool,
delta: PropTypes.array,
isPressed: PropTypes.bool,
showInfo: PropTypes.bool,
shake: PropTypes.bool,
username: PropTypes.string,
hikesActions: PropTypes.object
},
handleMouseDown({ pageX, pageY, touches }) {
if (touches) {
({ pageX, pageY } = touches[0]);
}
const { mouse: [pressX, pressY], hikesActions } = this.props;
hikesActions.grabQuestion({ pressX, pressY, pageX, pageY });
},
handleMouseUp() {
if (!this.props.isPressed) {
return null;
}
this.props.hikesActions.releaseQuestion();
},
handleMouseMove(answer) {
if (!this.props.isPressed) {
return () => {};
}
return (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: [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);
};
},
onAnswer(answer, userAnswer) {
const { hikesActions } = this.props;
return (e) => {
if (e && e.preventDefault) {
e.preventDefault();
}
return hikesActions.answer({
answer,
userAnswer,
props: this.props
});
};
},
routerWillLeave(nextState, router, cb) {
// TODO(berks): do animated transitions here stuff here
this.setState({
showInfo: false,
isCorrect: false,
mouse: [0, 0]
}, cb);
},
renderInfo(showInfo, info, hideInfo) {
if (!info) {
return null;
}
return (
<Modal
backdrop={ true }
onHide={ hideInfo }
show={ showInfo }>
<Modal.Body>
<h3>
{ info }
</h3>
</Modal.Body>
<Modal.Footer>
<Button
block={ true }
bsSize='large'
onClick={ hideInfo }>
hide
</Button>
</Modal.Footer>
</Modal>
);
},
renderQuestion(number, question, answer, shake) {
return ({ x }) => {
const style = {
WebkitTransform: `translate3d(${ x }px, 0, 0)`,
transform: `translate3d(${ x }px, 0, 0)`
};
const title = <h4>Question { number }</h4>;
return (
<Panel
className={ shake ? 'animated swing shake' : '' }
header={ title }
onMouseDown={ this.handleMouseDown }
onMouseLeave={ this.handleMouseUp }
onMouseMove={ this.handleMouseMove(answer) }
onMouseUp={ this.handleMouseUp }
onTouchEnd={ this.handleMouseUp }
onTouchMove={ this.handleMouseMove(answer) }
onTouchStart={ this.handleMouseDown }
style={ style }>
<p>{ question }</p>
</Panel>
);
};
},
render() {
const { showInfo, shake } = this.props;
const {
hike: { tests = [] } = {},
mouse: [x],
currentQuestion,
hikesActions
} = this.props;
const [ question, answer, info ] = tests[currentQuestion - 1] || [];
return (
<Col
onMouseUp={ this.handleMouseUp }
xs={ 8 }
xsOffset={ 2 }>
<Row>
<Motion style={{ x: spring(x, [120, 10]) }}>
{ this.renderQuestion(currentQuestion, question, answer, shake) }
</Motion>
{ this.renderInfo(showInfo, info, hikesActions.hideInfo) }
<Panel>
<Button
bsSize='large'
className='pull-left'
onClick={ this.onAnswer(answer, false) }>
false
</Button>
<Button
bsSize='large'
className='pull-right'
onClick={ this.onAnswer(answer, true) }>
true
</Button>
</Panel>
</Row>
</Col>
);
}
})
);

View File

@ -1,3 +1,5 @@
import _ from 'lodash';
import { Observable } from 'rx';
import { Actions } from 'thundercats'; import { Actions } from 'thundercats';
import debugFactory from 'debug'; import debugFactory from 'debug';
@ -24,41 +26,247 @@ function getCurrentHike(hikes = [{}], dashedName, currentHike) {
}, 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];
}
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({ export default Actions({
// start fetching hikes refs: { displayName: 'HikesActions' },
fetchHikes: null, shouldBindMethods: true,
// set hikes on store fetchHikes({ isPrimed, dashedName }) {
setHikes: null
})
.refs({ displayName: 'HikesActions' })
.init(({ instance: hikeActions, args: [services] }) => {
// set up hikes fetching
hikeActions.fetchHikes.subscribe(
({ isPrimed, dashedName }) => {
if (isPrimed) { if (isPrimed) {
return hikeActions.setHikes({ return {
transform: (oldState) => { transform: (state) => {
const { hikes } = oldState;
const { hikesApp: oldState } = state;
const currentHike = getCurrentHike( const currentHike = getCurrentHike(
hikes, oldState.hikes,
dashedName, dashedName,
oldState.currentHike oldState.currentHike
); );
return Object.assign({}, oldState, { currentHike });
const hikesApp = { ...oldState, currentHike };
return Object.assign({}, state, { hikesApp });
}
};
}
return this.readService$('hikes', null, null)
.map(hikes => {
const currentHike = getCurrentHike(hikes, dashedName);
return {
transform(state) {
const hikesApp = { ...state.hikesApp, currentHike, hikes };
return { ...state, hikesApp };
}
};
})
.catch(err => Observable.just({
transform(state) { return { ...state, err }; }
}));
},
toggleQuestions() {
return {
transform(state) {
const hikesApp = {
...state.hikesApp,
showQuestions: !state.hikesApp.showQuestions,
currentQuestion: 1
};
return { ...state, hikesApp };
}
};
},
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,
username
}
}) {
// 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
// index 0
if (tests[currentQuestion]) {
return Observable.just({
transform(state) {
const hikesApp = {
...state.hikesApp,
mouse: [0, 0],
showInfo: false
};
return { ...state, hikesApp };
}
})
.delay(300)
.startWith({
transform(state) {
const hikesApp = {
...state.hikesApp,
currentQuestion: currentQuestion + 1,
mouse: [ userAnswer ? 1000 : -1000, 0],
isPressed: false
};
return { ...state, hikesApp };
} }
}); });
} }
services.read('hikes', null, null, (err, hikes) => {
if (err) { // challenge completed
debug('an error occurred fetching hikes', err); const optimisticSave = username ?
this.post$('/completed-challenge', { id, name, challengeType }) :
Observable.just(true);
const correctAnswer = {
transform(state) {
const hikesApp = {
...state.hikesApp,
isCorrect: true,
isPressed: false,
delta: [0, 0],
mouse: [ userAnswer ? 1000 : -1000, 0]
};
return {
...state,
hikesApp
};
} }
hikeActions.setHikes({ };
set: {
hikes: hikes, return Observable.just({
currentHike: getCurrentHike(hikes, dashedName) transform(state) {
const { hikes, currentHike: { id } } = state.hikesApp;
const currentHike = findNextHike(hikes, id);
// go to next route
state.location = {
action: 'PUSH',
pathname: currentHike && currentHike.dashedName ?
`/hikes/${ currentHike.dashedName }` :
'/hikes'
};
const hikesApp = {
...state.hikesApp,
currentHike,
showQuestions: false,
currentQuestion: 1,
mouse: [0, 0]
};
return {
...state,
points: username ? state.points + 1 : state.points,
hikesApp
};
},
optimistic: optimisticSave
})
.delay(300)
.startWith(correctAnswer)
.catch(err => Observable.just({
transform(state) { return { ...state, err }; }
}));
},
resetHike() {
return {
transform(state) {
return { ...state,
hikesApp: {
...state.hikesApp,
currentQuestion: 1,
showQuestions: false,
showInfo: false,
mouse: [0, 0],
delta: [0, 0]
} }
}); };
});
} }
); };
}); }
});

View File

@ -1,20 +0,0 @@
import { Store } from 'thundercats';
const initialValue = {
hikes: [],
currentHike: {}
};
export default Store({
refs: {
displayName: 'HikesStore',
value: initialValue
},
init({ instance: hikeStore, args: [cat] }) {
let { setHikes } = cat.getActions('hikesActions');
hikeStore.register(setHikes);
return hikeStore;
}
});

View File

@ -1,2 +1 @@
export { default as HikesActions } from './Actions'; export { default as HikesActions } from './Actions';
export { default as HikesStore } from './Store';

View File

@ -1,6 +1,5 @@
import Hikes from './components/Hikes.jsx'; import Hikes from './components/Hikes.jsx';
import Lecture from './components/Lecture.jsx'; import Hike from './components/Hike.jsx';
import Question from './components/Question.jsx';
/* /*
* show video /hikes/someVideo * show video /hikes/someVideo
@ -12,9 +11,6 @@ export default {
component: Hikes, component: Hikes,
childRoutes: [{ childRoutes: [{
path: ':dashedName', path: ':dashedName',
component: Lecture component: Hike
}, {
path: ':dashedName/questions/:number',
component: Question
}] }]
}; };

View File

@ -11,13 +11,13 @@ const paypalIds = {
export default contain( export default contain(
{ {
store: 'JobsStore', store: 'appStore',
actions: [ actions: [
'jobActions', 'jobActions',
'appActions' 'appActions'
], ],
map({ map({ jobsApp: {
job: { id, isHighlighted } = {}, currentJob: { id, isHighlighted } = {},
buttonId = isHighlighted ? buttonId = isHighlighted ?
paypalIds.highlighted : paypalIds.highlighted :
paypalIds.regular, paypalIds.regular,
@ -25,8 +25,8 @@ export default contain(
discountAmount = 0, discountAmount = 0,
promoCode = '', promoCode = '',
promoApplied = false, promoApplied = false,
promoName promoName = ''
}) { }}) {
return { return {
id, id,
isHighlighted, isHighlighted,
@ -57,7 +57,7 @@ export default contain(
goToJobBoard() { goToJobBoard() {
const { appActions } = this.props; const { appActions } = this.props;
appActions.updateRoute('/jobs'); appActions.goTo('/jobs');
}, },
renderDiscount(discountAmount) { renderDiscount(discountAmount) {

View File

@ -6,8 +6,14 @@ import ListJobs from './List.jsx';
export default contain( export default contain(
{ {
store: 'jobsStore', store: 'appStore',
map({ jobsApp: { jobs, showModal }}) {
return { jobs, showModal };
},
fetchAction: 'jobActions.getJobs', fetchAction: 'jobActions.getJobs',
isPrimed({ jobs = [] }) {
return !!jobs.length;
},
actions: [ actions: [
'appActions', 'appActions',
'jobActions' 'jobActions'
@ -18,25 +24,19 @@ export default contain(
propTypes: { propTypes: {
children: PropTypes.element, children: PropTypes.element,
numOfFollowers: PropTypes.number,
appActions: PropTypes.object, appActions: PropTypes.object,
jobActions: PropTypes.object, jobActions: PropTypes.object,
jobs: PropTypes.array, jobs: PropTypes.array,
showModal: PropTypes.bool showModal: PropTypes.bool
}, },
componentDidMount() {
const { jobActions } = this.props;
jobActions.getFollowers();
},
handleJobClick(id) { handleJobClick(id) {
const { appActions, jobActions } = this.props; const { appActions, jobActions } = this.props;
if (!id) { if (!id) {
return null; return null;
} }
jobActions.findJob(id); jobActions.findJob(id);
appActions.updateRoute(`/jobs/${id}`); appActions.goTo(`/jobs/${id}`);
}, },
renderList(handleJobClick, jobs) { renderList(handleJobClick, jobs) {
@ -84,7 +84,7 @@ export default contain(
<Button <Button
className='signup-btn btn-block btn-cta' className='signup-btn btn-block btn-cta'
onClick={ ()=> { onClick={ ()=> {
appActions.updateRoute('/jobs/new'); appActions.goTo('/jobs/new');
}}> }}>
Post a job: $1,000 Post a job: $1,000
</Button> </Button>

View File

@ -103,9 +103,9 @@ function makeRequired(validator) {
} }
export default contain({ export default contain({
store: 'appStore',
actions: 'jobActions', actions: 'jobActions',
store: 'jobsStore', map({ jobsApp: { form = {} } }) {
map({ form = {} }) {
const { const {
position, position,
locale, locale,
@ -115,7 +115,7 @@ export default contain({
logo, logo,
company, company,
isFrontEndCert = true, isFrontEndCert = true,
isFullStackCert, isBackEndCert,
isHighlighted, isHighlighted,
isRemoteOk, isRemoteOk,
howToApply howToApply
@ -132,7 +132,7 @@ export default contain({
isRemoteOk: formatValue(isRemoteOk, null, 'bool'), isRemoteOk: formatValue(isRemoteOk, null, 'bool'),
howToApply: formatValue(howToApply, makeRequired(isAscii)), howToApply: formatValue(howToApply, makeRequired(isAscii)),
isFrontEndCert, isFrontEndCert,
isFullStackCert isBackEndCert
}; };
}, },
subscribeOnWillMount() { subscribeOnWillMount() {
@ -154,7 +154,7 @@ export default contain({
isHighlighted: PropTypes.object, isHighlighted: PropTypes.object,
isRemoteOk: PropTypes.object, isRemoteOk: PropTypes.object,
isFrontEndCert: PropTypes.bool, isFrontEndCert: PropTypes.bool,
isFullStackCert: PropTypes.bool, isBackEndCert: PropTypes.bool,
howToApply: PropTypes.object howToApply: PropTypes.object
}, },
@ -171,7 +171,11 @@ export default contain({
} }
}); });
if (!valid || !pros.isFrontEndCert && !pros.isFullStackCert ) { if (
!valid ||
!pros.isFrontEndCert &&
!pros.isBackEndCert
) {
debug('form not valid'); debug('form not valid');
return; return;
} }
@ -188,7 +192,7 @@ export default contain({
logo, logo,
company, company,
isFrontEndCert, isFrontEndCert,
isFullStackCert, isBackEndCert,
isHighlighted, isHighlighted,
isRemoteOk, isRemoteOk,
howToApply howToApply
@ -207,7 +211,7 @@ export default contain({
isRemoteOk: !!isRemoteOk.value, isRemoteOk: !!isRemoteOk.value,
howToApply: inHTMLData(howToApply.value), howToApply: inHTMLData(howToApply.value),
isFrontEndCert, isFrontEndCert,
isFullStackCert isBackEndCert
}; };
const job = Object.keys(jobValues).reduce((accu, prop) => { const job = Object.keys(jobValues).reduce((accu, prop) => {
@ -237,7 +241,7 @@ export default contain({
handleCertClick(name) { handleCertClick(name) {
const { jobActions: { handleForm } } = this.props; const { jobActions: { handleForm } } = this.props;
const otherButton = name === 'isFrontEndCert' ? const otherButton = name === 'isFrontEndCert' ?
'isFullStackCert' : 'isBackEndCert' :
'isFrontEndCert'; 'isFrontEndCert';
handleForm({ handleForm({
@ -259,7 +263,7 @@ export default contain({
isRemoteOk, isRemoteOk,
howToApply, howToApply,
isFrontEndCert, isFrontEndCert,
isFullStackCert, isBackEndCert,
jobActions: { handleForm } jobActions: { handleForm }
} = this.props; } = this.props;
@ -306,13 +310,13 @@ export default contain({
<div className='button-spacer' /> <div className='button-spacer' />
<Row> <Row>
<Button <Button
className={ isFullStackCert ? 'active' : ''} className={ isBackEndCert ? 'active' : ''}
onClick={ () => { onClick={ () => {
if (!isFullStackCert) { if (!isBackEndCert) {
this.handleCertClick('isFullStackCert'); this.handleCertClick('isBackEndCert');
} }
}}> }}>
<h4>Full Stack Development Certified</h4> <h4>Back End Development Certified</h4>
You can expect each applicant to have a code You can expect each applicant to have a code
portfolio using the following technologies: portfolio using the following technologies:
HTML5, CSS, jQuery, API integrations, MVC Framework, HTML5, CSS, jQuery, API integrations, MVC Framework,

View File

@ -8,12 +8,12 @@ import JobNotFound from './JobNotFound.jsx';
export default contain( export default contain(
{ {
store: 'JobsStore', store: 'appStore',
actions: [ actions: [
'appActions', 'appActions',
'jobActions' 'jobActions'
], ],
map({ form: job = {} }) { map({ jobsApp: { form: job = {} } }) {
return { job }; return { job };
} }
}, },
@ -32,7 +32,7 @@ export default contain(
const { appActions, job } = this.props; const { appActions, job } = this.props;
// redirect user in client // redirect user in client
if (!job || !job.position || !job.description) { if (!job || !job.position || !job.description) {
appActions.updateRoute('/jobs/new'); appActions.goTo('/jobs/new');
} }
}, },

View File

@ -9,25 +9,25 @@ import { isJobValid } from '../utils';
function shouldShowApply( function shouldShowApply(
{ {
isFrontEndCert: isFrontEndCertReq = false, isFrontEndCert: isFrontEndCertReq = false,
isFullStackCert: isFullStackCertReq = false isBackEndCert: isBackEndCertReq = false
}, { }, {
isFrontEndCert = false, isFrontEndCert = false,
isFullStackCert = false isBackEndCert = false
} }
) { ) {
return (!isFrontEndCertReq && !isFullStackCertReq) || return (!isFrontEndCertReq && !isBackEndCertReq) ||
(isFullStackCertReq && isFullStackCert) || (isBackEndCertReq && isBackEndCert) ||
(isFrontEndCertReq && isFrontEndCert); (isFrontEndCertReq && isFrontEndCert);
} }
function generateMessage( function generateMessage(
{ {
isFrontEndCert: isFrontEndCertReq = false, isFrontEndCert: isFrontEndCertReq = false,
isFullStackCert: isFullStackCertReq = false isBackEndCert: isBackEndCertReq = false
}, },
{ {
isFrontEndCert = false, isFrontEndCert = false,
isFullStackCert = false, isBackEndCert = false,
isSignedIn = false isSignedIn = false
} }
) { ) {
@ -39,39 +39,40 @@ function generateMessage(
return 'This employer requires Free Code Camps Front ' + return 'This employer requires Free Code Camps Front ' +
'End Development Certification in order to apply'; 'End Development Certification in order to apply';
} }
if (isFullStackCertReq && !isFullStackCert) { if (isBackEndCertReq && !isBackEndCert) {
return 'This employer requires Free Code Camps Full ' + return 'This employer requires Free Code Camps Back ' +
'Stack Development Certification in order to apply'; 'End Development Certification in order to apply';
} }
if (isFrontEndCertReq && isFrontEndCertReq) { if (isFrontEndCertReq && isFrontEndCertReq) {
return 'This employer requires the Front End Development Certification. ' + return 'This employer requires the Front End Development Certification. ' +
"You've earned it, so feel free to apply."; "You've earned it, so feel free to apply.";
} }
return 'This employer requires the Full Stack Development Certification. ' + return 'This employer requires the Back End Development Certification. ' +
"You've earned it, so feel free to apply."; "You've earned it, so feel free to apply.";
} }
export default contain( export default contain(
{ {
stores: ['appStore', 'jobsStore'], store: 'appStore',
fetchWaitFor: 'jobsStore',
fetchAction: 'jobActions.getJob', fetchAction: 'jobActions.getJob',
combineLatest( map({
{ username, isFrontEndCert, isFullStackCert }, username,
{ currentJob } isFrontEndCert,
) { isBackEndCert,
jobsApp: { currentJob }
}) {
return { return {
username, username,
job: currentJob, job: currentJob,
isFrontEndCert, isFrontEndCert,
isFullStackCert isBackEndCert
}; };
}, },
getPayload({ params: { id }, job = {} }) { getPayload({ params: { id } }) {
return { return id;
id, },
isPrimed: job.id === id isPrimed({ params: { id } = {}, job = {} }) {
}; return job.id === id;
}, },
// using es6 destructuring // using es6 destructuring
shouldContainerFetch({ job = {} }, { params: { id } } shouldContainerFetch({ job = {} }, { params: { id } }
@ -84,7 +85,7 @@ export default contain(
propTypes: { propTypes: {
job: PropTypes.object, job: PropTypes.object,
isFullStackCert: PropTypes.bool, isBackEndCert: PropTypes.bool,
isFrontEndCert: PropTypes.bool, isFrontEndCert: PropTypes.bool,
username: PropTypes.string username: PropTypes.string
}, },
@ -101,7 +102,7 @@ export default contain(
render() { render() {
const { const {
isFullStackCert, isBackEndCert,
isFrontEndCert, isFrontEndCert,
job, job,
username username
@ -115,12 +116,12 @@ export default contain(
const showApply = shouldShowApply( const showApply = shouldShowApply(
job, job,
{ isFrontEndCert, isFullStackCert } { isFrontEndCert, isBackEndCert }
); );
const message = generateMessage( const message = generateMessage(
job, job,
{ isFrontEndCert, isFullStackCert, isSignedIn } { isFrontEndCert, isBackEndCert, isSignedIn }
); );
return ( return (

View File

@ -1,17 +1,20 @@
import { Actions } from 'thundercats'; import { Actions } from 'thundercats';
import store from 'store'; import store from 'store';
import debugFactory from 'debug'; import { Observable } from 'rx';
import { jsonp$ } from '../../../../utils/jsonp$';
import { postJSON$ } from '../../../../utils/ajax-stream'; import { nameSpacedTransformer } from '../../../../utils';
const debug = debugFactory('freecc:jobs:actions');
const assign = Object.assign; const assign = Object.assign;
const jobsTranformer = nameSpacedTransformer('jobsApp');
const noOper = { transform: () => {} };
export default Actions({ export default Actions({
setJobs: null, refs: { displayName: 'JobActions' },
shouldBindMethods: true,
// findJob assumes that the job is already in the list of jobs // findJob assumes that the job is already in the list of jobs
findJob(id) { findJob(id) {
return oldState => { return {
transform: jobsTranformer(oldState => {
const { currentJob = {}, jobs = [] } = oldState; const { currentJob = {}, jobs = [] } = oldState;
// currentJob already set // currentJob already set
// do nothing // do nothing
@ -29,23 +32,71 @@ export default Actions({
return foundJob ? return foundJob ?
assign({}, oldState, { currentJob: foundJob }) : assign({}, oldState, { currentJob: foundJob }) :
null; null;
})
}; };
}, },
setError: null, saveJobToDb({ goTo, job }) {
getJob: null, return this.createService$('jobs', { job })
saveJobToDb: null, .map(job => ({
getJobs(params) { transform(state) {
return { params }; state.location = {
action: 'PUSH',
pathname: goTo
};
return {
...state,
jobsApp: {
...state.jobs,
currentJob: job
}
};
}
}))
.catch(err => Observable.just({
transform(state) {
return { ...state, err };
}
}));
},
getJob(id) {
return this.readService$('jobs', { id })
.map(job => ({
transform: jobsTranformer(state => {
return { ...state, currentJob: job };
})
}))
.catch(err => Observable.just({
transform(state) {
return { ...state, err };
}
}));
},
getJobs() {
return this.readService$('jobs')
.map(jobs => ({
transform: jobsTranformer(state => {
return { ...state, jobs };
})
}))
.catch(err => Observable.just({
transform(state) {
return { ...state, err };
}
}));
}, },
openModal() { openModal() {
return { showModal: true }; return {
transform: jobsTranformer(state => ({ ...state, showModal: true }))
};
}, },
closeModal() { closeModal() {
return { showModal: false }; return {
transform: jobsTranformer(state => ({ ...state, showModal: false }))
};
}, },
handleForm(value) { handleForm(value) {
return { return {
transform(oldState) { transform: jobsTranformer(oldState => {
const { form } = oldState; const { form } = oldState;
const newState = assign({}, oldState); const newState = assign({}, oldState);
newState.form = assign( newState.form = assign(
@ -54,123 +105,31 @@ export default Actions({
value value
); );
return newState; return newState;
} })
}; };
}, },
saveForm: null, saveForm: null,
getSavedForm: null,
clearSavedForm: null, clearSavedForm: null,
setForm(form) { getSavedForm() {
return { form }; const form = store.get('newJob');
}, if (form && !Array.isArray(form) && typeof form === 'object') {
getFollowers: null, return {
setFollowersCount(numOfFollowers) { transform: jobsTranformer(state => {
return { numOfFollowers }; return { ...state, form };
})
};
}
return noOper;
}, },
setPromoCode({ target: { value = '' }} = {}) { setPromoCode({ target: { value = '' }} = {}) {
return { promoCode: value.replace(/[^\d\w\s]/, '') };
},
applyCode: null,
clearPromo(foo, undef) {
return { return {
price: undef, transform: jobsTranformer(state => ({
buttonId: undef, ...state,
discountAmount: undef, promoCode: value.replace(/[^\d\w\s]/, '')
promoCode: undef, }))
promoApplied: false,
promoName: undef
}; };
}, },
applyPromo({ applyCode({ id, code = '', type = null}) {
fullPrice: price,
buttonId,
discountAmount,
code: promoCode,
name: promoName
} = {}) {
return {
price,
buttonId,
discountAmount,
promoCode,
promoApplied: true,
promoName
};
}
})
.refs({ displayName: 'JobActions' })
.init(({ instance: jobActions, args: [cat, services] }) => {
jobActions.getJobs.subscribe(() => {
services.read('jobs', null, null, (err, jobs) => {
if (err) {
debug('job services experienced an issue', err);
return jobActions.setError({ err });
}
jobActions.setJobs({ jobs });
});
});
jobActions.getJob.subscribe(({ id, isPrimed }) => {
// job is already set, do nothing.
if (isPrimed) {
debug('job is primed');
return;
}
services.read('jobs', { id }, null, (err, job) => {
if (err) {
debug('job services experienced an issue', err);
return jobActions.setError({ err });
}
if (job) {
jobActions.setJobs({ currentJob: job });
}
jobActions.setJobs({});
});
});
jobActions.saveForm.subscribe((form) => {
store.set('newJob', form);
});
jobActions.getSavedForm.subscribe(() => {
const job = store.get('newJob');
if (job && !Array.isArray(job) && typeof job === 'object') {
jobActions.setForm(job);
}
});
jobActions.clearSavedForm.subscribe(() => {
store.remove('newJob');
});
jobActions.saveJobToDb.subscribe(({ goTo, job }) => {
const appActions = cat.getActions('appActions');
services.create('jobs', { job }, null, (err, job) => {
if (err) {
debug('job services experienced an issue', err);
return jobActions.setError(err);
}
jobActions.setJobs({ job });
appActions.updateRoute(goTo);
});
});
jobActions.getFollowers.subscribe(() => {
const url = 'https://cdn.syndication.twimg.com/widgets/followbutton/' +
'info.json?lang=en&screen_names=CamperJobs' +
'&callback=JSONPCallback';
jsonp$(url)
.map(({ response }) => {
return response[0]['followers_count'];
})
.subscribe(
count => jobActions.setFollowersCount(count),
err => jobActions.setError(err)
);
});
jobActions.applyCode.subscribe(({ id, code = '', type = null}) => {
const body = { const body = {
id, id,
code: code.replace(/[^\d\w\s]/, '') code: code.replace(/[^\d\w\s]/, '')
@ -178,18 +137,62 @@ export default Actions({
if (type) { if (type) {
body.type = type; body.type = type;
} }
postJSON$('/api/promos/getButton', body) return this.postJSON$('/api/promos/getButton', body)
.pluck('response') .pluck('response')
.subscribe( .map(({ promo }) => {
({ promo }) => { if (!promo || !promo.buttonId) {
if (promo && promo.buttonId) { return noOper;
jobActions.applyPromo(promo);
} }
jobActions.setError(new Error('no promo found')); const {
fullPrice: price,
buttonId,
discountAmount,
code: promoCode,
name: promoName
} = promo;
return {
transform: jobsTranformer(state => ({
...state,
price,
buttonId,
discountAmount,
promoCode,
promoApplied: true,
promoName
}))
};
})
.catch(err => Observable.just({
transform(state) {
return { ...state, err };
}
}));
}, },
jobActions.setError clearPromo() {
); return {
/* eslint-disable no-undefined */
transform: jobsTranformer(state => ({
...state,
price: undefined,
buttonId: undefined,
discountAmount: undefined,
promoCode: undefined,
promoApplied: false,
promoName: undefined
}))
/* eslint-enable no-undefined */
};
},
init({ instance: jobActions }) {
jobActions.saveForm.subscribe((form) => {
store.set('newJob', form);
});
jobActions.clearSavedForm.subscribe(() => {
store.remove('newJob');
}); });
return jobActions; return jobActions;
}); }
});

View File

@ -1,42 +0,0 @@
import { Store } from 'thundercats';
const {
createRegistrar,
setter,
transformer
} = Store;
export default Store({
refs: {
displayName: 'JobsStore',
value: { showModal: false }
},
init({ instance: jobsStore, args: [cat] }) {
const {
setJobs,
findJob,
setError,
openModal,
closeModal,
handleForm,
setForm,
setFollowersCount,
setPromoCode,
applyPromo,
clearPromo
} = cat.getActions('JobActions');
const register = createRegistrar(jobsStore);
register(setter(setJobs));
register(setter(setError));
register(setter(openModal));
register(setter(closeModal));
register(setter(setForm));
register(setter(setPromoCode));
register(setter(applyPromo));
register(setter(clearPromo));
register(setter(setFollowersCount));
register(transformer(findJob));
register(handleForm);
}
});

View File

@ -1,2 +1 @@
export { default as JobActions } from './Actions'; export { default as JobActions } from './Actions';
export { default as JobsStore } from './Store';

View File

@ -74,6 +74,11 @@
"defaut": false, "defaut": false,
"description": "Camper must be front end certified to apply" "description": "Camper must be front end certified to apply"
}, },
"isBackEndCert": {
"type": "boolean",
"default": false,
"description": "Camper must be back end certified to apply"
},
"isFullStackCert": { "isFullStackCert": {
"type": "boolean", "type": "boolean",
"default": false, "default": false,

View File

@ -17,7 +17,7 @@
*/ */
import debugFactory from 'debug'; import debugFactory from 'debug';
import { AnonymousObservable, helpers } from 'rx'; import { Observable, AnonymousObservable, helpers } from 'rx';
const debug = debugFactory('freecc:ajax$'); const debug = debugFactory('freecc:ajax$');
const root = typeof window !== 'undefined' ? window : {}; const root = typeof window !== 'undefined' ? window : {};
@ -147,8 +147,12 @@ export function ajax$(options) {
var processResponse = function(xhr, e) { var processResponse = function(xhr, e) {
var status = xhr.status === 1223 ? 204 : xhr.status; var status = xhr.status === 1223 ? 204 : xhr.status;
if ((status >= 200 && status <= 300) || status === 0 || status === '') { if ((status >= 200 && status <= 300) || status === 0 || status === '') {
try {
observer.onNext(normalizeSuccess(e, xhr, settings)); observer.onNext(normalizeSuccess(e, xhr, settings));
observer.onCompleted(); observer.onCompleted();
} catch (err) {
observer.onError(err);
}
} else { } else {
observer.onError(normalizeError(e, xhr, 'error')); observer.onError(normalizeError(e, xhr, 'error'));
} }
@ -228,8 +232,8 @@ export function ajax$(options) {
settings.hasContent && settings.body settings.hasContent && settings.body
); );
xhr.send(settings.hasContent && settings.body || null); xhr.send(settings.hasContent && settings.body || null);
} catch (e) { } catch (err) {
observer.onError(e); observer.onError(err);
} }
return function() { return function() {
@ -247,13 +251,25 @@ export function ajax$(options) {
* from the Ajax POST. * from the Ajax POST.
*/ */
export function post$(url, body) { export function post$(url, body) {
try {
body = JSON.stringify(body);
} catch (e) {
return Observable.throw(e);
}
return ajax$({ url, body, method: 'POST' }); return ajax$({ url, body, method: 'POST' });
} }
export function postJSON$(url, body) { export function postJSON$(url, body) {
try {
body = JSON.stringify(body);
} catch (e) {
return Observable.throw(e);
}
return ajax$({ return ajax$({
url, url,
body: JSON.stringify(body), body,
method: 'POST', method: 'POST',
responseType: 'json', responseType: 'json',
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' }

16
common/utils/index.js Normal file
View File

@ -0,0 +1,16 @@
export function nameSpacedTransformer(ns, transformer) {
if (!transformer) {
return nameSpacedTransformer.bind(null, ns);
}
return (state) => {
const newState = transformer(state[ns]);
// nothing has changed
// noop
if (!newState || newState === state[ns]) {
return null;
}
return { ...state, [ns]: newState };
};
}

View File

@ -183,7 +183,7 @@ function delRev(dest, manifestName) {
}); });
} }
gulp.task('serve', function(cb) { gulp.task('serve', ['build-manifest'], function(cb) {
var called = false; var called = false;
nodemon({ nodemon({
script: paths.server, script: paths.server,
@ -483,6 +483,10 @@ function buildManifest() {
var buildDependents = ['less', 'js', 'dependents']; var buildDependents = ['less', 'js', 'dependents'];
if (__DEV__) {
buildDependents.push('pack-watch-manifest');
}
gulp.task('build-manifest', buildDependents, function() { gulp.task('build-manifest', buildDependents, function() {
return buildManifest(); return buildManifest();
}); });
@ -505,9 +509,9 @@ var watchDependents = [
'dependents', 'dependents',
'serve', 'serve',
'sync', 'sync',
'build-manifest',
'pack-watch', 'pack-watch',
'pack-watch-manifest' 'pack-watch-manifest',
'build-manifest'
]; ];
gulp.task('reload', function() { gulp.task('reload', function() {
@ -533,6 +537,7 @@ gulp.task('default', [
'serve', 'serve',
'pack-watch', 'pack-watch',
'pack-watch-manifest', 'pack-watch-manifest',
'build-manifest-watch',
'watch', 'watch',
'sync' 'sync'
]); ]);

View File

@ -115,9 +115,10 @@
"rx": "^4.0.0", "rx": "^4.0.0",
"sanitize-html": "^1.11.1", "sanitize-html": "^1.11.1",
"sort-keys": "^1.1.1", "sort-keys": "^1.1.1",
"stampit": "^2.1.1",
"store": "https://github.com/berkeleytrue/store.js.git#feature/noop-server", "store": "https://github.com/berkeleytrue/store.js.git#feature/noop-server",
"thundercats": "^3.0.0", "thundercats": "^3.1.0",
"thundercats-react": "~0.4.0", "thundercats-react": "~0.5.1",
"twit": "^2.1.1", "twit": "^2.1.1",
"uglify-js": "^2.5.0", "uglify-js": "^2.5.0",
"url-regex": "^3.0.0", "url-regex": "^3.0.0",

View File

@ -1,6 +1,6 @@
{ {
"name": "Computer Basics", "name": "Computer Basics",
"order": 0.050, "order": 0,
"time": "3h", "time": "3h",
"challenges": [ "challenges": [
{ {

View File

@ -3,8 +3,10 @@ import { RoutingContext } from 'react-router';
import Fetchr from 'fetchr'; import Fetchr from 'fetchr';
import { createLocation } from 'history'; import { createLocation } from 'history';
import debugFactory from 'debug'; import debugFactory from 'debug';
import { dehydrate } from 'thundercats';
import { renderToString$ } from 'thundercats-react';
import { app$ } from '../../common/app'; import { app$ } from '../../common/app';
import { RenderToString } from 'thundercats-react';
const debug = debugFactory('freecc:react-server'); const debug = debugFactory('freecc:react-server');
@ -12,14 +14,13 @@ const debug = debugFactory('freecc:react-server');
// remove their individual controllers // remove their individual controllers
const routes = [ const routes = [
'/jobs', '/jobs',
'/jobs/*' '/jobs/*',
];
const devRoutes = [
'/hikes', '/hikes',
'/hikes/*' '/hikes/*'
]; ];
const devRoutes = [];
export default function reactSubRouter(app) { export default function reactSubRouter(app) {
var router = app.loopback.Router(); var router = app.loopback.Router();
@ -51,20 +52,28 @@ export default function reactSubRouter(app) {
return !!props; return !!props;
}) })
.flatMap(function({ props, AppCat }) { .flatMap(function({ props, AppCat }) {
// call thundercats renderToString const cat = AppCat(null, services);
// prefetches data and sets up it up for current state debug('render react markup and pre-fetch data');
debug('rendering to string'); const store = cat.getStore('appStore');
return RenderToString(
AppCat(null, services), // primes store to observe action changes
// cleaned up by cat.dispose further down
store.subscribe(() => {});
return renderToString$(
cat,
React.createElement(RoutingContext, props) React.createElement(RoutingContext, props)
)
.flatMap(
dehydrate(cat),
({ markup }, data) => ({ markup, data, cat })
); );
}) })
// makes sure we only get one onNext and closes subscription .flatMap(function({ data, markup, cat }) {
.flatMap(function({ data, markup }) { debug('react markup rendered, data fetched');
debug('react rendered'); cat.dispose();
const { title } = data.AppStore; const { title } = data.AppStore;
res.expose(data, 'data'); res.expose(data, 'data');
// now render jade file with markup injected from react
return res.render$( return res.render$(
'layout-react', 'layout-react',
{ markup, title } { markup, title }
@ -72,7 +81,7 @@ export default function reactSubRouter(app) {
}) })
.subscribe( .subscribe(
function(markup) { function(markup) {
debug('jade rendered'); debug('html rendered and ready to send');
res.send(markup); res.send(markup);
}, },
next next

View File

@ -1,9 +1,13 @@
import helmet from 'helmet'; import helmet from 'helmet';
const trusted = [ let trusted = [
"'self'" "'self'"
]; ];
if (process.env.NODE_ENV !== 'production') {
trusted.push('ws://localhost:3001');
}
export default function csp() { export default function csp() {
return helmet.csp({ return helmet.csp({
defaultSrc: trusted, defaultSrc: trusted,

View File

@ -11,7 +11,7 @@ export default function hikesService(app) {
read: (req, resource, params, config, cb) => { read: (req, resource, params, config, cb) => {
const query = { const query = {
where: { challengeType: '6' }, where: { challengeType: '6' },
order: 'suborder ASC' order: ['order ASC', 'suborder ASC' ]
}; };
debug('params', params); debug('params', params);