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 { createLocation, createHistory } from 'history';
import { hydrate } from 'thundercats';
import { Render } from 'thundercats-react';
import { render$ } from 'thundercats-react';
import { app$ } from '../common/app';
import synchroniseHistory from './synchronise-history';
const debug = debugFactory('fcc:client');
const DOMContianer = document.getElementById('fcc');
@ -23,56 +24,55 @@ const appLocation = createLocation(
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
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
({ nextLocation, props }, appCat) => ({ nextLocation, props, appCat })
)
.doOnNext(({ appCat }) => {
const appActions = appCat.getActions('appActions');
const { updateLocation, goTo, goBack } = appCat.getActions('appActions');
const appStore$ = appCat.getStore('appStore');
location$(history)
.pluck('pathname')
const routerState$ = appStore$
.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()
.doOnNext(route => debug('route change', route))
.subscribe(route => appActions.updateRoute(route));
.subscribe(err => console.error(err));
appActions.goBack.subscribe(function() {
history.goBack();
});
appActions
.updateRoute
.pluck('route')
.doOnNext(route => debug('update route', route))
.subscribe(function(route) {
history.pushState(null, route);
});
synchroniseHistory(
history,
updateLocation,
goTo,
goBack,
routerState$
);
})
// allow store subscribe to subscribe to actions
.delay(10)
.flatMap(({ props, appCat }) => {
props.history = history;
return Render(
return render$(
appCat,
React.createElement(Router, props),
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',
fetchAction: 'appActions.getUser',
isPrimed({ username }) {
return !!username;
},
getPayload(props) {
return {
isPrimed: !!props.username
@ -25,22 +28,6 @@ export default contain(
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() {
const { username, points, picture } = this.props;
const navProps = { username, points, picture };

View File

@ -1,17 +1,65 @@
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 { HikesActions, HikesStore } from './routes/Hikes/flux';
import { JobActions, JobsStore} from './routes/Jobs/flux';
import { HikesActions } from './routes/Hikes/flux';
import { JobActions } from './routes/Jobs/flux';
export default Cat()
.init(({ instance: cat, args: [services] }) => {
cat.register(AppActions, null, services);
cat.register(AppStore, null, cat);
const ajaxStamp = stamp({
methods: {
postJSON$,
post$
}
});
cat.register(HikesActions, null, services);
cat.register(HikesStore, null, cat);
export default Cat().init(({ instance: cat, args: [services] }) => {
const serviceStamp = stamp({
methods: {
readService$(resource, params, config) {
cat.register(JobActions, null, cat, services);
cat.register(JobsStore, null, cat);
return Observable.create(function(observer) {
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 debugFactory from 'debug';
import { Observable } from 'rx';
const debug = debugFactory('freecc:app:actions');
export default Actions({
shouldBindMethods: true,
refs: { displayName: 'AppActions' },
setTitle(title = 'Learn To Code') {
return { title: title + '| Free Code Camp' };
return { title: title + ' | Free Code Camp' };
},
setUser({
username,
picture,
progressTimestamps = [],
isFrontEndCert,
isFullStackCert
}) {
getUser() {
return this.readService$('user', null, null)
.map(({
username,
picture,
progressTimestamps = [],
isFrontEndCert,
isBackEndCert,
isFullStackCert
}) => {
return {
username,
picture,
points: progressTimestamps.length,
isFrontEndCert,
isBackEndCert,
isFullStackCert
};
})
.catch(err => Observable.just({ err }));
},
// routing
goTo: null,
goBack: null,
updateLocation(location) {
return {
username,
picture,
points: progressTimestamps.length,
isFrontEndCert,
isFullStackCert
};
},
getUser: null,
updateRoute(route) {
return { route };
},
goBack: null
})
.refs({ displayName: 'AppActions' })
.init(({ instance: appActions, args: [services] }) => {
appActions.getUser.subscribe(({ isPrimed }) => {
if (isPrimed) {
debug('isPrimed');
return;
transform(state) {
return { ...state, location };
}
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',
username: null,
picture: null,
points: 0
points: 0,
hikesApp: {
hikes: [],
// lecture state
currentHike: {},
showQuestions: false
},
jobsApp: {
showModal: false
}
};
export default Store({
@ -13,12 +22,82 @@ export default Store({
displayName: 'AppStore',
value: initValue
},
init({ instance: appStore, args: [cat] }) {
const { updateRoute, setUser, setTitle } = cat.getActions('appActions');
const register = createRegistrar(appStore);
init({ instance: store, args: [cat] }) {
const register = createRegistrar(store);
// 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(
{
store: 'hikesStore',
store: 'appStore',
map(state) {
return state.hikesApp;
},
actions: ['appActions'],
fetchAction: 'hikesActions.fetchHikes',
getPayload: ({ hikes, params }) => ({
@ -27,7 +30,9 @@ export default contain(
appActions: PropTypes.object,
children: PropTypes.element,
currentHike: PropTypes.object,
hikes: PropTypes.array
hikes: PropTypes.array,
params: PropTypes.object,
showQuestions: PropTypes.bool
},
componentWillMount() {
@ -41,21 +46,26 @@ export default contain(
);
},
renderChild(children, hikes, currentHike) {
renderChild({ children, ...props }) {
if (!children) {
return null;
}
return React.cloneElement(children, { hikes, currentHike });
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 (
<div>
<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>
</div>
);

View File

@ -1,71 +1,88 @@
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 Vimeo from 'react-vimeo';
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: {
currentHike: PropTypes.object,
params: PropTypes.object
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');
const { dashedName } = this.props.params;
this.history.pushState(null, `/hikes/${dashedName}/questions/1`);
},
shouldComponentUpdate(nextProps) {
const { props } = this;
return nextProps.id !== props.id;
},
renderTranscript(transcript, dashedName) {
return transcript.map((line, index) => (
<p key={ dashedName + index }>{ line }</p>
));
},
handleError: debug,
render() {
const {
title,
challengeSeed = ['1'],
description = []
} = this.props.currentHike;
const { dashedName } = this.props.params;
handleFinish(hikesActions) {
debug('loading questions');
hikesActions.toggleQuestions();
},
const [ id ] = challengeSeed;
renderTranscript(transcript, dashedName) {
return transcript.map((line, index) => (
<p key={ dashedName + index }>{ line }</p>
));
},
const videoTitle = <h2>{ title }</h2>;
return (
<Col xs={ 12 }>
<Row>
<Panel className={ 'text-center' } title={ videoTitle }>
render() {
const {
id = '1',
description = [],
hikesActions
} = this.props;
const dashedName = 'foo';
return (
<Col xs={ 12 }>
<Row>
<Vimeo
onError={ this.handleError }
onFinish= { this.handleFinish }
onFinish= { () => this.handleFinish(hikesActions) }
videoId={ id } />
</Panel>
</Row>
<Row>
<Col xs={ 12 }>
<Panel>
{ this.renderTranscript(description, dashedName) }
</Panel>
<Panel>
<Button
block={ true }
bsSize='large'
onClick={ this.handleFinish }>
Take me to the Questions
</Button>
</Panel>
</Col>
</Row>
</Col>
);
}
});
</Row>
<Row>
{ this.renderTranscript(description, dashedName) }
<Button
block={ true }
bsSize='large'
onClick={ () => this.handleFinish(hikesActions) }>
Take me to the Questions
</Button>
</Row>
</Col>
);
}
})
);

View File

@ -11,7 +11,7 @@ export default React.createClass({
render() {
const {
hikes
hikes = [{}]
} = this.props;
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 debugFactory from 'debug';
@ -24,41 +26,247 @@ 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];
}
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({
// start fetching hikes
fetchHikes: null,
// set hikes on store
setHikes: null
})
.refs({ displayName: 'HikesActions' })
.init(({ instance: hikeActions, args: [services] }) => {
// set up hikes fetching
hikeActions.fetchHikes.subscribe(
({ isPrimed, dashedName }) => {
if (isPrimed) {
return hikeActions.setHikes({
transform: (oldState) => {
const { hikes } = oldState;
const currentHike = getCurrentHike(
hikes,
dashedName,
oldState.currentHike
);
return Object.assign({}, oldState, { currentHike });
}
});
refs: { displayName: 'HikesActions' },
shouldBindMethods: true,
fetchHikes({ isPrimed, dashedName }) {
if (isPrimed) {
return {
transform: (state) => {
const { hikesApp: oldState } = state;
const currentHike = getCurrentHike(
oldState.hikes,
dashedName,
oldState.currentHike
);
const hikesApp = { ...oldState, currentHike };
return Object.assign({}, state, { hikesApp });
}
services.read('hikes', null, null, (err, hikes) => {
if (err) {
debug('an error occurred fetching hikes', err);
};
}
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 };
}
hikeActions.setHikes({
set: {
hikes: hikes,
currentHike: getCurrentHike(hikes, dashedName)
}
});
});
};
})
.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 };
}
});
}
// challenge completed
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
};
}
};
return Observable.just({
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 HikesStore } from './Store';

View File

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

View File

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

View File

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

View File

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

View File

@ -8,12 +8,12 @@ import JobNotFound from './JobNotFound.jsx';
export default contain(
{
store: 'JobsStore',
store: 'appStore',
actions: [
'appActions',
'jobActions'
],
map({ form: job = {} }) {
map({ jobsApp: { form: job = {} } }) {
return { job };
}
},
@ -32,7 +32,7 @@ export default contain(
const { appActions, job } = this.props;
// redirect user in client
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(
{
isFrontEndCert: isFrontEndCertReq = false,
isFullStackCert: isFullStackCertReq = false
isBackEndCert: isBackEndCertReq = false
}, {
isFrontEndCert = false,
isFullStackCert = false
isBackEndCert = false
}
) {
return (!isFrontEndCertReq && !isFullStackCertReq) ||
(isFullStackCertReq && isFullStackCert) ||
return (!isFrontEndCertReq && !isBackEndCertReq) ||
(isBackEndCertReq && isBackEndCert) ||
(isFrontEndCertReq && isFrontEndCert);
}
function generateMessage(
{
isFrontEndCert: isFrontEndCertReq = false,
isFullStackCert: isFullStackCertReq = false
isBackEndCert: isBackEndCertReq = false
},
{
isFrontEndCert = false,
isFullStackCert = false,
isBackEndCert = false,
isSignedIn = false
}
) {
@ -39,39 +39,40 @@ function generateMessage(
return 'This employer requires Free Code Camps Front ' +
'End Development Certification in order to apply';
}
if (isFullStackCertReq && !isFullStackCert) {
return 'This employer requires Free Code Camps Full ' +
'Stack Development Certification in order to apply';
if (isBackEndCertReq && !isBackEndCert) {
return 'This employer requires Free Code Camps Back ' +
'End Development Certification in order to apply';
}
if (isFrontEndCertReq && isFrontEndCertReq) {
return 'This employer requires the Front End Development Certification. ' +
"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.";
}
export default contain(
{
stores: ['appStore', 'jobsStore'],
fetchWaitFor: 'jobsStore',
store: 'appStore',
fetchAction: 'jobActions.getJob',
combineLatest(
{ username, isFrontEndCert, isFullStackCert },
{ currentJob }
) {
map({
username,
isFrontEndCert,
isBackEndCert,
jobsApp: { currentJob }
}) {
return {
username,
job: currentJob,
isFrontEndCert,
isFullStackCert
isBackEndCert
};
},
getPayload({ params: { id }, job = {} }) {
return {
id,
isPrimed: job.id === id
};
getPayload({ params: { id } }) {
return id;
},
isPrimed({ params: { id } = {}, job = {} }) {
return job.id === id;
},
// using es6 destructuring
shouldContainerFetch({ job = {} }, { params: { id } }
@ -84,7 +85,7 @@ export default contain(
propTypes: {
job: PropTypes.object,
isFullStackCert: PropTypes.bool,
isBackEndCert: PropTypes.bool,
isFrontEndCert: PropTypes.bool,
username: PropTypes.string
},
@ -101,7 +102,7 @@ export default contain(
render() {
const {
isFullStackCert,
isBackEndCert,
isFrontEndCert,
job,
username
@ -115,12 +116,12 @@ export default contain(
const showApply = shouldShowApply(
job,
{ isFrontEndCert, isFullStackCert }
{ isFrontEndCert, isBackEndCert }
);
const message = generateMessage(
job,
{ isFrontEndCert, isFullStackCert, isSignedIn }
{ isFrontEndCert, isBackEndCert, isSignedIn }
);
return (

View File

@ -1,51 +1,102 @@
import { Actions } from 'thundercats';
import store from 'store';
import debugFactory from 'debug';
import { jsonp$ } from '../../../../utils/jsonp$';
import { postJSON$ } from '../../../../utils/ajax-stream';
import { Observable } from 'rx';
import { nameSpacedTransformer } from '../../../../utils';
const debug = debugFactory('freecc:jobs:actions');
const assign = Object.assign;
const jobsTranformer = nameSpacedTransformer('jobsApp');
const noOper = { transform: () => {} };
export default Actions({
setJobs: null,
refs: { displayName: 'JobActions' },
shouldBindMethods: true,
// findJob assumes that the job is already in the list of jobs
findJob(id) {
return oldState => {
const { currentJob = {}, jobs = [] } = oldState;
// currentJob already set
// do nothing
if (currentJob.id === id) {
return null;
}
const foundJob = jobs.reduce((newJob, job) => {
if (job.id === id) {
return job;
return {
transform: jobsTranformer(oldState => {
const { currentJob = {}, jobs = [] } = oldState;
// currentJob already set
// do nothing
if (currentJob.id === id) {
return null;
}
return newJob;
}, null);
const foundJob = jobs.reduce((newJob, job) => {
if (job.id === id) {
return job;
}
return newJob;
}, null);
// if no job found this will be null which is a op noop
return foundJob ?
assign({}, oldState, { currentJob: foundJob }) :
null;
// if no job found this will be null which is a op noop
return foundJob ?
assign({}, oldState, { currentJob: foundJob }) :
null;
})
};
},
setError: null,
getJob: null,
saveJobToDb: null,
getJobs(params) {
return { params };
saveJobToDb({ goTo, job }) {
return this.createService$('jobs', { job })
.map(job => ({
transform(state) {
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() {
return { showModal: true };
return {
transform: jobsTranformer(state => ({ ...state, showModal: true }))
};
},
closeModal() {
return { showModal: false };
return {
transform: jobsTranformer(state => ({ ...state, showModal: false }))
};
},
handleForm(value) {
return {
transform(oldState) {
transform: jobsTranformer(oldState => {
const { form } = oldState;
const newState = assign({}, oldState);
newState.form = assign(
@ -54,142 +105,94 @@ export default Actions({
value
);
return newState;
}
})
};
},
saveForm: null,
getSavedForm: null,
clearSavedForm: null,
setForm(form) {
return { form };
},
getFollowers: null,
setFollowersCount(numOfFollowers) {
return { numOfFollowers };
getSavedForm() {
const form = store.get('newJob');
if (form && !Array.isArray(form) && typeof form === 'object') {
return {
transform: jobsTranformer(state => {
return { ...state, form };
})
};
}
return noOper;
},
setPromoCode({ target: { value = '' }} = {}) {
return { promoCode: value.replace(/[^\d\w\s]/, '') };
},
applyCode: null,
clearPromo(foo, undef) {
return {
price: undef,
buttonId: undef,
discountAmount: undef,
promoCode: undef,
promoApplied: false,
promoName: undef
transform: jobsTranformer(state => ({
...state,
promoCode: value.replace(/[^\d\w\s]/, '')
}))
};
},
applyPromo({
fullPrice: price,
buttonId,
discountAmount,
code: promoCode,
name: promoName
} = {}) {
return {
price,
buttonId,
discountAmount,
promoCode,
promoApplied: true,
promoName
applyCode({ id, code = '', type = null}) {
const body = {
id,
code: code.replace(/[^\d\w\s]/, '')
};
}
})
.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 });
if (type) {
body.type = type;
}
return this.postJSON$('/api/promos/getButton', body)
.pluck('response')
.map(({ promo }) => {
if (!promo || !promo.buttonId) {
return noOper;
}
jobActions.setJobs({ jobs });
});
});
const {
fullPrice: price,
buttonId,
discountAmount,
code: promoCode,
name: promoName
} = promo;
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 });
return {
transform: jobsTranformer(state => ({
...state,
price,
buttonId,
discountAmount,
promoCode,
promoApplied: true,
promoName
}))
};
})
.catch(err => Observable.just({
transform(state) {
return { ...state, err };
}
if (job) {
jobActions.setJobs({ currentJob: job });
}
jobActions.setJobs({});
});
});
}));
},
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.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 = {
id,
code: code.replace(/[^\d\w\s]/, '')
};
if (type) {
body.type = type;
}
postJSON$('/api/promos/getButton', body)
.pluck('response')
.subscribe(
({ promo }) => {
if (promo && promo.buttonId) {
jobActions.applyPromo(promo);
}
jobActions.setError(new Error('no promo found'));
},
jobActions.setError
);
});
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 JobsStore } from './Store';

View File

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

View File

@ -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' }

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

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

View File

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

View File

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

View File

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

View File

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

View File

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