Merge pull request #7214 from FreeCodeCamp/feature/redux

Move video/jobs to redux
This commit is contained in:
Logan Tegman
2016-03-04 18:43:38 -08:00
103 changed files with 3341 additions and 2015 deletions

View File

@ -1,6 +1,9 @@
{ {
"ecmaFeatures": { "parserOption": {
"jsx": true "ecmaVersion": 6,
"ecmaFeatures": {
"jsx": true
}
}, },
"env": { "env": {
"browser": true, "browser": true,
@ -12,6 +15,7 @@
"react" "react"
], ],
"globals": { "globals": {
"Promise": true,
"window": true, "window": true,
"$": true, "$": true,
"ga": true, "ga": true,
@ -58,7 +62,6 @@
"no-caller": 2, "no-caller": 2,
"no-div-regex": 2, "no-div-regex": 2,
"no-else-return": 0, "no-else-return": 0,
"no-empty-label": 2,
"no-eq-null": 1, "no-eq-null": 1,
"no-eval": 2, "no-eval": 2,
"no-extend-native": 2, "no-extend-native": 2,
@ -182,10 +185,7 @@
"always" "always"
], ],
"sort-vars": 0, "sort-vars": 0,
"space-after-keywords": [ "keyword-spacing": [ 2 ],
2,
"always"
],
"space-before-function-paren": [ "space-before-function-paren": [
2, 2,
"never" "never"
@ -197,7 +197,6 @@
"space-in-brackets": 0, "space-in-brackets": 0,
"space-in-parens": 0, "space-in-parens": 0,
"space-infix-ops": 2, "space-infix-ops": 2,
"space-return-throw-case": 2,
"space-unary-ops": [ "space-unary-ops": [
1, 1,
{ {
@ -214,7 +213,7 @@
"max-depth": 0, "max-depth": 0,
"max-len": [ "max-len": [
1, 2,
80, 80,
2 2
], ],
@ -232,7 +231,7 @@
"react/jsx-uses-vars": 1, "react/jsx-uses-vars": 1,
"react/no-did-mount-set-state": 2, "react/no-did-mount-set-state": 2,
"react/no-did-update-set-state": 2, "react/no-did-update-set-state": 2,
"react/no-multi-comp": 2, "react/no-multi-comp": [2, { "ignoreStateless": true } ],
"react/prop-types": 2, "react/prop-types": 2,
"react/react-in-jsx-scope": 1, "react/react-in-jsx-scope": 1,
"react/self-closing-comp": 1, "react/self-closing-comp": 1,

View File

@ -34,6 +34,7 @@ window.common = (function(global) {
} }
} }
} }
return null;
}, },
isAlive: function(key) { isAlive: function(key) {

View File

@ -51,10 +51,10 @@ $(document).ready(function() {
<h1>${err}</h1> <h1>${err}</h1>
`).subscribe(() => {}); `).subscribe(() => {});
} }
return null;
}, },
err => console.error(err) err => console.error(err)
); );
} }
common.resetBtn$ common.resetBtn$
@ -74,6 +74,7 @@ $(document).ready(function() {
common.codeStorage.updateStorage(challengeName, originalCode); common.codeStorage.updateStorage(challengeName, originalCode);
common.codeUri.querify(originalCode); common.codeUri.querify(originalCode);
common.updateOutputDisplay(output); common.updateOutputDisplay(output);
return null;
}, },
(err) => { (err) => {
if (err) { if (err) {
@ -112,6 +113,7 @@ $(document).ready(function() {
if (solved) { if (solved) {
common.showCompletion(); common.showCompletion();
} }
return null;
}, },
({ err }) => { ({ err }) => {
console.error(err); console.error(err);
@ -138,6 +140,7 @@ $(document).ready(function() {
return common.updateOutputDisplay('' + err); return common.updateOutputDisplay('' + err);
} }
common.displayTestResults(tests); common.displayTestResults(tests);
return null;
}, },
({ err }) => { ({ err }) => {
console.error(err); console.error(err);
@ -149,7 +152,7 @@ $(document).ready(function() {
challengeType === challengeTypes.BONFIRE || challengeType === challengeTypes.BONFIRE ||
challengeType === challengeTypes.JS challengeType === challengeTypes.JS
) { ) {
Observable.just({}) return Observable.just({})
.delay(500) .delay(500)
.flatMap(() => common.executeChallenge$()) .flatMap(() => common.executeChallenge$())
.catch(err => Observable.just({ err })) .catch(err => Observable.just({ err }))
@ -161,6 +164,7 @@ $(document).ready(function() {
} }
common.codeStorage.updateStorage(challengeName, originalCode); common.codeStorage.updateStorage(challengeName, originalCode);
common.displayTestResults(tests); common.displayTestResults(tests);
return null;
}, },
(err) => { (err) => {
console.error(err); console.error(err);
@ -168,4 +172,5 @@ $(document).ready(function() {
} }
); );
} }
return null;
}); });

View File

@ -110,7 +110,7 @@ window.common = (function({ common = { init: [] }}) {
return null; return null;
} }
execInProgress = true; execInProgress = true;
setTimeout(function() { return setTimeout(function() {
if ( if (
$($('.scroll-locker').children()[0]).height() - 800 > e.detail $($('.scroll-locker').children()[0]).height() - 800 > e.detail
) { ) {

View File

@ -92,18 +92,16 @@ window.common = (function({ $, common = { init: [] }}) {
} }
function handleActionClick(e) { function handleActionClick(e) {
var props = common.challengeSeed[0] || var props = common.challengeSeed[0] || { stepIndex: [] };
{ stepIndex: [] };
var $el = $(this); var $el = $(this);
var index = +$el.attr('id'); var index = +$el.attr('id');
var propIndex = props.stepIndex.indexOf(index); var propIndex = props.stepIndex.indexOf(index);
if (propIndex === -1) { if (propIndex === -1) {
return $el return $el.parent()
.parent() .find('.disabled')
.find('.disabled') .removeClass('disabled');
.removeClass('disabled');
} }
// an API action // an API action
@ -112,30 +110,26 @@ window.common = (function({ $, common = { init: [] }}) {
var prop = props.properties[propIndex]; var prop = props.properties[propIndex];
var api = props.apis[propIndex]; var api = props.apis[propIndex];
if (common[prop]) { if (common[prop]) {
return $el return $el.parent()
.parent()
.find('.disabled')
.removeClass('disabled');
}
$
.post(api)
.done(function(data) {
// assume a boolean indicates passing
if (typeof data === 'boolean') {
return $el
.parent()
.find('.disabled') .find('.disabled')
.removeClass('disabled'); .removeClass('disabled');
} }
// assume api returns string when fails return $.post(api)
$el .done(function(data) {
.parent() // assume a boolean indicates passing
.find('.disabled') if (typeof data === 'boolean') {
.replaceWith('<p>' + data + '</p>'); return $el.parent()
}) .find('.disabled')
.fail(function() { .removeClass('disabled');
console.log('failed'); }
}); // assume api returns string when fails
return $el.parent()
.find('.disabled')
.replaceWith('<p>' + data + '</p>');
})
.fail(function() {
console.log('failed');
});
} }
function handleFinishClick(e) { function handleFinishClick(e) {
@ -199,6 +193,7 @@ window.common = (function({ $, common = { init: [] }}) {
$(nextBtnClass).click(handleNextStepClick); $(nextBtnClass).click(handleNextStepClick);
$(actionBtnClass).click(handleActionClick); $(actionBtnClass).click(handleActionClick);
$(finishBtnClass).click(handleFinishClick); $(finishBtnClass).click(handleFinishClick);
return null;
}); });
return common; return common;

View File

@ -1,9 +0,0 @@
export default function toastSaga(err$, toast) {
err$
.doOnNext(() => toast({
type: 'error',
title: 'Oops, something went wrong',
message: `Something went wrong, please try again later`
}))
.subscribe(err => console.error(err));
}

View File

@ -1,69 +0,0 @@
import { Disposable, Observable } from 'rx';
export function location$(history) {
return Observable.create(function(observer) {
const dispose = history.listen(function(location) {
observer.onNext(location);
});
return Disposable.create(() => {
dispose();
});
});
}
const emptyLocation = {
pathname: '',
search: '',
hash: ''
};
let prevKey;
let isSyncing = false;
export default function historySaga(
history,
updateLocation,
goTo,
goBack,
routerState$
) {
routerState$.subscribe(
location => {
if (!location) {
return null;
}
// store location has changed, update history
if (!location.key || location.key !== prevKey) {
isSyncing = true;
history.transitionTo({ ...emptyLocation, ...location });
isSyncing = false;
}
}
);
location$(history)
.doOnNext(location => {
prevKey = location.key;
if (isSyncing) {
return null;
}
return updateLocation(location);
})
.subscribe(() => {});
goTo
.doOnNext((route = '/') => {
history.push(route);
})
.subscribe(() => {});
goBack
.doOnNext(() => {
history.goBack();
})
.subscribe(() => {});
}

View File

@ -1,99 +1,71 @@
import unused from './es6-shims'; // eslint-disable-line import './es6-shims';
import Rx from 'rx'; import Rx from 'rx';
import React from 'react'; import React from 'react';
import Fetchr from 'fetchr'; import debug from 'debug';
import debugFactory from 'debug';
import { Router } from 'react-router'; import { Router } from 'react-router';
import { createLocation, createHistory } from 'history'; import { routeReducer as routing, syncHistory } from 'react-router-redux';
import { hydrate } from 'thundercats'; import { createHistory } from 'history';
import { render$ } from 'thundercats-react';
import app$ from '../common/app'; import app$ from '../common/app';
import historySaga from './history-saga'; import provideStore from '../common/app/provide-store';
import errSaga from './err-saga';
const debug = debugFactory('fcc:client'); // client specific sagas
import sagas from './sagas';
// render to observable
import render from '../common/app/utils/render';
const log = debug('fcc:client');
const DOMContianer = document.getElementById('fcc'); const DOMContianer = document.getElementById('fcc');
const catState = window.__fcc__.data || {}; const initialState = window.__fcc__.data;
const services = new Fetchr({
xhrPath: '/services' const serviceOptions = { xhrPath: '/services' };
});
Rx.config.longStackSupport = !!debug.enabled; Rx.config.longStackSupport = !!debug.enabled;
const history = createHistory(); const history = createHistory();
const appLocation = createLocation( const appLocation = history.createLocation(
location.pathname + location.search location.pathname + location.search
); );
const routingMiddleware = syncHistory(history);
const devTools = window.devToolsExtension ? window.devToolsExtension() : f => f;
const shouldRouterListenForReplays = !!window.devToolsExtension;
const clientSagaOptions = { doc: document };
// returns an observable // returns an observable
app$({ history, location: appLocation }) app$({
.flatMap( location: appLocation,
({ AppCat }) => { history,
// instantiate the cat with service serviceOptions,
const appCat = AppCat(null, services, history); initialState,
// hydrate the stores middlewares: [
return hydrate(appCat, catState).map(() => appCat); routingMiddleware,
}, ...sagas.map(saga => saga(clientSagaOptions))
// not using nextLocation at the moment but will be used for ],
// redirects in the future reducers: { routing },
({ nextLocation, props }, appCat) => ({ nextLocation, props, appCat }) enhancers: [ devTools ]
) })
.doOnNext(({ appCat }) => { .flatMap(({ props, store }) => {
const appStore$ = appCat.getStore('appStore');
const { // because of weirdness in react-routers match function
toast, // we replace the wrapped returned in props with the first one
updateLocation, // we passed in. This might be fixed in react-router 2.0
goTo,
goBack
} = appCat.getActions('appActions');
const routerState$ = appStore$
.map(({ location }) => location)
.filter(location => !!location);
// set page title
appStore$
.pluck('title')
.distinctUntilChanged()
.doOnNext(title => document.title = title)
.subscribe(() => {});
historySaga(
history,
updateLocation,
goTo,
goBack,
routerState$
);
const err$ = appStore$
.pluck('err')
.filter(err => !!err)
.distinctUntilChanged();
errSaga(err$, toast);
})
// allow store subscribe to subscribe to actions
.delay(10)
.flatMap(({ props, appCat }) => {
props.history = history; props.history = history;
return render$( if (shouldRouterListenForReplays && store) {
appCat, log('routing middleware listening for replays');
React.createElement(Router, props), routingMiddleware.listenForReplays(store);
}
log('rendering');
return render(
provideStore(React.createElement(Router, props), store),
DOMContianer DOMContianer
); );
}) })
.subscribe( .subscribe(
() => { () => debug('react rendered'),
debug('react rendered'); err => { throw err; },
}, () => debug('react closed subscription')
err => {
throw err;
},
() => {
debug('react closed subscription');
}
); );

View File

@ -93,6 +93,7 @@ main = (function(main, global) {
'<span>Free Code Camp\'s Main Chat</span>' + '<span>Free Code Camp\'s Main Chat</span>' +
'</div>' '</div>'
); );
return null;
}); });
@ -233,7 +234,7 @@ $(document).ready(function() {
}; };
$('#story-submit').unbind('click'); $('#story-submit').unbind('click');
$.post('/stories/', data) return $.post('/stories/', data)
.fail(function() { .fail(function() {
$('#story-submit').bind('click', storySubmitButtonHandler); $('#story-submit').bind('click', storySubmitButtonHandler);
}) })
@ -243,6 +244,7 @@ $(document).ready(function() {
return null; return null;
} }
window.location = '/stories/' + storyLink; window.location = '/stories/' + storyLink;
return null;
}); });
}; };

0
client/sagas/README.md Normal file
View File

20
client/sagas/err-saga.js Normal file
View File

@ -0,0 +1,20 @@
// () =>
// (store: Store) =>
// (next: (action: Action) => Object) =>
// errSaga(action: Action) => Object|Void
export default () => ({ dispatch }) => next => {
return function errorSaga(action) {
const result = next(action);
if (!action.error) { return result; }
console.error(action.error);
return dispatch({
type: 'app.makeToast',
payload: {
type: 'error',
title: 'Oops, something went wrong',
message: 'Something went wrong, please try again later'
}
});
};
};

View File

@ -0,0 +1,24 @@
import { hardGoTo } from '../../common/app/redux/types';
const loc = typeof window !== 'undefined' ?
window.location :
{};
export default () => ({ dispatch }) => next => {
return function hardGoToSaga(action) {
const result = next(action);
if (action.type !== hardGoTo) {
return result;
}
if (!loc.pathname) {
dispatch({
type: 'app.error',
error: new Error('no location object found')
});
}
loc.pathname = action.payload || '/map';
return null;
};
};

6
client/sagas/index.js Normal file
View File

@ -0,0 +1,6 @@
import errSaga from './err-saga';
import titleSaga from './title-saga';
import localStorageSaga from './local-storage-saga';
import hardGoToSaga from './hard-go-to-saga';
export default [ errSaga, titleSaga, localStorageSaga, hardGoToSaga ];

View File

@ -0,0 +1,69 @@
import {
saveForm,
clearForm,
loadSavedForm
} from '../../common/app/routes/Jobs/redux/types';
import {
saveCompleted,
loadSavedFormCompleted
} from '../../common/app/routes/Jobs/redux/actions';
const formKey = 'newJob';
let enabled = false;
let store = typeof window !== 'undefined' ?
window.localStorage :
false;
try {
const testKey = '__testKey__';
store.setItem(testKey, testKey);
enabled = store.getItem(testKey) === testKey;
store.removeItem(testKey);
} catch (e) {
enabled = !e;
}
if (!enabled) {
console.error(new Error('No localStorage found'));
}
export default () => ({ dispatch }) => next => {
return function localStorageSaga(action) {
if (!enabled) { return next(action); }
if (action.type === saveForm) {
const form = action.payload;
try {
store.setItem(formKey, JSON.stringify(form));
next(action);
return dispatch(saveCompleted(form));
} catch (error) {
return dispatch({
type: 'app.handleError',
error
});
}
}
if (action.type === clearForm) {
store.removeItem(formKey);
return null;
}
if (action.type === loadSavedForm) {
const formString = store.getItem(formKey);
try {
const form = JSON.parse(formString);
return dispatch(loadSavedFormCompleted(form));
} catch (error) {
return dispatch({
type: 'app.handleError',
error
});
}
}
return next(action);
};
};

View File

@ -0,0 +1,17 @@
// (doc: Object) =>
// () =>
// (next: (action: Action) => Object) =>
// titleSage(action: Action) => Object|Void
export default ({ doc }) => ({ getState }) => next => {
return function titleSage(action) {
// get next state
const result = next(action);
if (action.type !== 'app.updateTitle') {
return result;
}
const state = getState();
const newTitle = state.app.title;
doc.title = newTitle;
return result;
};
};

View File

@ -1,81 +1,89 @@
import React, { PropTypes } from 'react'; import React, { PropTypes } from 'react';
import { Row } from 'react-bootstrap'; import { Row } from 'react-bootstrap';
import { ToastMessage, ToastContainer } from 'react-toastr'; import { ToastMessage, ToastContainer } from 'react-toastr';
import { contain } from 'thundercats-react'; import { compose } from 'redux';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchUser } from './redux/actions';
import contain from './utils/professor-x';
import Nav from './components/Nav'; import Nav from './components/Nav';
const toastMessageFactory = React.createFactory(ToastMessage.animation); const toastMessageFactory = React.createFactory(ToastMessage.animation);
export default contain( const mapStateToProps = createSelector(
{ state => state.app,
actions: ['appActions'], ({
store: 'appStore', username,
fetchAction: 'appActions.getUser', points,
isPrimed({ username }) { picture,
return !!username; toast
}, }) => ({
map({ username,
username, points,
points, picture,
picture, toast
toast
}) {
return {
username,
points,
picture,
toast
};
},
getPayload(props) {
return {
isPrimed: !!props.username
};
}
},
React.createClass({
displayName: 'FreeCodeCamp',
propTypes: {
appActions: PropTypes.object,
children: PropTypes.node,
username: PropTypes.string,
points: PropTypes.number,
picture: PropTypes.string,
toast: PropTypes.object
},
componentWillReceiveProps({ toast: nextToast = {} }) {
const { toast = {} } = this.props;
if (toast.id !== nextToast.id) {
this.refs.toaster[nextToast.type || 'success'](
nextToast.message,
nextToast.title,
{
closeButton: true,
timeOut: 10000
}
);
}
},
render() {
const { username, points, picture } = this.props;
const navProps = { username, points, picture };
return (
<div>
<Nav
{ ...navProps }/>
<Row>
{ this.props.children }
</Row>
<ToastContainer
className='toast-bottom-right'
ref='toaster'
toastMessageFactory={ toastMessageFactory } />
</div>
);
}
}) })
); );
const fetchContainerOptions = {
fetchAction: 'fetchUser',
isPrimed({ username }) {
return !!username;
}
};
// export plain class for testing
export class FreeCodeCamp extends React.Component {
static displayName = 'FreeCodeCamp';
static propTypes = {
children: PropTypes.node,
username: PropTypes.string,
points: PropTypes.number,
picture: PropTypes.string,
toast: PropTypes.object
};
componentWillReceiveProps({ toast: nextToast = {} }) {
const { toast = {} } = this.props;
if (toast.id !== nextToast.id) {
this.refs.toaster[nextToast.type || 'success'](
nextToast.message,
nextToast.title,
{
closeButton: true,
timeOut: 10000
}
);
}
}
render() {
const { username, points, picture } = this.props;
const navProps = { username, points, picture };
return (
<div>
<Nav { ...navProps }/>
<Row>
{ this.props.children }
</Row>
<ToastContainer
className='toast-bottom-right'
ref='toaster'
toastMessageFactory={ toastMessageFactory } />
</div>
);
}
}
const wrapComponent = compose(
// connect Component to Redux Store
connect(mapStateToProps, { fetchUser }),
// handles prefetching data
contain(fetchContainerOptions)
);
export default wrapComponent(FreeCodeCamp);

View File

@ -1,65 +0,0 @@
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 from './routes/Hikes/flux';
import JobActions from './routes/Jobs/flux';
const ajaxStamp = stamp({
methods: {
postJSON$,
post$
}
});
export default Cat().init(({ instance: cat, args: [services] }) => {
const serviceStamp = stamp({
methods: {
readService$(resource, params, config) {
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,17 +0,0 @@
import Rx from 'rx';
import { match } from 'react-router';
import App from './App.jsx';
import AppCat from './Cat';
import childRoutes from './routes';
const route$ = Rx.Observable.fromNodeCallback(match);
const routes = Object.assign({ components: App }, childRoutes);
export default function app$({ location, history }) {
return route$({ routes, location, history })
.map(([nextLocation, props]) => {
return { nextLocation, props, AppCat };
});
}

View File

@ -0,0 +1 @@
Currently not used

View File

@ -23,20 +23,20 @@ const logoElement = (
); );
const toggleButtonChild = ( const toggleButtonChild = (
<Col xs={ 12 }> <Col xs={ 12 }>
<span className='hamburger-text'>Menu</span> <span className='hamburger-text'>Menu</span>
</Col> </Col>
); );
export default React.createClass({ export default class extends React.Component {
displayName: 'Nav', static displayName = 'Nav';
propTypes: { static propTypes = {
points: PropTypes.number, points: PropTypes.number,
picture: PropTypes.string, picture: PropTypes.string,
signedIn: PropTypes.bool, signedIn: PropTypes.bool,
username: PropTypes.string username: PropTypes.string
}, };
renderLinks() { renderLinks() {
return navLinks.map(({ content, link, react, target }, index) => { return navLinks.map(({ content, link, react, target }, index) => {
@ -63,7 +63,7 @@ export default React.createClass({
</NavItem> </NavItem>
); );
}); });
}, }
renderPoints(username, points) { renderPoints(username, points) {
if (!username) { if (!username) {
@ -76,7 +76,7 @@ export default React.createClass({
[ { points } ] [ { points } ]
</FCCNavItem> </FCCNavItem>
); );
}, }
renderSignin(username, picture) { renderSignin(username, picture) {
if (username) { if (username) {
@ -100,7 +100,7 @@ export default React.createClass({
</NavItem> </NavItem>
); );
} }
}, }
render() { render() {
const { username, points, picture } = this.props; const { username, points, picture } = this.props;
@ -124,4 +124,4 @@ export default React.createClass({
</Navbar> </Navbar>
); );
} }
}); }

View File

@ -6,17 +6,21 @@ function goToServer(path) {
win.location = '/' + path; win.location = '/' + path;
} }
export default React.createClass({ export default class extends React.Component {
displayName: 'NotFound', static displayName = 'NotFound';
propTypes: {
static propTypes = {
params: PropTypes.object params: PropTypes.object
}, };
componentWillMount() { componentWillMount() {
goToServer(this.props.params.splat); goToServer(this.props.params.splat);
}, }
componentDidMount() { componentDidMount() {
}, }
render() { render() {
return <span></span>; return <span></span>;
} }
}); }

79
common/app/create-app.jsx Normal file
View File

@ -0,0 +1,79 @@
import { Observable } from 'rx';
import { match } from 'react-router';
import { compose, createStore, applyMiddleware } from 'redux';
// main app
import App from './App.jsx';
// app routes
import childRoutes from './routes';
// redux
import createReducer from './create-reducer';
import middlewares from './middlewares';
import sagas from './sagas';
// general utils
import servicesCreator from '../utils/services-creator';
const createRouteProps = Observable.fromNodeCallback(match);
const routes = { components: App, ...childRoutes };
//
// createApp(settings: {
// location?: Location|String,
// history?: History,
// initialState?: Object|Void,
// serviceOptions?: Object,
// middlewares?: Function[],
// sideReducers?: Object
// enhancers?: Function[],
// sagas?: Function[],
// }) => Observable
//
// Either location or history must be defined
export default function createApp({
location,
history,
initialState,
serviceOptions = {},
middlewares: sideMiddlewares = [],
enhancers: sideEnhancers = [],
reducers: sideReducers = {},
sagas: sideSagas = []
}) {
const sagaOptions = {
services: servicesCreator(null, serviceOptions)
};
const enhancers = [
applyMiddleware(
...middlewares,
...sideMiddlewares,
...[ ...sagas, ...sideSagas].map(saga => saga(sagaOptions)),
),
// enhancers must come after middlewares
// on client side these are things like Redux DevTools
...sideEnhancers
];
const reducer = createReducer(sideReducers);
// create composed store enhancer
// use store enhancer function to enhance `createStore` function
// call enhanced createStore function with reducer and initialState
// to create store
const store = compose(...enhancers)(createStore)(reducer, initialState);
// createRouteProps({
// redirect: LocationDescriptor,
// history: History,
// routes: Object
// }) => Observable
return createRouteProps({ routes, location, history })
.map(([ redirect, props ]) => ({
redirect,
props,
reducer,
store
}));
}

View File

@ -0,0 +1,19 @@
import { combineReducers } from 'redux';
import { reducer as formReducer } from 'redux-form';
import { reducer as app } from './redux';
import { reducer as hikesApp } from './routes/Hikes/redux';
import {
reducer as jobsApp,
formNormalizer as jobsNormalizer
} from './routes/Jobs/redux';
export default function createReducer(sideReducers = {}) {
return combineReducers({
...sideReducers,
app,
hikesApp,
jobsApp,
form: formReducer.normalize(jobsNormalizer)
});
}

View File

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

View File

@ -1,2 +0,0 @@
export AppActions from './Actions';
export AppStore from './Store';

View File

@ -1 +1 @@
export default from './app-stream.jsx'; export default from './create-app.jsx';

View File

View File

@ -0,0 +1,11 @@
/* eslint-disable react/display-name */
import React from 'react';
import { Provider } from 'react-redux';
export default function provideStore(element, store) {
return React.createElement(
Provider,
{ store },
element
);
}

View File

@ -0,0 +1,32 @@
import { createAction } from 'redux-actions';
import types from './types';
// updateTitle(title: String) => Action
export const updateTitle = createAction(types.updateTitle);
let id = 0;
// makeToast({ type?: String, message: String, title: String }) => Action
export const makeToast = createAction(
types.makeToast,
toast => {
id += 1;
return {
...toast,
id,
type: toast.type || 'info'
};
}
);
// fetchUser() => Action
// used in combination with fetch-user-saga
export const fetchUser = createAction(types.fetchUser);
// setUser(userInfo: Object) => Action
export const setUser = createAction(types.setUser);
// updatePoints(points: Number) => Action
export const updatePoints = createAction(types.updatePoints);
// hardGoTo(path: String) => Action
export const hardGoTo = createAction(types.hardGoTo);

View File

@ -0,0 +1,39 @@
import { Observable } from 'rx';
import { handleError, setUser, fetchUser } from './types';
export default ({ services }) => ({ dispatch }) => next => {
return function getUserSaga(action) {
if (action.type !== fetchUser) {
return next(action);
}
return services.readService$({ service: 'user' })
.map(({
username,
picture,
progressTimestamps = [],
isFrontEndCert,
isBackEndCert,
isFullStackCert
}) => {
return {
type: setUser,
payload: {
username,
picture,
points: progressTimestamps.length,
isFrontEndCert,
isBackEndCert,
isFullStackCert,
isSignedIn: true
}
};
})
.catch(error => Observable.just({
type: handleError,
error
}))
.doOnNext(dispatch);
};
};

View File

@ -0,0 +1,6 @@
export { default as reducer } from './reducer';
export { default as actions } from './actions';
export { default as types } from './types';
import fetchUserSaga from './fetch-user-saga';
export const sagas = [ fetchUserSaga ];

View File

@ -0,0 +1,35 @@
import { handleActions } from 'redux-actions';
import types from './types';
export default handleActions(
{
[types.updateTitle]: (state, { payload = 'Learn To Code' }) => ({
...state,
title: payload + ' | Free Code Camp'
}),
[types.makeToast]: (state, { payload: toast }) => ({
...state,
toast
}),
[types.setUser]: (state, { payload: user }) => ({ ...state, ...user }),
[types.challengeSaved]: (state, { payload: { points = 0 } }) => ({
...state,
points
}),
[types.updatePoints]: (state, { payload: points }) => ({
...state,
points
})
},
{
title: 'Learn To Code | Free Code Camp',
username: null,
picture: null,
points: 0,
isSignedIn: false
}
);

14
common/app/redux/types.js Normal file
View File

@ -0,0 +1,14 @@
import createTypes from '../utils/create-types';
export default createTypes([
'updateTitle',
'fetchUser',
'setUser',
'makeToast',
'updatePoints',
'handleError',
// used to hit the server
'hardGoTo'
], 'app');

View File

@ -1 +0,0 @@
future home of FAVS app

View File

@ -1,63 +1,76 @@
import React, { PropTypes } from 'react'; import React, { PropTypes } from 'react';
import { contain } from 'thundercats-react'; import { connect } from 'react-redux';
import { Col, Row } from 'react-bootstrap'; import { Col, Row } from 'react-bootstrap';
import { createSelector } from 'reselect';
import Lecture from './Lecture.jsx'; import Lecture from './Lecture.jsx';
import Questions from './Questions.jsx'; import Questions from './Questions.jsx';
import { resetHike } from '../redux/actions';
import { getCurrentHike } from '../redux/selectors';
export default contain( const mapStateToProps = createSelector(
{ getCurrentHike,
actions: ['hikesActions'] state => state.hikesApp.shouldShowQuestions,
}, (currentHike, shouldShowQuestions) => ({
React.createClass({ title: currentHike ? currentHike.title : '',
displayName: 'Hike', shouldShowQuestions
propTypes: {
currentHike: PropTypes.object,
hikesActions: PropTypes.object,
params: PropTypes.object,
showQuestions: PropTypes.bool
},
componentWillUnmount() {
this.props.hikesActions.resetHike();
},
componentWillReceiveProps({ params: { dashedName } }) {
if (this.props.params.dashedName !== dashedName) {
this.props.hikesActions.resetHike();
}
},
renderBody(showQuestions) {
if (showQuestions) {
return <Questions />;
}
return <Lecture />;
},
render() {
const {
currentHike: { title } = {},
showQuestions
} = this.props;
return (
<Col xs={ 12 }>
<Row>
<header className='text-center'>
<h4>{ title }</h4>
</header>
<hr />
<div className='spacer' />
<section
className={ 'text-center' }
title={ title }>
{ this.renderBody(showQuestions) }
</section>
</Row>
</Col>
);
}
}) })
); );
// export plain component for testing
export class Hike extends React.Component {
static displayName = 'Hike';
static propTypes = {
// actions
resetHike: PropTypes.func,
// ui
title: PropTypes.string,
params: PropTypes.object,
shouldShowQuestions: PropTypes.bool
};
componentWillUnmount() {
this.props.resetHike();
}
componentWillReceiveProps({ params: { dashedName } }) {
if (this.props.params.dashedName !== dashedName) {
this.props.resetHike();
}
}
renderBody(showQuestions) {
if (showQuestions) {
return <Questions />;
}
return <Lecture />;
}
render() {
const {
title,
shouldShowQuestions
} = this.props;
return (
<Col xs={ 12 }>
<Row>
<header className='text-center'>
<h4>{ title }</h4>
</header>
<hr />
<div className='spacer' />
<section
className={ 'text-center' }
title={ title }>
{ this.renderBody(shouldShowQuestions) }
</section>
</Row>
</Col>
);
}
}
// export redux aware component
export default connect(mapStateToProps, { resetHike })(Hike);

View File

@ -1,74 +1,82 @@
import React, { PropTypes } from 'react'; import React, { PropTypes } from 'react';
import { compose } from 'redux';
import { connect } from 'react-redux';
import { Row } from 'react-bootstrap'; import { Row } from 'react-bootstrap';
import { contain } from 'thundercats-react'; import PureComponent from 'react-pure-render/component';
// import debugFactory from 'debug'; import { createSelector } from 'reselect';
// import debug from 'debug';
import HikesMap from './Map.jsx'; import HikesMap from './Map.jsx';
import { updateTitle } from '../../../redux/actions';
import { fetchHikes } from '../redux/actions';
// const debug = debugFactory('freecc:hikes'); import contain from '../../../utils/professor-x';
export default contain( // const log = debug('fcc:hikes');
{
store: 'appStore', const mapStateToProps = createSelector(
map(state) { state => state.hikesApp.hikes.entities,
return state.hikesApp; state => state.hikesApp.hikes.results,
}, (hikesMap, hikesByDashedName)=> {
actions: ['appActions'], if (!hikesMap || !hikesByDashedName) {
fetchAction: 'hikesActions.fetchHikes', return { hikes: [] };
getPayload: ({ hikes, params }) => ({
isPrimed: (hikes && !!hikes.length),
dashedName: params.dashedName
}),
shouldContainerFetch(props, nextProps) {
return props.params.dashedName !== nextProps.params.dashedName;
} }
}, return {
React.createClass({ hikes: hikesByDashedName.map(dashedName => hikesMap[dashedName])
displayName: 'Hikes', };
}
propTypes: {
appActions: PropTypes.object,
children: PropTypes.element,
currentHike: PropTypes.object,
hikes: PropTypes.array,
params: PropTypes.object,
showQuestions: PropTypes.bool
},
componentWillMount() {
const { appActions } = this.props;
appActions.setTitle('Videos');
},
renderMap(hikes) {
return (
<HikesMap hikes={ hikes }/>
);
},
renderChild({ children, ...props }) {
if (!children) {
return null;
}
return React.cloneElement(children, props);
},
render() {
const { hikes } = this.props;
const { dashedName } = this.props.params;
const preventOverflow = { overflow: 'hidden' };
return (
<div>
<Row style={ preventOverflow }>
{
// render sub-route
this.renderChild({ ...this.props, dashedName }) ||
// if no sub-route render hikes map
this.renderMap(hikes)
}
</Row>
</div>
);
}
})
); );
const fetchOptions = {
fetchAction: 'fetchHikes',
isPrimed: ({ hikes }) => hikes && !!hikes.length,
getActionArgs: ({ params: { dashedName } }) => [ dashedName ],
shouldContainerFetch(props, nextProps) {
return props.params.dashedName !== nextProps.params.dashedName;
}
};
export class Hikes extends PureComponent {
static displayName = 'Hikes';
static propTypes = {
children: PropTypes.element,
hikes: PropTypes.array,
params: PropTypes.object,
updateTitle: PropTypes.func
};
componentWillMount() {
const { updateTitle } = this.props;
updateTitle('Hikes');
}
renderMap(hikes) {
return (
<HikesMap hikes={ hikes }/>
);
}
render() {
const { hikes } = this.props;
const preventOverflow = { overflow: 'hidden' };
return (
<div>
<Row style={ preventOverflow }>
{
// render sub-route
this.props.children ||
// if no sub-route render hikes map
this.renderMap(hikes)
}
</Row>
</div>
);
}
}
// export redux and fetch aware component
export default compose(
connect(mapStateToProps, { fetchHikes, updateTitle }),
contain(fetchOptions)
)(Hikes);

View File

@ -1,95 +1,103 @@
import React, { PropTypes } from 'react'; import React, { PropTypes } from 'react';
import { contain } from 'thundercats-react'; import { connect } from 'react-redux';
import { Button, Col, Row } from 'react-bootstrap'; import { Button, Col, Row } from 'react-bootstrap';
import { History } from 'react-router';
import Vimeo from 'react-vimeo'; import Vimeo from 'react-vimeo';
import debugFactory from 'debug'; import { createSelector } from 'reselect';
import debug from 'debug';
const debug = debugFactory('freecc:hikes'); import { hardGoTo } from '../../../redux/actions';
import { toggleQuestionView } from '../redux/actions';
import { getCurrentHike } from '../redux/selectors';
export default contain( const log = debug('fcc:hikes');
{
actions: ['hikesActions'],
store: 'appStore',
map(state) {
const {
currentHike: {
dashedName,
description,
challengeSeed: [id] = [0]
} = {}
} = state.hikesApp;
return { const mapStateToProps = createSelector(
dashedName, getCurrentHike,
description, (currentHike) => {
id const {
}; dashedName,
} description,
}, challengeSeed: [id] = [0]
React.createClass({ } = currentHike;
displayName: 'Lecture', return {
mixins: [History], id,
dashedName,
propTypes: { description
dashedName: PropTypes.string, };
description: PropTypes.array, }
id: PropTypes.string,
hikesActions: PropTypes.object
},
shouldComponentUpdate(nextProps) {
const { props } = this;
return nextProps.id !== props.id;
},
handleError: debug,
handleFinish(hikesActions) {
debug('loading questions');
hikesActions.toggleQuestions();
},
renderTranscript(transcript, dashedName) {
return transcript.map((line, index) => (
<p
className='lead text-left'
key={ dashedName + index }>
{ line }
</p>
));
},
render() {
const {
id = '1',
description = [],
hikesActions
} = this.props;
const dashedName = 'foo';
return (
<Col xs={ 12 }>
<Row>
<Vimeo
onError={ this.handleError }
onFinish= { () => this.handleFinish(hikesActions) }
videoId={ id } />
</Row>
<Row>
<article>
{ this.renderTranscript(description, dashedName) }
</article>
<Button
block={ true }
bsSize='large'
bsStyle='primary'
onClick={ () => this.handleFinish(hikesActions) }>
Take me to the Questions
</Button>
</Row>
</Col>
);
}
})
); );
export class Lecture extends React.Component {
static displayName = 'Lecture';
static propTypes = {
// actions
toggleQuestionView: PropTypes.func,
// ui
id: PropTypes.number,
description: PropTypes.array,
dashedName: PropTypes.string,
hardGoTo: PropTypes.func
};
componentWillMount() {
if (!this.props.id) {
this.props.hardGoTo('/map');
}
}
shouldComponentUpdate(nextProps) {
const { props } = this;
return nextProps.id !== props.id;
}
handleError: log;
renderTranscript(transcript, dashedName) {
return transcript.map((line, index) => (
<p
className='lead text-left'
key={ dashedName + index }>
{ line }
</p>
));
}
render() {
const {
id = '1',
description = [],
toggleQuestionView
} = this.props;
const dashedName = 'foo';
return (
<Col xs={ 12 }>
<Row>
<Vimeo
onError={ this.handleError }
onFinish= { toggleQuestionView }
videoId={ '' + id } />
</Row>
<Row>
<article>
{ this.renderTranscript(description, dashedName) }
</article>
<Button
block={ true }
bsSize='large'
bsStyle='primary'
onClick={ toggleQuestionView }>
Take me to the Questions
</Button>
</Row>
</Col>
);
}
}
export default connect(
mapStateToProps,
{ hardGoTo, toggleQuestionView }
)(Lecture);

View File

@ -14,10 +14,10 @@ export default React.createClass({
hikes = [{}] hikes = [{}]
} = this.props; } = this.props;
const vidElements = hikes.map(({ title, dashedName}) => { const vidElements = hikes.map(({ title, dashedName }) => {
return ( return (
<ListGroupItem key={ dashedName }> <ListGroupItem key={ dashedName }>
<Link to={ `/hikes/${dashedName}` }> <Link to={ `/videos/${dashedName}` }>
<h3>{ title }</h3> <h3>{ title }</h3>
</Link> </Link>
</ListGroupItem> </ListGroupItem>

View File

@ -1,177 +1,212 @@
import React, { PropTypes } from 'react'; import React, { PropTypes } from 'react';
import { spring, Motion } from 'react-motion'; import { spring, Motion } from 'react-motion';
import { contain } from 'thundercats-react'; import { connect } from 'react-redux';
import { Button, Col, Row } from 'react-bootstrap'; import { Button, Col, Row } from 'react-bootstrap';
import { CompositeDisposable } from 'rx';
import { createSelector } from 'reselect';
import {
answerQuestion,
moveQuestion,
releaseQuestion,
grabQuestion
} from '../redux/actions';
import { getCurrentHike } from '../redux/selectors';
const answerThreshold = 100; const answerThreshold = 100;
const springProperties = { stiffness: 120, damping: 10 };
const actionsToBind = {
answerQuestion,
moveQuestion,
releaseQuestion,
grabQuestion
};
export default contain( const mapStateToProps = createSelector(
{ getCurrentHike,
store: 'appStore', state => state.hikesApp,
actions: ['hikesActions'], state => state.app.isSignedIn,
map({ hikesApp, username }) { (currentHike, ui, isSignedIn) => {
const { const {
currentHike, currentQuestion = 1,
currentQuestion = 1, mouse = [ 0, 0 ],
mouse = [0, 0], delta = [ 0, 0 ],
isCorrect = false, isCorrect = false,
delta = [0, 0], isPressed = false,
isPressed = false, shouldShakeQuestion = false
shake = false } = ui;
} = hikesApp;
return {
hike: currentHike,
currentQuestion,
mouse,
isCorrect,
delta,
isPressed,
shake,
isSignedIn: !!username
};
}
},
React.createClass({
displayName: 'Questions',
propTypes: { const {
hike: PropTypes.object, tests = []
currentQuestion: PropTypes.number, } = currentHike;
mouse: PropTypes.array,
isCorrect: PropTypes.bool,
delta: PropTypes.array,
isPressed: PropTypes.bool,
shake: PropTypes.bool,
isSignedIn: PropTypes.bool,
hikesActions: PropTypes.object
},
handleMouseUp(e, answer, info) { return {
e.stopPropagation(); tests,
if (!this.props.isPressed) { currentQuestion,
return null; isCorrect,
} mouse,
delta,
const { isPressed,
hike, shouldShakeQuestion,
currentQuestion, isSignedIn
isSignedIn, };
delta }
} = this.props;
this.props.hikesActions.releaseQuestion();
this.props.hikesActions.answer({
e,
answer,
hike,
delta,
currentQuestion,
isSignedIn,
info,
threshold: answerThreshold
});
},
handleMouseMove(e) {
if (!this.props.isPressed) {
return null;
}
const { delta, hikesActions } = this.props;
hikesActions.moveQuestion({ e, delta });
},
onAnswer(answer, userAnswer, info) {
const { isSignedIn, hike, currentQuestion, hikesActions } = this.props;
return (e) => {
if (e && e.preventDefault) {
e.preventDefault();
}
return hikesActions.answer({
answer,
userAnswer,
currentQuestion,
hike,
info,
isSignedIn
});
};
},
renderQuestion(number, question, answer, shake, info) {
const { hikesActions } = this.props;
const mouseUp = e => this.handleMouseUp(e, answer, info);
return ({ x }) => {
const style = {
WebkitTransform: `translate3d(${ x }px, 0, 0)`,
transform: `translate3d(${ x }px, 0, 0)`
};
return (
<article
className={ shake ? 'animated swing shake' : '' }
onMouseDown={ hikesActions.grabQuestion }
onMouseLeave={ mouseUp }
onMouseMove={ this.handleMouseMove }
onMouseUp={ mouseUp }
onTouchEnd={ mouseUp }
onTouchMove={ this.handleMouseMove }
onTouchStart={ hikesActions.grabQuestion }
style={ style }>
<h4>Question { number }</h4>
<p>{ question }</p>
</article>
);
};
},
render() {
const {
hike: { tests = [] } = {},
mouse: [x],
currentQuestion,
shake
} = this.props;
const [ question, answer, info ] = tests[currentQuestion - 1] || [];
const questionElement = this.renderQuestion(
currentQuestion,
question,
answer,
shake,
info
);
return (
<Col
onMouseUp={ e => this.handleMouseUp(e, answer, info) }
xs={ 8 }
xsOffset={ 2 }>
<Row>
<Motion style={{ x: spring(x, { stiffness: 120, damping: 10 }) }}>
{ questionElement }
</Motion>
<div className='spacer' />
<hr />
<div>
<Button
bsSize='large'
bsStyle='primary'
className='pull-left'
onClick={ this.onAnswer(answer, false, info) }>
false
</Button>
<Button
bsSize='large'
bsStyle='primary'
className='pull-right'
onClick={ this.onAnswer(answer, true, info) }>
true
</Button>
</div>
</Row>
</Col>
);
}
})
); );
class Question extends React.Component {
constructor(...args) {
super(...args);
this._subscriptions = new CompositeDisposable();
}
static displayName = 'Questions';
static propTypes = {
// actions
answerQuestion: PropTypes.func,
releaseQuestion: PropTypes.func,
moveQuestion: PropTypes.func,
grabQuestion: PropTypes.func,
// ui state
tests: PropTypes.array,
mouse: PropTypes.array,
delta: PropTypes.array,
isCorrect: PropTypes.bool,
isPressed: PropTypes.bool,
isSignedIn: PropTypes.bool,
currentQuestion: PropTypes.number,
shouldShakeQuestion: PropTypes.bool
};
componentWillUnmount() {
this._subscriptions.dispose();
}
handleMouseUp(e, answer, info) {
e.stopPropagation();
if (!this.props.isPressed) {
return null;
}
const {
releaseQuestion,
answerQuestion
} = this.props;
releaseQuestion();
const subscription = answerQuestion({
e,
answer,
info,
threshold: answerThreshold
})
.subscribe();
this._subscriptions.add(subscription);
return null;
}
handleMouseMove(isPressed, { delta, moveQuestion }) {
if (!isPressed) {
return null;
}
return e => moveQuestion({ e, delta });
}
onAnswer(answer, userAnswer, info) {
const { isSignedIn, answerQuestion } = this.props;
const subscriptions = this._subscriptions;
return e => {
if (e && e.preventDefault) {
e.preventDefault();
}
const subscription = answerQuestion({
answer,
userAnswer,
info,
isSignedIn
})
.subscribe();
subscriptions.add(subscription);
};
}
renderQuestion(number, question, answer, shouldShakeQuestion, info) {
const { grabQuestion, isPressed } = this.props;
const mouseUp = e => this.handleMouseUp(e, answer, info);
return ({ x }) => {
const style = {
WebkitTransform: `translate3d(${ x }px, 0, 0)`,
transform: `translate3d(${ x }px, 0, 0)`
};
return (
<article
className={ shouldShakeQuestion ? 'animated swing shake' : '' }
onMouseDown={ grabQuestion }
onMouseLeave={ mouseUp }
onMouseMove={ this.handleMouseMove(isPressed, this.props) }
onMouseUp={ mouseUp }
onTouchEnd={ mouseUp }
onTouchMove={ this.handleMouseMove(isPressed, this.props) }
onTouchStart={ grabQuestion }
style={ style }>
<h4>Question { number }</h4>
<p>{ question }</p>
</article>
);
};
}
render() {
const {
tests = [],
mouse: [xPosition],
currentQuestion,
shouldShakeQuestion
} = this.props;
const [ question, answer, info ] = tests[currentQuestion - 1] || [];
const questionElement = this.renderQuestion(
currentQuestion,
question,
answer,
shouldShakeQuestion,
info
);
return (
<Col
onMouseUp={ e => this.handleMouseUp(e, answer, info) }
xs={ 8 }
xsOffset={ 2 }>
<Row>
<Motion style={{ x: spring(xPosition, springProperties) }}>
{ questionElement }
</Motion>
<div className='spacer' />
<hr />
<div>
<Button
bsSize='large'
bsStyle='primary'
className='pull-left'
onClick={ this.onAnswer(answer, false, info) }>
false
</Button>
<Button
bsSize='large'
bsStyle='primary'
className='pull-right'
onClick={ this.onAnswer(answer, true, info) }>
true
</Button>
</div>
</Row>
</Col>
);
}
}
export default connect(mapStateToProps, actionsToBind)(Question);

View File

@ -1 +0,0 @@
export default from './Actions';

View File

@ -1,11 +1,6 @@
import Hikes from './components/Hikes.jsx'; import Hikes from './components/Hikes.jsx';
import Hike from './components/Hike.jsx'; import Hike from './components/Hike.jsx';
/*
* show video /hikes/someVideo
* show question /hikes/someVideo/question1
*/
export default { export default {
path: 'videos', path: 'videos',
component: Hikes, component: Hikes,

View File

@ -0,0 +1,57 @@
import { createAction } from 'redux-actions';
import types from './types';
import { getMouse } from './utils';
// fetchHikes(dashedName?: String) => Action
// used with fetchHikesSaga
export const fetchHikes = createAction(types.fetchHikes);
// fetchHikesCompleted(hikes: Object) => Action
// hikes is a normalized response from server
// called within fetchHikesSaga
export const fetchHikesCompleted = createAction(
types.fetchHikesCompleted,
(hikes, currentHike) => ({ hikes, currentHike })
);
export const resetHike = createAction(types.resetHike);
export const toggleQuestionView = createAction(types.toggleQuestionView);
export const grabQuestion = createAction(types.grabQuestion, e => {
let { pageX, pageY, touches } = e;
if (touches) {
e.preventDefault();
// these re-assigns the values of pageX, pageY from touches
({ pageX, pageY } = touches[0]);
}
const delta = [pageX, pageY];
const mouse = [0, 0];
return { delta, mouse };
});
export const releaseQuestion = createAction(types.releaseQuestion);
export const moveQuestion = createAction(
types.moveQuestion,
({ e, delta }) => getMouse(e, delta)
);
// answer({
// e: Event,
// answer: Boolean,
// userAnswer: Boolean,
// info: String,
// threshold: Number
// }) => Action
export const answerQuestion = createAction(types.answerQuestion);
export const startShake = createAction(types.startShake);
export const endShake = createAction(types.primeNextQuestion);
export const goToNextQuestion = createAction(types.goToNextQuestion);
export const hikeCompleted = createAction(types.hikeCompleted);
export const goToNextHike = createAction(types.goToNextHike);

View File

@ -0,0 +1,146 @@
import { Observable } from 'rx';
import { push } from 'react-router-redux';
import types from './types';
import { getMouse } from './utils';
import { makeToast, updatePoints } from '../../../redux/actions';
import { hikeCompleted, goToNextHike } from './actions';
import { postJSON$ } from '../../../../utils/ajax-stream';
import { getCurrentHike } from './selectors';
function handleAnswer(getState, dispatch, next, action) {
const {
e,
answer,
userAnswer,
info,
threshold
} = action.payload;
const state = getState();
const { id, name, challengeType, tests } = getCurrentHike(state);
const {
app: { isSignedIn },
hikesApp: {
currentQuestion,
delta = [ 0, 0 ]
}
} = state;
let finalAnswer;
// drag answer, compute response
if (typeof userAnswer === 'undefined') {
const [positionX] = getMouse(e, delta);
// question released under threshold
if (Math.abs(positionX) < threshold) {
return next(action);
}
if (positionX >= threshold) {
finalAnswer = true;
}
if (positionX <= -threshold) {
finalAnswer = false;
}
} else {
finalAnswer = userAnswer;
}
// incorrect question
if (answer !== finalAnswer) {
if (info) {
dispatch(makeToast({
title: 'Hint',
message: info,
type: 'info'
}));
}
return Observable
.just({ type: types.endShake })
.delay(500)
.startWith({ type: types.startShake })
.doOnNext(dispatch);
}
if (tests[currentQuestion]) {
return Observable
.just({ type: types.goToNextQuestion })
.delay(300)
.startWith({ type: types.primeNextQuestion })
.doOnNext(dispatch);
}
let updateUser$;
if (isSignedIn) {
const body = { id, name, challengeType: +challengeType };
updateUser$ = postJSON$('/completed-challenge', body)
// if post fails, will retry once
.retry(3)
.flatMap(({ alreadyCompleted, points }) => {
return Observable.of(
makeToast({
message:
'Challenge saved.' +
(alreadyCompleted ? '' : ' First time Completed!'),
title: 'Saved',
type: 'info'
}),
updatePoints(points),
);
})
.catch(error => {
return Observable.just({
type: 'app.error',
error
});
});
} else {
updateUser$ = Observable.empty();
}
const challengeCompleted$ = Observable.of(
goToNextHike(),
makeToast({
title: 'Congratulations!',
message: 'Hike completed.' + (isSignedIn ? ' Saving...' : ''),
type: 'success'
})
);
return Observable.merge(challengeCompleted$, updateUser$)
.delay(300)
.startWith(hikeCompleted(finalAnswer))
.catch(error => Observable.just({
type: 'error',
error
}))
// end with action so we know it is ok to transition
.doOnCompleted(() => dispatch({ type: types.transitionHike }))
.doOnNext(dispatch);
}
export default () => ({ getState, dispatch }) => next => {
return function answerSaga(action) {
if (action.type === types.answerQuestion) {
return handleAnswer(getState, dispatch, next, action);
}
// let goToNextQuestion hit reducers first
const result = next(action);
if (action.type === types.transitionHike) {
const { hikesApp: { currentHike } } = getState();
// if no next hike currentHike will equal '' which is falsy
if (currentHike) {
dispatch(push(`/videos/${currentHike}`));
} else {
dispatch(push('/map'));
}
}
return result;
};
};

View File

@ -0,0 +1,45 @@
import { Observable } from 'rx';
import { normalize, Schema, arrayOf } from 'normalizr';
// import debug from 'debug';
import types from './types';
import { fetchHikesCompleted } from './actions';
import { handleError } from '../../../redux/types';
import { findCurrentHike } from './utils';
// const log = debug('fcc:fetch-hikes-saga');
const hike = new Schema('hike', { idAttribute: 'dashedName' });
export default ({ services }) => ({ dispatch }) => next => {
return function fetchHikesSaga(action) {
if (action.type !== types.fetchHikes) {
return next(action);
}
const dashedName = action.payload;
return services.readService$({ service: 'hikes' })
.map(hikes => {
const { entities, result } = normalize(
{ hikes },
{ hikes: arrayOf(hike) }
);
hikes = {
entities: entities.hike,
results: result.hikes
};
const currentHike = findCurrentHike(hikes, dashedName);
return fetchHikesCompleted(hikes, currentHike);
})
.catch(error => {
return Observable.just({
type: handleError,
error
});
})
.doOnNext(dispatch);
};
};

View File

@ -0,0 +1,8 @@
export actions from './actions';
export reducer from './reducer';
export types from './types';
import answerSaga from './answer-saga';
import fetchHikesSaga from './fetch-hikes-saga';
export const sagas = [ answerSaga, fetchHikesSaga ];

View File

@ -3,7 +3,7 @@ import { Observable } from 'rx';
import { Actions } from 'thundercats'; import { Actions } from 'thundercats';
import debugFactory from 'debug'; import debugFactory from 'debug';
const debug = debugFactory('freecc:hikes:actions'); const debug = debugFactory('fcc:hikes:actions');
const noOp = { transform: () => {} }; const noOp = { transform: () => {} };
function getCurrentHike(hikes = [{}], dashedName, currentHike) { function getCurrentHike(hikes = [{}], dashedName, currentHike) {

View File

@ -0,0 +1,99 @@
import { handleActions } from 'redux-actions';
import types from './types';
import { findNextHikeName } from './utils';
const initialState = {
hikes: {
results: [],
entities: {}
},
// ui
// hike dashedName
currentHike: '',
// 1 indexed
currentQuestion: 1,
// [ xPosition, yPosition ]
mouse: [ 0, 0 ],
// change in mouse position since pressed
// [ xDelta, yDelta ]
delta: [ 0, 0 ],
isPressed: false,
isCorrect: false,
shouldShakeQuestion: false,
shouldShowQuestions: false
};
export default handleActions(
{
[types.toggleQuestionView]: state => ({
...state,
shouldShowQuestions: !state.shouldShowQuestions,
currentQuestion: 1
}),
[types.grabQuestion]: (state, { payload: { delta, mouse } }) => ({
...state,
isPressed: true,
delta,
mouse
}),
[types.releaseQuestion]: state => ({
...state,
isPressed: false,
mouse: [ 0, 0 ]
}),
[types.moveQuestion]: (state, { payload: mouse }) => ({ ...state, mouse }),
[types.resetHike]: state => ({
...state,
currentQuestion: 1,
shouldShowQuestions: false,
mouse: [0, 0],
delta: [0, 0]
}),
[types.startShake]: state => ({ ...state, shouldShakeQuestion: true }),
[types.endShake]: state => ({ ...state, shouldShakeQuestion: false }),
[types.primeNextQuestion]: (state, { payload: userAnswer }) => ({
...state,
currentQuestion: state.currentQuestion + 1,
mouse: [ userAnswer ? 1000 : -1000, 0],
isPressed: false
}),
[types.goToNextQuestion]: state => ({
...state,
mouse: [ 0, 0 ]
}),
[types.hikeCompleted]: (state, { payload: userAnswer } ) => ({
...state,
isCorrect: true,
isPressed: false,
delta: [ 0, 0 ],
mouse: [ userAnswer ? 1000 : -1000, 0]
}),
[types.goToNextHike]: state => ({
...state,
currentHike: findNextHikeName(state.hikes, state.currentHike),
showQuestions: false,
currentQuestion: 1,
mouse: [ 0, 0 ]
}),
[types.fetchHikesCompleted]: (state, { payload }) => {
const { hikes, currentHike } = payload;
return {
...state,
hikes,
currentHike
};
}
},
initialState
);

View File

@ -0,0 +1,8 @@
// use this file for common selectors
import { createSelector } from 'reselect';
export const getCurrentHike = createSelector(
state => state.hikesApp.hikes.entities,
state => state.hikesApp.currentHike,
(hikesMap, currentHikeDashedName) => (hikesMap[currentHikeDashedName] || {})
);

View File

@ -0,0 +1,24 @@
import createTypes from '../../../utils/create-types';
export default createTypes([
'fetchHikes',
'fetchHikesCompleted',
'resetHike',
'toggleQuestionView',
'grabQuestion',
'releaseQuestion',
'moveQuestion',
'answerQuestion',
'startShake',
'endShake',
'primeNextQuestion',
'goToNextQuestion',
'transitionHike',
'hikeCompleted',
'goToNextHike'
], 'videos');

View File

@ -0,0 +1,77 @@
import debug from 'debug';
import _ from 'lodash';
const log = debug('fcc:hikes:utils');
function getFirstHike(hikes) {
return hikes.results[0];
}
// interface Hikes {
// results: String[],
// entities: {
// hikeId: Challenge
// }
// }
//
// findCurrentHike({
// hikes: Hikes,
// dashedName: String
// }) => String
export function findCurrentHike(hikes, dashedName) {
if (!dashedName) {
return getFirstHike(hikes) || {};
}
const filterRegex = new RegExp(dashedName, 'i');
return hikes
.results
.filter(dashedName => {
return filterRegex.test(dashedName);
})
.reduce((throwAway, hike) => {
return hike;
}, '');
}
export function getCurrentHike(hikes = {}, dashedName) {
if (!dashedName) {
return getFirstHike(hikes) || {};
}
return hikes.entities[dashedName];
}
// findNextHikeName(
// hikes: { results: String[] },
// dashedName: String
// ) => String
export function findNextHikeName({ results }, dashedName) {
if (!dashedName) {
log('find next hike no id provided');
return results[0];
}
const currentIndex = _.findIndex(
results,
_dashedName => _dashedName === dashedName
);
if (currentIndex >= results.length) {
return '';
}
return results[currentIndex + 1];
}
export function getMouse(e, [dx, dy]) {
let { pageX, pageY, touches, changedTouches } = e;
// touches can be empty on touchend
if (touches || changedTouches) {
e.preventDefault();
// these re-assigns the values of pageX, pageY from touches
({ pageX, pageY } = touches[0] || changedTouches[0]);
}
return [pageX - dx, pageY - dy];
}

View File

@ -1,277 +0,0 @@
import React, { PropTypes } from 'react';
import { Button, Input, Col, Row, Well } from 'react-bootstrap';
import { contain } from 'thundercats-react';
// real paypal buttons
// will take your money
const paypalIds = {
regular: 'Q8Z82ZLAX3Q8N',
highlighted: 'VC8QPSKCYMZLN'
};
export default contain(
{
store: 'appStore',
actions: [
'jobActions',
'appActions'
],
map({ jobsApp: {
currentJob: { id, isHighlighted } = {},
buttonId = isHighlighted ?
paypalIds.highlighted :
paypalIds.regular,
price = 1000,
discountAmount = 0,
promoCode = '',
promoApplied = false,
promoName = ''
}}) {
return {
id,
isHighlighted,
buttonId,
price,
discountAmount,
promoName,
promoCode,
promoApplied
};
}
},
React.createClass({
displayName: 'GoToPayPal',
propTypes: {
appActions: PropTypes.object,
id: PropTypes.string,
isHighlighted: PropTypes.bool,
buttonId: PropTypes.string,
price: PropTypes.number,
discountAmount: PropTypes.number,
promoName: PropTypes.string,
promoCode: PropTypes.string,
promoApplied: PropTypes.bool,
jobActions: PropTypes.object
},
componentDidMount() {
const { jobActions } = this.props;
jobActions.clearPromo();
},
goToJobBoard() {
const { appActions } = this.props;
setTimeout(() => appActions.goTo('/jobs'), 0);
},
renderDiscount(discountAmount) {
if (!discountAmount) {
return null;
}
return (
<Row>
<Col
md={ 3 }
mdOffset={ 3 }>
<h4>Promo Discount</h4>
</Col>
<Col
md={ 3 }>
<h4>-{ discountAmount }</h4>
</Col>
</Row>
);
},
renderHighlightPrice(isHighlighted) {
if (!isHighlighted) {
return null;
}
return (
<Row>
<Col
md={ 3 }
mdOffset={ 3 }>
<h4>Highlighting</h4>
</Col>
<Col
md={ 3 }>
<h4>+ 250</h4>
</Col>
</Row>
);
},
renderPromo() {
const {
id,
promoApplied,
promoCode,
promoName,
isHighlighted,
jobActions
} = this.props;
if (promoApplied) {
return (
<div>
<div className='spacer' />
<Row>
<Col
md={ 3 }
mdOffset={ 3 }>
{ promoName } applied
</Col>
</Row>
</div>
);
}
return (
<div>
<div className='spacer' />
<Row>
<Col
md={ 3 }
mdOffset={ 3 }>
Have a promo code?
</Col>
</Row>
<Row>
<Col
md={ 3 }
mdOffset={ 3 }>
<Input
onChange={ jobActions.setPromoCode }
type='text'
value={ promoCode } />
</Col>
<Col
md={ 3 }>
<Button
block={ true }
onClick={ () => {
jobActions.applyCode({
id,
code: promoCode,
type: isHighlighted ? 'isHighlighted' : null
});
}}>
Apply Promo Code
</Button>
</Col>
</Row>
</div>
);
},
render() {
const {
id,
isHighlighted,
buttonId,
price,
discountAmount
} = this.props;
return (
<div>
<Row>
<Col
md={ 10 }
mdOffset={ 1 }
sm={ 8 }
smOffset={ 2 }
xs={ 12 }>
<div>
<Row>
<Col
md={ 6 }
mdOffset={ 3 }>
<h2 className='text-center'>
One more step
</h2>
<div className='spacer' />
You're Awesome! just one more step to go.
Clicking on the link below will redirect to paypal.
</Col>
</Row>
<div className='spacer' />
<Well>
<Row>
<Col
md={ 3 }
mdOffset={ 3 }>
<h4>Job Posting</h4>
</Col>
<Col
md={ 6 }>
<h4>+ { price }</h4>
</Col>
</Row>
{ this.renderHighlightPrice(isHighlighted) }
{ this.renderDiscount(discountAmount) }
<Row>
<Col
md={ 3 }
mdOffset={ 3 }>
<h4>Total</h4>
</Col>
<Col
md={ 6 }>
<h4>${
price - discountAmount + (isHighlighted ? 250 : 0)
}</h4>
</Col>
</Row>
</Well>
{ this.renderPromo() }
<div className='spacer' />
<Row>
<Col
md={ 6 }
mdOffset={ 3 }>
<form
action='https://www.paypal.com/cgi-bin/webscr'
method='post'
onClick={ this.goToJobBoard }
target='_blank'>
<input
name='cmd'
type='hidden'
value='_s-xclick' />
<input
name='hosted_button_id'
type='hidden'
value={ buttonId } />
<input
name='custom'
type='hidden'
value={ '' + id } />
<Button
block={ true }
bsSize='large'
className='signup-btn'
type='submit'>
<i className='fa fa-paypal' />
Continue to PayPal
</Button>
<div className='spacer' />
<img
alt='An array of credit cards'
border='0'
src='http://i.imgur.com/Q2SdSZG.png'
style={{
width: '100%'
}} />
</form>
</Col>
</Row>
<div className='spacer' />
</div>
</Col>
</Row>
</div>
);
}
})
);

View File

@ -2,8 +2,12 @@ import React from 'react';
import { LinkContainer } from 'react-router-bootstrap'; import { LinkContainer } from 'react-router-bootstrap';
import { Button, Row, Col } from 'react-bootstrap'; import { Button, Row, Col } from 'react-bootstrap';
export default React.createClass({ export default class extends React.Component {
displayName: 'NoJobFound', static displayName = 'NoJobFound';
shouldComponentUpdate() {
return false;
}
render() { render() {
return ( return (
@ -28,4 +32,4 @@ export default React.createClass({
</div> </div>
); );
} }
}); }

View File

@ -0,0 +1,306 @@
import { CompositeDisposable } from 'rx';
import React, { PropTypes } from 'react';
import { Button, Input, Col, Row, Well } from 'react-bootstrap';
import { connect } from 'react-redux';
import { push } from 'react-router-redux';
import PureComponent from 'react-pure-render/component';
import { createSelector } from 'reselect';
import {
applyPromo,
clearPromo,
updatePromo
} from '../redux/actions';
// real paypal buttons
// will take your money
const paypalIds = {
regular: 'Q8Z82ZLAX3Q8N',
highlighted: 'VC8QPSKCYMZLN'
};
const bindableActions = {
applyPromo,
clearPromo,
push,
updatePromo
};
const mapStateToProps = createSelector(
state => state.jobsApp.newJob,
state => state.jobsApp,
(
{ id, isHighlighted } = {},
{
buttonId,
price = 1000,
discountAmount = 0,
promoCode = '',
promoApplied = false,
promoName = ''
}
) => {
if (!buttonId) {
buttonId = isHighlighted ?
paypalIds.highlighted :
paypalIds.regular;
}
return {
id,
isHighlighted,
price,
discountAmount,
promoName,
promoCode,
promoApplied
};
}
);
export class JobTotal extends PureComponent {
constructor(...args) {
super(...args);
this._subscriptions = new CompositeDisposable();
}
static displayName = 'JobTotal';
static propTypes = {
id: PropTypes.string,
isHighlighted: PropTypes.bool,
buttonId: PropTypes.string,
price: PropTypes.number,
discountAmount: PropTypes.number,
promoName: PropTypes.string,
promoCode: PropTypes.string,
promoApplied: PropTypes.bool
};
componentWillMount() {
if (!this.props.id) {
this.props.push('/jobs');
}
this.props.clearPromo();
}
componentWillUnmount() {
this._subscriptions.dispose();
}
renderDiscount(discountAmount) {
if (!discountAmount) {
return null;
}
return (
<Row>
<Col
md={ 3 }
mdOffset={ 3 }>
<h4>Promo Discount</h4>
</Col>
<Col
md={ 3 }>
<h4>-{ discountAmount }</h4>
</Col>
</Row>
);
}
renderHighlightPrice(isHighlighted) {
if (!isHighlighted) {
return null;
}
return (
<Row>
<Col
md={ 3 }
mdOffset={ 3 }>
<h4>Highlighting</h4>
</Col>
<Col
md={ 3 }>
<h4>+ 250</h4>
</Col>
</Row>
);
}
renderPromo() {
const {
id,
promoApplied,
promoCode,
promoName,
isHighlighted,
applyPromo,
updatePromo
} = this.props;
if (promoApplied) {
return (
<div>
<div className='spacer' />
<Row>
<Col
md={ 3 }
mdOffset={ 3 }>
{ promoName } applied
</Col>
</Row>
</div>
);
}
return (
<div>
<div className='spacer' />
<Row>
<Col
md={ 3 }
mdOffset={ 3 }>
Have a promo code?
</Col>
</Row>
<Row>
<Col
md={ 3 }
mdOffset={ 3 }>
<Input
onChange={ updatePromo }
type='text'
value={ promoCode } />
</Col>
<Col
md={ 3 }>
<Button
block={ true }
onClick={ () => {
const subscription = applyPromo({
id,
code: promoCode,
type: isHighlighted ? 'isHighlighted' : null
}).subscribe();
this._subscriptions.add(subscription);
}}>
Apply Promo Code
</Button>
</Col>
</Row>
</div>
);
}
render() {
const {
id,
isHighlighted,
buttonId,
price,
discountAmount,
push
} = this.props;
return (
<div>
<Row>
<Col
md={ 10 }
mdOffset={ 1 }
sm={ 8 }
smOffset={ 2 }
xs={ 12 }>
<div>
<Row>
<Col
md={ 6 }
mdOffset={ 3 }>
<h2 className='text-center'>
One more step
</h2>
<div className='spacer' />
You're Awesome! just one more step to go.
Clicking on the link below will redirect to paypal.
</Col>
</Row>
<div className='spacer' />
<Well>
<Row>
<Col
md={ 3 }
mdOffset={ 3 }>
<h4>Job Posting</h4>
</Col>
<Col
md={ 6 }>
<h4>+ { price }</h4>
</Col>
</Row>
{ this.renderHighlightPrice(isHighlighted) }
{ this.renderDiscount(discountAmount) }
<Row>
<Col
md={ 3 }
mdOffset={ 3 }>
<h4>Total</h4>
</Col>
<Col
md={ 6 }>
<h4>${
price - discountAmount + (isHighlighted ? 250 : 0)
}</h4>
</Col>
</Row>
</Well>
{ this.renderPromo() }
<div className='spacer' />
<Row>
<Col
md={ 6 }
mdOffset={ 3 }>
<form
action='https://www.paypal.com/cgi-bin/webscr'
method='post'
onClick={ () => setTimeout(push, 0, '/jobs') }
target='_blank'>
<input
name='cmd'
type='hidden'
value='_s-xclick' />
<input
name='hosted_button_id'
type='hidden'
value={ buttonId } />
<input
name='custom'
type='hidden'
value={ '' + id } />
<Button
block={ true }
bsSize='large'
className='signup-btn'
type='submit'>
<i className='fa fa-paypal' />
Continue to PayPal
</Button>
<div className='spacer' />
<img
alt='An array of credit cards'
border='0'
src='http://i.imgur.com/Q2SdSZG.png'
style={{
width: '100%'
}} />
</form>
</Col>
</Row>
<div className='spacer' />
</div>
</Col>
</Row>
</div>
);
}
}
export default connect(mapStateToProps, bindableActions)(JobTotal);

View File

@ -1,132 +1,150 @@
import React, { cloneElement, PropTypes } from 'react'; import React, { cloneElement, PropTypes } from 'react';
import { contain } from 'thundercats-react'; import { compose } from 'redux';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { LinkContainer } from 'react-router-bootstrap';
import PureComponent from 'react-pure-render/component';
import { Button, Row, Col } from 'react-bootstrap'; import { Button, Row, Col } from 'react-bootstrap';
import contain from '../../../utils/professor-x';
import ListJobs from './List.jsx'; import ListJobs from './List.jsx';
export default contain( import {
{ findJob,
store: 'appStore', fetchJobs
map({ jobsApp: { jobs, showModal }}) { } from '../redux/actions';
return { jobs, showModal };
},
fetchAction: 'jobActions.getJobs',
isPrimed({ jobs = [] }) {
return !!jobs.length;
},
actions: [
'appActions',
'jobActions'
]
},
React.createClass({
displayName: 'Jobs',
propTypes: { const mapStateToProps = createSelector(
children: PropTypes.element, state => state.jobsApp.jobs.entities,
appActions: PropTypes.object, state => state.jobsApp.jobs.results,
jobActions: PropTypes.object, state => state.jobsApp,
jobs: PropTypes.array, (jobsMap, jobsById) => {
showModal: PropTypes.bool return { jobs: jobsById.map(id => jobsMap[id]) };
}, }
);
handleJobClick(id) { const bindableActions = {
const { appActions, jobActions } = this.props; findJob,
if (!id) { fetchJobs
return null; };
}
jobActions.findJob(id);
appActions.goTo(`/jobs/${id}`);
},
renderList(handleJobClick, jobs) { const fetchOptions = {
return ( fetchAction: 'fetchJobs',
<ListJobs isPrimed({ jobs }) {
handleClick={ handleJobClick } return jobs.length > 1;
jobs={ jobs }/> }
); };
},
renderChild(child, jobs) { export class Jobs extends PureComponent {
if (!child) { static displayName = 'Jobs';
return null;
}
return cloneElement(
child,
{ jobs }
);
},
render() { static propTypes = {
const { push: PropTypes.func,
children, findJob: PropTypes.func,
jobs, fetchJobs: PropTypes.func,
appActions children: PropTypes.element,
} = this.props; jobs: PropTypes.array,
showModal: PropTypes.bool
};
return ( createJobClickHandler() {
const { findJob } = this.props;
return (id) => {
findJob(id);
};
}
renderList(handleJobClick, jobs) {
return (
<ListJobs
handleClick={ handleJobClick }
jobs={ jobs }/>
);
}
renderChild(child, jobs) {
if (!child) {
return null;
}
return cloneElement(
child,
{ jobs }
);
}
render() {
const {
children,
jobs
} = this.props;
return (
<Row>
<Col
md={ 10 }
mdOffset= { 1 }
xs={ 12 }>
<h1 className='text-center'>
Hire a JavaScript engineer who's experienced in HTML5,
Node.js, MongoDB, and Agile Development.
</h1>
<div className='spacer' />
<Row className='text-center'>
<Col
sm={ 8 }
smOffset={ 2 }
xs={ 12 }>
<LinkContainer to='/jobs/new' >
<Button className='signup-btn btn-block btn-cta'>
Post a job: $1,000
</Button>
</LinkContainer>
<div className='spacer' />
</Col>
</Row>
<div className='spacer' />
<Row> <Row>
<Col <Col
md={ 10 } md={ 2 }
mdOffset= { 1 } xs={ 4 }>
xs={ 12 }> <img
<h1 className='text-center'> alt={`
Hire a JavaScript engineer who's experienced in HTML5, a photo of Michael Gai, who recently hired a software
Node.js, MongoDB, and Agile Development. engineer through Free Code Camp
</h1> `}
<div className='spacer' /> className='img-responsive testimonial-image-jobs img-center'
<Row className='text-center'> src='http://i.imgur.com/tGcAA8H.jpg' />
<Col </Col>
sm={ 8 } <Col
smOffset={ 2 } md={ 10 }
xs={ 12 }> xs={ 8 }>
<Button <blockquote>
className='signup-btn btn-block btn-cta' <p>
onClick={ ()=> { We hired our last developer out of Free Code Camp
appActions.goTo('/jobs/new'); and couldn't be happier. Free Code Camp is now
}}> our go-to way to bring on pre-screened candidates
Post a job: $1,000 who are enthusiastic about learning quickly and
</Button> becoming immediately productive in their new career.
<div className='spacer' /> </p>
</Col> <footer>
</Row> Michael Gai, <cite>CEO at CoNarrative</cite>
<div className='spacer' /> </footer>
<Row> </blockquote>
<Col </Col>
md={ 2 } </Row>
xs={ 4 }> <Row>
<img
alt={`
a photo of Michael Gai, who recently hired a software
engineer through Free Code Camp
`}
className='img-responsive testimonial-image-jobs img-center'
src='http://i.imgur.com/tGcAA8H.jpg' />
</Col>
<Col
md={ 10 }
xs={ 8 }>
<blockquote>
<p>
We hired our last developer out of Free Code Camp
and couldn't be happier. Free Code Camp is now
our go-to way to bring on pre-screened candidates
who are enthusiastic about learning quickly and
becoming immediately productive in their new career.
</p>
<footer>
Michael Gai, <cite>CEO at CoNarrative</cite>
</footer>
</blockquote>
</Col>
</Row>
<Row>
{ this.renderChild(children, jobs) || { this.renderChild(children, jobs) ||
this.renderList(this.handleJobClick, jobs) } this.renderList(this.createJobClickHandler(), jobs) }
</Row> </Row>
</Col> </Col>
</Row> </Row>
); );
} }
}) }
);
export default compose(
connect(mapStateToProps, bindableActions),
contain(fetchOptions)
)(Jobs);

View File

@ -1,14 +1,16 @@
import React, { PropTypes } from 'react'; import React, { PropTypes } from 'react';
import classnames from 'classnames'; import classnames from 'classnames';
import { LinkContainer } from 'react-router-bootstrap';
import { ListGroup, ListGroupItem } from 'react-bootstrap'; import { ListGroup, ListGroupItem } from 'react-bootstrap';
import PureComponent from 'react-pure-render/component';
export default React.createClass({ export default class ListJobs extends PureComponent {
displayName: 'ListJobs', static displayName = 'ListJobs';
propTypes: { static propTypes = {
handleClick: PropTypes.func, handleClick: PropTypes.func,
jobs: PropTypes.array jobs: PropTypes.array
}, };
addLocation(locale) { addLocation(locale) {
if (!locale) { if (!locale) {
@ -19,31 +21,35 @@ export default React.createClass({
{ locale } { locale }
</span> </span>
); );
}, }
renderJobs(handleClick, jobs = []) { renderJobs(handleClick, jobs = []) {
return jobs return jobs
.filter(({ isPaid, isApproved, isFilled }) => { .filter(({ isPaid, isApproved, isFilled }) => {
return isPaid && isApproved && !isFilled; return isPaid && isApproved && !isFilled;
}) })
.map(({ .map(({
id, id,
company, company,
position, position,
isHighlighted, isHighlighted,
locale locale
}) => { }) => {
const className = classnames({ const className = classnames({
'jobs-list': true, 'jobs-list': true,
'col-xs-12': true, 'col-xs-12': true,
'jobs-list-highlight': isHighlighted 'jobs-list-highlight': isHighlighted
}); });
return ( const to = `/jobs/${id}`;
return (
<LinkContainer
key={ id }
to={ to }>
<ListGroupItem <ListGroupItem
className={ className } className={ className }
key={ id }
onClick={ () => handleClick(id) }> onClick={ () => handleClick(id) }>
<div> <div>
<h4 className='pull-left' style={{ display: 'inline-block' }}> <h4 className='pull-left' style={{ display: 'inline-block' }}>
@ -60,9 +66,10 @@ export default React.createClass({
</h4> </h4>
</div> </div>
</ListGroupItem> </ListGroupItem>
); </LinkContainer>
}); );
}, });
}
render() { render() {
const { const {
@ -76,4 +83,4 @@ export default React.createClass({
</ListGroup> </ListGroup>
); );
} }
}); }

View File

@ -1,17 +1,15 @@
import { helpers } from 'rx'; import { helpers } from 'rx';
import React, { PropTypes } from 'react'; import React, { PropTypes } from 'react';
import { History } from 'react-router'; import { push } from 'react-router-redux';
import { contain } from 'thundercats-react'; import { reduxForm } from 'redux-form';
import debugFactory from 'debug'; // import debug from 'debug';
import dedent from 'dedent'; import dedent from 'dedent';
import normalizeUrl from 'normalize-url';
import { getDefaults } from '../utils';
import { import {
inHTMLData, isAscii,
uriInSingleQuotedAttr isEmail,
} from 'xss-filters'; isURL
} from 'validator';
import { import {
Button, Button,
@ -20,30 +18,14 @@ import {
Row Row
} from 'react-bootstrap'; } from 'react-bootstrap';
import { import { saveForm, loadSavedForm } from '../redux/actions';
isAscii,
isEmail,
isURL
} from 'validator';
const debug = debugFactory('freecc:jobs:newForm'); // const log = debug('fcc:jobs:newForm');
const checkValidity = [
'position',
'locale',
'description',
'email',
'url',
'logo',
'company',
'isHighlighted',
'howToApply'
];
const hightlightCopy = ` const hightlightCopy = `
Highlight my post to make it stand out. (+$250) Highlight my post to make it stand out. (+$250)
`; `;
const isRemoteCopy = ` const isRemoteCopy = `
This job can be performed remotely. This job can be performed remotely.
`; `;
@ -60,196 +42,103 @@ const checkboxClass = dedent`
col-sm-6 col-md-offset-3 col-sm-6 col-md-offset-3
`; `;
function formatValue(value, validator, type = 'string') { const certTypes = {
const formatted = getDefaults(type); isFrontEndCert: 'isFrontEndCert',
if (validator && type === 'string' && typeof value === 'string') { isBackEndCert: 'isBackEndCert'
formatted.valid = validator(value);
}
if (value) {
formatted.value = value;
formatted.bsStyle = formatted.valid ? 'success' : 'error';
}
return formatted;
}
const normalizeOptions = {
stripWWW: false
}; };
function formatUrl(url, shouldKeepTrailingSlash = true) {
if (
typeof url === 'string' &&
url.length > 4 &&
url.indexOf('.') !== -1
) {
// prevent trailing / from being stripped during typing
let lastChar = '';
if (shouldKeepTrailingSlash && url.substring(url.length - 1) === '/') {
lastChar = '/';
}
return normalizeUrl(url, normalizeOptions) + lastChar;
}
return url;
}
function isValidURL(data) { function isValidURL(data) {
return isURL(data, { 'require_protocol': true }); return isURL(data, { 'require_protocol': true });
} }
const fields = [
'position',
'locale',
'description',
'email',
'url',
'logo',
'company',
'isHighlighted',
'isRemoteOk',
'isFrontEndCert',
'isBackEndCert',
'howToApply'
];
const fieldValidators = {
position: makeRequired(isAscii),
locale: makeRequired(isAscii),
description: makeRequired(helpers.identity),
email: makeRequired(isEmail),
url: makeRequired(isValidURL),
logo: makeOptional(isValidURL),
company: makeRequired(isAscii),
howToApply: makeRequired(isAscii)
};
function makeOptional(validator) {
return val => val ? validator(val) : true;
}
function makeRequired(validator) { function makeRequired(validator) {
return (val) => !!val && validator(val); return (val) => val ? validator(val) : false;
} }
export default contain({ function validateForm(values) {
store: 'appStore', return Object.keys(fieldValidators)
actions: 'jobActions', .map(field => {
map({ jobsApp: { form = {} } }) { if (fieldValidators[field](values[field])) {
const { return null;
position,
locale,
description,
email,
url,
logo,
company,
isFrontEndCert = true,
isBackEndCert,
isHighlighted,
isRemoteOk,
howToApply
} = form;
return {
position: formatValue(position, makeRequired(isAscii)),
locale: formatValue(locale, makeRequired(isAscii)),
description: formatValue(description, makeRequired(helpers.identity)),
email: formatValue(email, makeRequired(isEmail)),
url: formatValue(formatUrl(url), isValidURL),
logo: formatValue(formatUrl(logo), isValidURL),
company: formatValue(company, makeRequired(isAscii)),
isHighlighted: formatValue(isHighlighted, null, 'bool'),
isRemoteOk: formatValue(isRemoteOk, null, 'bool'),
howToApply: formatValue(howToApply, makeRequired(isAscii)),
isFrontEndCert,
isBackEndCert
};
},
subscribeOnWillMount() {
return typeof window !== 'undefined';
}
},
React.createClass({
displayName: 'NewJob',
propTypes: {
jobActions: PropTypes.object,
position: PropTypes.object,
locale: PropTypes.object,
description: PropTypes.object,
email: PropTypes.object,
url: PropTypes.object,
logo: PropTypes.object,
company: PropTypes.object,
isHighlighted: PropTypes.object,
isRemoteOk: PropTypes.object,
isFrontEndCert: PropTypes.bool,
isBackEndCert: PropTypes.bool,
howToApply: PropTypes.object
},
mixins: [History],
handleSubmit(e) {
e.preventDefault();
const pros = this.props;
let valid = true;
checkValidity.forEach((prop) => {
// if value exist, check if it is valid
if (pros[prop].value && pros[prop].type !== 'boolean') {
valid = valid && !!pros[prop].valid;
}
});
if (
!valid ||
!pros.isFrontEndCert &&
!pros.isBackEndCert
) {
debug('form not valid');
return;
} }
return { [field]: !fieldValidators[field](values[field]) };
})
.filter(Boolean)
.reduce((errors, error) => ({ ...errors, ...error }), {});
}
const { function getBsStyle(field) {
jobActions, if (field.pristine) {
return null;
}
// form values return field.error ?
position, 'error' :
locale, 'success';
description, }
email,
url,
logo,
company,
isFrontEndCert,
isBackEndCert,
isHighlighted,
isRemoteOk,
howToApply
} = this.props;
// sanitize user output export class NewJob extends React.Component {
const jobValues = { static displayName = 'NewJob';
position: inHTMLData(position.value),
locale: inHTMLData(locale.value),
description: inHTMLData(description.value),
email: inHTMLData(email.value),
url: formatUrl(uriInSingleQuotedAttr(url.value), false),
logo: formatUrl(uriInSingleQuotedAttr(logo.value), false),
company: inHTMLData(company.value),
isHighlighted: !!isHighlighted.value,
isRemoteOk: !!isRemoteOk.value,
howToApply: inHTMLData(howToApply.value),
isFrontEndCert,
isBackEndCert
};
const job = Object.keys(jobValues).reduce((accu, prop) => { static propTypes = {
if (jobValues[prop]) { fields: PropTypes.object,
accu[prop] = jobValues[prop]; handleSubmit: PropTypes.func,
} loadSavedForm: PropTypes.func,
return accu; push: PropTypes.func,
}, {}); saveForm: PropTypes.func
};
job.postedOn = new Date(); componentDidMount() {
debug('job sanitized', job); this.props.loadSavedForm();
jobActions.saveForm(job); }
this.history.pushState(null, '/jobs/new/preview'); handleSubmit(job) {
}, this.props.saveForm(job);
this.props.push('/jobs/new/preview');
}
componentDidMount() { handleCertClick(name) {
const { jobActions } = this.props; const { fields } = this.props;
jobActions.getSavedForm(); Object.keys(certTypes).forEach(certType => {
}, if (certType === name) {
return fields[certType].onChange(true);
}
return fields[certType].onChange(false);
});
}
handleChange(name, { target: { value } }) { render() {
const { jobActions: { handleForm } } = this.props; const {
handleForm({ [name]: value }); fields: {
},
handleCertClick(name) {
const { jobActions: { handleForm } } = this.props;
const otherButton = name === 'isFrontEndCert' ?
'isBackEndCert' :
'isFrontEndCert';
handleForm({
[name]: true,
[otherButton]: false
});
},
render() {
const {
position, position,
locale, locale,
description, description,
@ -261,235 +150,242 @@ export default contain({
isRemoteOk, isRemoteOk,
howToApply, howToApply,
isFrontEndCert, isFrontEndCert,
isBackEndCert, isBackEndCert
jobActions: { handleForm } },
} = this.props; handleSubmit
} = this.props;
const { handleChange } = this; const { handleChange } = this;
const labelClass = 'col-sm-offset-1 col-sm-2'; const labelClass = 'col-sm-offset-1 col-sm-2';
const inputClass = 'col-sm-6'; const inputClass = 'col-sm-6';
return ( return (
<div> <div>
<Row> <Row>
<Col <Col
md={ 10 } md={ 10 }
mdOffset={ 1 }> mdOffset={ 1 }>
<div className='text-center'> <div className='text-center'>
<form <form
className='form-horizontal' className='form-horizontal'
onSubmit={ this.handleSubmit }> onSubmit={ handleSubmit(data => this.handleSubmit(data)) }>
<div className='spacer'> <div className='spacer'>
<h2>First, select your ideal applicant: </h2> <h2>First, select your ideal applicant: </h2>
</div>
<Row>
<Col
xs={ 6 }
xsOffset={ 3 }>
<Row>
<Button
bsStyle='primary'
className={ isFrontEndCert.value ? 'active' : '' }
onClick={ () => {
if (!isFrontEndCert.value) {
this.handleCertClick(certTypes.isFrontEndCert);
}
}}>
<h4>Front End Development Certified</h4>
You can expect each applicant
to have a code portfolio using the
following technologies:
HTML5, CSS, jQuery, API integrations
<br />
<br />
</Button>
</Row>
<div className='button-spacer' />
<Row>
<Button
bsStyle='primary'
className={ isBackEndCert.value ? 'active' : ''}
onClick={ () => {
if (!isBackEndCert.value) {
this.handleCertClick(certTypes.isBackEndCert);
}
}}>
<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,
JavaScript, Node.js, MongoDB, Express.js
<br />
<br />
</Button>
</Row>
</Col>
</Row>
<div className='spacer'>
<h2>Tell us about the position</h2>
</div>
<hr />
<Input
bsStyle={ getBsStyle(position) }
label='Job Title'
labelClassName={ labelClass }
placeholder={
'e.g. Full Stack Developer, Front End Developer, etc.'
}
required={ true }
type='text'
wrapperClassName={ inputClass }
{ ...position }
/>
<Input
bsStyle={ getBsStyle(locale) }
label='Location'
labelClassName={ labelClass }
placeholder='e.g. San Francisco, Remote, etc.'
required={ true }
type='text'
wrapperClassName={ inputClass }
{ ...locale }
/>
<Input
bsStyle={ getBsStyle(description) }
label='Description'
labelClassName={ labelClass }
required={ true }
rows='10'
type='textarea'
wrapperClassName={ inputClass }
{ ...description }
/>
<Input
label={ isRemoteCopy }
type='checkbox'
wrapperClassName={ checkboxClass }
{ ...isRemoteOk }
/>
<div className='spacer' />
<hr />
<Row>
<div>
<h2>How should they apply?</h2>
</div> </div>
<Input
bsStyle={ getBsStyle(howToApply) }
label=' '
labelClassName={ labelClass }
placeholder={ howToApplyCopy }
required={ true }
rows='2'
type='textarea'
wrapperClassName={ inputClass }
{ ...howToApply }
/>
</Row>
<div className='spacer' />
<hr />
<div>
<h2>Tell us about your organization</h2>
</div>
<Input
bsStyle={ getBsStyle(company) }
label='Company Name'
labelClassName={ labelClass }
onChange={ (e) => handleChange('company', e) }
type='text'
wrapperClassName={ inputClass }
{ ...company }
/>
<Input
bsStyle={ getBsStyle(email) }
label='Email'
labelClassName={ labelClass }
placeholder='This is how we will contact you'
required={ true }
type='email'
wrapperClassName={ inputClass }
{ ...email }
/>
<Input
bsStyle={ getBsStyle(url) }
label='URL'
labelClassName={ labelClass }
placeholder='http://yourcompany.com'
type='url'
wrapperClassName={ inputClass }
{ ...url }
/>
<Input
bsStyle={ getBsStyle(logo) }
label='Logo'
labelClassName={ labelClass }
placeholder='http://yourcompany.com/logo.png'
type='url'
wrapperClassName={ inputClass }
{ ...logo }
/>
<div className='spacer' />
<hr />
<div>
<div>
<h2>Make it stand out</h2>
</div>
<div className='spacer' />
<Row> <Row>
<Col <Col
xs={ 6 } md={ 6 }
xsOffset={ 3 }> mdOffset={ 3 }>
<Row>
<Button
bsStyle='primary'
className={ isFrontEndCert ? 'active' : '' }
onClick={ () => {
if (!isFrontEndCert) {
this.handleCertClick('isFrontEndCert');
}
}}>
<h4>Front End Development Certified</h4>
You can expect each applicant
to have a code portfolio using the
following technologies:
HTML5, CSS, jQuery, API integrations
<br />
<br />
</Button>
</Row>
<div className='button-spacer' />
<Row>
<Button
bsStyle='primary'
className={ isBackEndCert ? 'active' : ''}
onClick={ () => {
if (!isBackEndCert) {
this.handleCertClick('isBackEndCert');
}
}}>
<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,
JavaScript, Node.js, MongoDB, Express.js
<br />
<br />
</Button>
</Row>
</Col>
</Row>
<div className='spacer'>
<h2>Tell us about the position</h2>
</div>
<hr />
<Input
bsStyle={ position.bsStyle }
label='Job Title'
labelClassName={ labelClass }
onChange={ (e) => handleChange('position', e) }
placeholder={
'e.g. Full Stack Developer, Front End Developer, etc.'
}
required={ true }
type='text'
value={ position.value }
wrapperClassName={ inputClass } />
<Input
bsStyle={ locale.bsStyle }
label='Location'
labelClassName={ labelClass }
onChange={ (e) => handleChange('locale', e) }
placeholder='e.g. San Francisco, Remote, etc.'
required={ true }
type='text'
value={ locale.value }
wrapperClassName={ inputClass } />
<Input
bsStyle={ description.bsStyle }
label='Description'
labelClassName={ labelClass }
onChange={ (e) => handleChange('description', e) }
required={ true }
rows='10'
type='textarea'
value={ description.value }
wrapperClassName={ inputClass } />
<Input
checked={ isRemoteOk.value }
label={ isRemoteCopy }
onChange={
({ target: { checked } }) => handleForm({
isRemoteOk: !!checked
})
}
type='checkbox'
wrapperClassName={ checkboxClass } />
<div className='spacer' />
<hr />
<Row>
<div>
<h2>How should they apply?</h2>
</div>
<Input
bsStyle={ howToApply.bsStyle }
label=' '
labelClassName={ labelClass }
onChange={ (e) => handleChange('howToApply', e) }
placeholder={ howToApplyCopy }
required={ true }
rows='2'
type='textarea'
value={ howToApply.value }
wrapperClassName={ inputClass } />
</Row>
<div className='spacer' />
<hr />
<div>
<h2>Tell us about your organization</h2>
</div>
<Input
bsStyle={ company.bsStyle }
label='Company Name'
labelClassName={ labelClass }
onChange={ (e) => handleChange('company', e) }
type='text'
value={ company.value }
wrapperClassName={ inputClass } />
<Input
bsStyle={ email.bsStyle }
label='Email'
labelClassName={ labelClass }
onChange={ (e) => handleChange('email', e) }
placeholder='This is how we will contact you'
required={ true }
type='email'
value={ email.value }
wrapperClassName={ inputClass } />
<Input
bsStyle={ url.bsStyle }
label='URL'
labelClassName={ labelClass }
onChange={ (e) => handleChange('url', e) }
placeholder='http://yourcompany.com'
type='url'
value={ url.value }
wrapperClassName={ inputClass } />
<Input
bsStyle={ logo.bsStyle }
label='Logo'
labelClassName={ labelClass }
onChange={ (e) => handleChange('logo', e) }
placeholder='http://yourcompany.com/logo.png'
type='url'
value={ logo.value }
wrapperClassName={ inputClass } />
<div className='spacer' />
<hr />
<div>
<div>
<h2>Make it stand out</h2>
</div>
<div className='spacer' />
<Row>
<Col
md={ 6 }
mdOffset={ 3 }>
Highlight this ad to give it extra attention. Highlight this ad to give it extra attention.
<br /> <br />
Featured listings receive more clicks and more applications. Featured listings receive more clicks and more applications.
</Col>
</Row>
<div className='spacer' />
<Row>
<Input
bsSize='large'
bsStyle='success'
checked={ isHighlighted.value }
label={ hightlightCopy }
onChange={
({ target: { checked } }) => handleForm({
isHighlighted: !!checked
})
}
type='checkbox'
wrapperClassName={
checkboxClass.replace('text-left', '')
} />
</Row>
</div>
<Row>
<Col
className='text-left'
lg={ 6 }
lgOffset={ 3 }>
<Button
block={ true }
bsSize='large'
bsStyle='primary'
type='submit'>
Preview My Ad
</Button>
</Col> </Col>
</Row> </Row>
</form> <div className='spacer' />
</div> <Row>
</Col> <Input
</Row> bsSize='large'
</div> bsStyle='success'
); label={ hightlightCopy }
} type='checkbox'
}) wrapperClassName={
); checkboxClass.replace('text-left', '')
}
{ ...isHighlighted }
/>
</Row>
</div>
<Row>
<Col
className='text-left'
lg={ 6 }
lgOffset={ 3 }>
<Button
block={ true }
bsSize='large'
bsStyle='primary'
type='submit'>
Preview My Ad
</Button>
</Col>
</Row>
</form>
</div>
</Col>
</Row>
</div>
);
}
}
export default reduxForm(
{
form: 'NewJob',
fields,
validate: validateForm
},
state => ({ initialValues: state.jobsApp.initialValues }),
{
loadSavedForm,
push,
saveForm
}
)(NewJob);

View File

@ -2,8 +2,8 @@ import React from 'react';
import { LinkContainer } from 'react-router-bootstrap'; import { LinkContainer } from 'react-router-bootstrap';
import { Button, Col, Row } from 'react-bootstrap'; import { Button, Col, Row } from 'react-bootstrap';
export default React.createClass({ export default class extends React.createClass {
displayName: 'NewJobCompleted', static displayName = 'NewJobCompleted';
render() { render() {
return ( return (
@ -36,4 +36,4 @@ export default React.createClass({
</div> </div>
); );
} }
}); }

View File

@ -1,79 +1,94 @@
import { CompositeDisposable } from 'rx';
import React, { PropTypes } from 'react'; import React, { PropTypes } from 'react';
import { Button, Row, Col } from 'react-bootstrap'; import { Button, Row, Col } from 'react-bootstrap';
import { contain } from 'thundercats-react'; import { connect } from 'react-redux';
import PureComponent from 'react-pure-render/component';
import { goBack, push } from 'react-router-redux';
import ShowJob from './ShowJob.jsx'; import ShowJob from './ShowJob.jsx';
import JobNotFound from './JobNotFound.jsx'; import JobNotFound from './JobNotFound.jsx';
export default contain( import { clearForm, saveJob } from '../redux/actions';
{
store: 'appStore', const mapStateToProps = state => ({ job: state.jobsApp.newJob });
actions: [
'appActions', const bindableActions = {
'jobActions' goBack,
], push,
map({ jobsApp: { form: job = {} } }) { clearForm,
return { job }; saveJob
};
export class JobPreview extends PureComponent {
constructor(...args) {
super(...args);
this._subscriptions = new CompositeDisposable();
}
static displayName = 'Preview';
static propTypes = {
job: PropTypes.object,
saveJob: PropTypes.func,
clearForm: PropTypes.func,
push: PropTypes.func
};
componentWillMount() {
const { push, job } = this.props;
// redirect user in client
if (!job || !job.position || !job.description) {
push('/jobs/new');
} }
}, }
React.createClass({
displayName: 'Preview',
propTypes: { componentWillUnmount() {
appActions: PropTypes.object, this._subscriptions.dispose();
job: PropTypes.object, }
jobActions: PropTypes.object
},
componentDidMount() { handleJobSubmit() {
const { appActions, job } = this.props; const { clearForm, saveJob, job } = this.props;
// redirect user in client clearForm();
if (!job || !job.position || !job.description) { const subscription = saveJob(job).subscribe();
appActions.goTo('/jobs/new'); this._subscriptions.add(subscription);
} }
},
render() { render() {
const { appActions, job, jobActions } = this.props; const { job, goBack } = this.props;
if (!job || !job.position || !job.description) { if (!job || !job.position || !job.description) {
return <JobNotFound />; return <JobNotFound />;
}
return (
<div>
<ShowJob job={ job } />
<div className='spacer'></div>
<hr />
<Row>
<Col
md={ 10 }
mdOffset={ 1 }
xs={ 12 }>
<div>
<Button
block={ true }
className='signup-btn'
onClick={ () => {
jobActions.clearSavedForm();
jobActions.saveJobToDb({
goTo: '/jobs/new/check-out',
job
});
}}>
Looks great! Let's Check Out
</Button>
<Button
block={ true }
onClick={ () => appActions.goBack() } >
Head back and make edits
</Button>
</div>
</Col>
</Row>
</div>
);
} }
})
); return (
<div>
<ShowJob job={ job } />
<div className='spacer'></div>
<hr />
<Row>
<Col
md={ 10 }
mdOffset={ 1 }
xs={ 12 }>
<div>
<Button
block={ true }
className='signup-btn'
onClick={ () => this.handleJobSubmit() }>
Looks great! Let's Check Out
</Button>
<Button
block={ true }
onClick={ goBack } >
Head back and make edits
</Button>
</div>
</Col>
</Row>
</div>
);
}
}
export default connect(mapStateToProps, bindableActions)(JobPreview);

View File

@ -1,6 +1,12 @@
import React, { PropTypes } from 'react'; import React, { PropTypes } from 'react';
import { History } from 'react-router'; import { compose } from 'redux';
import { contain } from 'thundercats-react'; import { connect } from 'react-redux';
import { push } from 'react-router-redux';
import PureComponent from 'react-pure-render/component';
import { createSelector } from 'reselect';
import contain from '../../../utils/professor-x';
import { fetchJobs } from '../redux/actions';
import ShowJob from './ShowJob.jsx'; import ShowJob from './ShowJob.jsx';
import JobNotFound from './JobNotFound.jsx'; import JobNotFound from './JobNotFound.jsx';
@ -51,86 +57,90 @@ function generateMessage(
"You've earned it, so feel free to apply."; "You've earned it, so feel free to apply.";
} }
export default contain( const mapStateToProps = createSelector(
{ state => state.app,
store: 'appStore', state => state.jobsApp.currentJob,
fetchAction: 'jobActions.getJob', state => state.jobsApp.jobs.entities,
map({ ({ username, isFrontEndCert, isBackEndCert }, currentJob, jobs) => ({
username, username,
isFrontEndCert, isFrontEndCert,
isBackEndCert, isBackEndCert,
jobsApp: { currentJob } job: jobs[currentJob] || {}
}) {
return {
username,
job: currentJob,
isFrontEndCert,
isBackEndCert
};
},
getPayload({ params: { id } }) {
return id;
},
isPrimed({ params: { id } = {}, job = {} }) {
return job.id === id;
},
// using es6 destructuring
shouldContainerFetch({ job = {} }, { params: { id } }
) {
return job.id !== id;
}
},
React.createClass({
displayName: 'Show',
propTypes: {
job: PropTypes.object,
isBackEndCert: PropTypes.bool,
isFrontEndCert: PropTypes.bool,
username: PropTypes.string
},
mixins: [History],
componentDidMount() {
const { job } = this.props;
// redirect user in client
if (!isJobValid(job)) {
this.history.pushState(null, '/jobs');
}
},
render() {
const {
isBackEndCert,
isFrontEndCert,
job,
username
} = this.props;
if (!isJobValid(job)) {
return <JobNotFound />;
}
const isSignedIn = !!username;
const showApply = shouldShowApply(
job,
{ isFrontEndCert, isBackEndCert }
);
const message = generateMessage(
job,
{ isFrontEndCert, isBackEndCert, isSignedIn }
);
return (
<ShowJob
message={ message }
preview={ false }
showApply={ showApply }
{ ...this.props }/>
);
}
}) })
); );
const bindableActions = {
push,
fetchJobs
};
const fetchOptions = {
fetchAction: 'fetchJobs',
getActionArgs({ params: { id } }) {
return [ id ];
},
isPrimed({ params: { id } = {}, job = {} }) {
return job.id === id;
},
// using es6 destructuring
shouldRefetch({ job }, { params: { id } }) {
return job.id !== id;
}
};
export class Show extends PureComponent {
static displayName = 'Show';
static propTypes = {
job: PropTypes.object,
isBackEndCert: PropTypes.bool,
isFrontEndCert: PropTypes.bool,
username: PropTypes.string
};
componentDidMount() {
const { job, push } = this.props;
// redirect user in client
if (!isJobValid(job)) {
push('/jobs');
}
}
render() {
const {
isBackEndCert,
isFrontEndCert,
job,
username
} = this.props;
if (!isJobValid(job)) {
return <JobNotFound />;
}
const isSignedIn = !!username;
const showApply = shouldShowApply(
job,
{ isFrontEndCert, isBackEndCert }
);
const message = generateMessage(
job,
{ isFrontEndCert, isBackEndCert, isSignedIn }
);
return (
<ShowJob
message={ message }
preview={ false }
showApply={ showApply }
{ ...this.props }/>
);
}
}
export default compose(
connect(mapStateToProps, bindableActions),
contain(fetchOptions)
)(Show);

View File

@ -1 +0,0 @@
export default from './Actions';

View File

@ -2,7 +2,7 @@ import Jobs from './components/Jobs.jsx';
import NewJob from './components/NewJob.jsx'; import NewJob from './components/NewJob.jsx';
import Show from './components/Show.jsx'; import Show from './components/Show.jsx';
import Preview from './components/Preview.jsx'; import Preview from './components/Preview.jsx';
import GoToPayPal from './components/GoToPayPal.jsx'; import JobTotal from './components/JobTotal.jsx';
import NewJobCompleted from './components/NewJobCompleted.jsx'; import NewJobCompleted from './components/NewJobCompleted.jsx';
/* /*
@ -23,7 +23,7 @@ export default {
component: Preview component: Preview
}, { }, {
path: 'jobs/new/check-out', path: 'jobs/new/check-out',
component: GoToPayPal component: JobTotal
}, { }, {
path: 'jobs/new/completed', path: 'jobs/new/completed',
component: NewJobCompleted component: NewJobCompleted

View File

@ -0,0 +1,34 @@
import { createAction } from 'redux-actions';
import types from './types';
export const fetchJobs = createAction(types.fetchJobs);
export const fetchJobsCompleted = createAction(
types.fetchJobsCompleted,
(currentJob, jobs) => ({ currentJob, jobs })
);
export const findJob = createAction(types.findJob);
// saves to database
export const saveJob = createAction(types.saveJob);
// saves to localStorage
export const saveForm = createAction(types.saveForm);
export const saveCompleted = createAction(types.saveCompleted);
export const clearForm = createAction(types.clearForm);
export const loadSavedForm = createAction(types.loadSavedForm);
export const loadSavedFormCompleted = createAction(
types.loadSavedFormCompleted
);
export const clearPromo = createAction(types.clearPromo);
export const updatePromo = createAction(
types.updatePromo,
({ target: { value = '' } = {} } = {}) => value
);
export const applyPromo = createAction(types.applyPromo);
export const applyPromoCompleted = createAction(types.applyPromoCompleted);

View File

@ -0,0 +1,39 @@
import { Observable } from 'rx';
import { applyPromo } from './types';
import { applyPromoCompleted } from './actions';
import { postJSON$ } from '../../../../utils/ajax-stream';
export default () => ({ dispatch }) => next => {
return function applyPromoSaga(action) {
if (action.type !== applyPromo) {
return next(action);
}
const { id, code = '', type = null } = action.payload;
const body = {
id,
code: code.replace(/[^\d\w\s]/, '')
};
if (type) {
body.type = type;
}
return postJSON$('/api/promos/getButton', body)
.retry(3)
.map(({ promo }) => {
if (!promo || !promo.buttonId) {
throw new Error('No promo returned by server');
}
return applyPromoCompleted(promo);
})
.catch(error => Observable.just({
type: 'app.handleError',
error
}))
.doOnNext(dispatch);
};
};

View File

@ -0,0 +1,50 @@
import { Observable } from 'rx';
import { normalize, Schema, arrayOf } from 'normalizr';
import { fetchJobsCompleted } from './actions';
import { fetchJobs } from './types';
import { handleError } from '../../../redux/types';
const job = new Schema('job', { idAttribute: 'id' });
export default ({ services }) => ({ dispatch }) => next => {
return function fetchJobsSaga(action) {
if (action.type !== fetchJobs) {
return next(action);
}
const { payload: id } = action;
const data = { service: 'jobs' };
if (id) {
data.params = { id };
}
return services.readService$(data)
.map(jobs => {
if (!Array.isArray(jobs)) {
jobs = [jobs];
}
const { entities, result } = normalize(
{ jobs },
{ jobs: arrayOf(job) }
);
return fetchJobsCompleted(
result.jobs[0],
{
entities: entities.job,
results: result.jobs
}
);
})
.catch(error => {
return Observable.just({
type: handleError,
error
});
})
.doOnNext(dispatch);
};
};

View File

@ -0,0 +1,11 @@
export actions from './actions';
export reducer from './reducer';
export types from './types';
import fetchJobsSaga from './fetch-jobs-saga';
import saveJobSaga from './save-job-saga';
import applyPromoSaga from './apply-promo-saga';
export formNormalizer from './jobs-form-normalizer';
export const sagas = [ fetchJobsSaga, saveJobSaga, applyPromoSaga ];

View File

@ -0,0 +1,42 @@
import normalizeUrl from 'normalize-url';
import {
inHTMLData,
uriInSingleQuotedAttr
} from 'xss-filters';
const normalizeOptions = {
stripWWW: false
};
function ifDefinedNormalize(normalizer) {
return value => value ? normalizer(value) : value;
}
function formatUrl(url) {
if (
typeof url === 'string' &&
url.length > 4 &&
url.indexOf('.') !== -1
) {
// prevent trailing / from being stripped during typing
let lastChar = '';
if (url.substring(url.length - 1) === '/') {
lastChar = '/';
}
return normalizeUrl(url, normalizeOptions) + lastChar;
}
return url;
}
export default {
NewJob: {
position: ifDefinedNormalize(inHTMLData),
locale: ifDefinedNormalize(inHTMLData),
description: ifDefinedNormalize(inHTMLData),
email: ifDefinedNormalize(inHTMLData),
url: ifDefinedNormalize(value => formatUrl(uriInSingleQuotedAttr(value))),
logo: ifDefinedNormalize(value => formatUrl(uriInSingleQuotedAttr(value))),
company: ifDefinedNormalize(inHTMLData),
howToApply: ifDefinedNormalize(inHTMLData)
}
};

View File

@ -0,0 +1,85 @@
import { handleActions } from 'redux-actions';
import types from './types';
const replaceMethod = ''.replace;
function replace(str) {
if (!str) { return ''; }
return replaceMethod.call(str, /[^\d\w\s]/, '');
}
const initialState = {
// used by NewJob form
initialValues: {},
currentJob: '',
newJob: {},
jobs: {
entities: {},
results: []
}
};
export default handleActions(
{
[types.findJob]: (state, { payload: id }) => {
const currentJob = state.jobs.entities[id];
return {
...state,
currentJob: currentJob && currentJob.id ?
currentJob.id :
state.currentJob
};
},
[types.fetchJobsCompleted]: (state, { payload: { jobs, currentJob } }) => ({
...state,
currentJob,
jobs
}),
[types.updatePromo]: (state, { payload }) => ({
...state,
promoCode: replace(payload)
}),
[types.saveCompleted]: (state, { payload: newJob }) => {
return {
...state,
newJob
};
},
[types.loadSavedFormCompleted]: (state, { payload: initialValues }) => ({
...state,
initialValues
}),
[types.applyPromoCompleted]: (state, { payload: promo }) => {
const {
fullPrice: price,
buttonId,
discountAmount,
code: promoCode,
name: promoName
} = promo;
return {
...state,
price,
buttonId,
discountAmount,
promoCode,
promoApplied: true,
promoName
};
},
[types.clearPromo]: state => ({
/* eslint-disable no-undefined */
...state,
price: undefined,
buttonId: undefined,
discountAmount: undefined,
promoCode: undefined,
promoApplied: false,
promoName: undefined
/* eslint-enable no-undefined */
})
},
initialState
);

View File

@ -0,0 +1,32 @@
import { push } from 'react-router-redux';
import { Observable } from 'rx';
import { saveCompleted } from './actions';
import { saveJob } from './types';
import { handleError } from '../../../redux/types';
export default ({ services }) => ({ dispatch }) => next => {
return function saveJobSaga(action) {
const result = next(action);
if (action.type !== saveJob) {
return result;
}
const { payload: job } = action;
return services.createService$({
service: 'jobs',
params: { job }
})
.retry(3)
.flatMap(job => Observable.of(
saveCompleted(job),
push('/jobs/new/check-out')
))
.catch(error => Observable.just({
type: handleError,
error
}))
.doOnNext(dispatch);
};
};

View File

@ -0,0 +1,22 @@
import createTypes from '../../../utils/create-types';
export default createTypes([
'fetchJobs',
'fetchJobsCompleted',
'findJob',
'saveJob',
'saveForm',
'saveCompleted',
'clearForm',
'loadSavedForm',
'loadSavedFormCompleted',
'clearPromo',
'updatePromo',
'applyPromo',
'applyPromoCompleted'
], 'jobs');

9
common/app/sagas.js Normal file
View File

@ -0,0 +1,9 @@
import { sagas as appSagas } from './redux';
import { sagas as hikesSagas} from './routes/Hikes/redux';
import { sagas as jobsSagas } from './routes/Jobs/redux';
export default [
...appSagas,
...hikesSagas,
...jobsSagas
];

View File

@ -0,0 +1,42 @@
import React, { Children, PropTypes } from 'react';
class ProfessorContext extends React.Component {
constructor(props) {
super(props);
this.professor = props.professor;
}
static displayName = 'ProfessorContext';
static propTypes = {
professor: PropTypes.object,
children: PropTypes.element.isRequired
};
static childContextTypes = {
professor: PropTypes.object
};
getChildContext() {
return { professor: this.professor };
}
render() {
return Children.only(this.props.children);
}
}
/* eslint-disable react/display-name, react/prop-types */
ProfessorContext.wrap = function wrap(Component, professor) {
const props = {};
if (professor) {
props.professor = professor;
}
return React.createElement(
ProfessorContext,
props,
Component
);
};
export default ProfessorContext;

View File

@ -0,0 +1,10 @@
// createTypes(types: String[], prefix: String) => Object
export default function createTypes(types = [], prefix = '') {
if (!Array.isArray(types) || typeof prefix !== 'string') {
return {};
}
return types.reduce((types, type) => {
types[type] = prefix + '.' + type;
return types;
}, {});
}

View File

@ -0,0 +1,192 @@
import React, { PropTypes, createElement } from 'react';
import { Observable, CompositeDisposable } from 'rx';
import shouldComponentUpdate from 'react-pure-render/function';
import debug from 'debug';
// interface contain {
// (options?: Object, Component: ReactComponent) => ReactComponent
// (options?: Object) => (Component: ReactComponent) => ReactComponent
// }
//
// Action: { type: String, payload: Any, ...meta }
//
// ActionCreator(...args) => Observable
//
// interface options {
// fetchAction?: ActionCreator,
// getActionArgs?(props: Object, context: Object) => [],
// isPrimed?(props: Object, context: Object) => Boolean,
// handleError?(err) => Void
// shouldRefetch?(
// props: Object,
// nextProps: Object,
// context: Object,
// nextContext: Object
// ) => Boolean,
// }
const log = debug('fcc:professerx');
function getChildContext(childContextTypes, currentContext) {
const compContext = { ...currentContext };
// istanbul ignore else
if (!childContextTypes || !childContextTypes.professor) {
delete compContext.professor;
}
return compContext;
}
const __DEV__ = process.env.NODE_ENV !== 'production';
export default function contain(options = {}, Component) {
/* istanbul ignore else */
if (!Component) {
return contain.bind(null, options);
}
let action;
let isActionable = false;
let hasRefetcher = typeof options.shouldRefetch === 'function';
const getActionArgs = typeof options.getActionArgs === 'function' ?
options.getActionArgs :
(() => []);
const isPrimed = typeof options.isPrimed === 'function' ?
options.isPrimed :
(() => false);
return class Container extends React.Component {
constructor(props, context) {
super(props, context);
this.__subscriptions = new CompositeDisposable();
}
static displayName = `Container(${Component.displayName})`;
static contextTypes = {
...Component.contextTypes,
professor: PropTypes.object
};
componentWillMount() {
const { professor } = this.context;
const { props } = this;
if (!options.fetchAction) {
log(`${Component.displayName} has no fetch action defined`);
return null;
}
action = props[options.fetchAction];
isActionable = typeof action === 'function';
if (__DEV__ && typeof action !== 'function') {
throw new Error(
`${options.fetchAction} should return a function but got ${action}.
Check the fetch options for ${Component.displayName}.`
);
}
if (
!professor ||
!professor.fetchContext
) {
log(
`${Component.displayName} did not have professor defined on context`
);
return null;
}
const actionArgs = getActionArgs(
props,
getChildContext(Component.contextTypes, this.context)
);
return professor.fetchContext.push({
name: options.fetchAction,
action,
actionArgs,
component: Component.displayName || 'Anon'
});
}
componentDidMount() {
if (isPrimed(this.props, this.context)) {
log('container is primed');
return null;
}
if (!isActionable) {
log(`${Component.displayName} container is not actionable`);
return null;
}
const actionArgs = getActionArgs(this.props, this.context);
const fetch$ = action.apply(null, actionArgs);
if (__DEV__ && !Observable.isObservable(fetch$)) {
console.log(fetch$);
throw new Error(
`Action creator should return an Observable but got ${fetch$}.
Check the action creator for fetch action ${options.fetchAction}`
);
}
const subscription = fetch$.subscribe(
() => {},
options.handleError
);
return this.__subscriptions.add(subscription);
}
componentWillReceiveProps(nextProps, nextContext) {
if (
!isActionable ||
!hasRefetcher ||
!options.shouldRefetch(
this.props,
nextProps,
getChildContext(Component.contextTypes, this.context),
getChildContext(Component.contextTypes, nextContext)
)
) {
return;
}
const actionArgs = getActionArgs(
this.props,
getChildContext(Component.contextTypes, this.context)
);
const fetch$ = action.apply(null, actionArgs);
if (__DEV__ && !Observable.isObservable(fetch$)) {
throw new Error(
'fetch action should return observable'
);
}
const subscription = fetch$.subscribe(
() => {},
options.errorHandler
);
this.__subscriptions.add(subscription);
}
componentWillUnmount() {
if (this.__subscriptions) {
this.__subscriptions.dispose();
}
}
shouldComponentUpdate = shouldComponentUpdate;
render() {
const { props } = this;
return createElement(
Component,
props
);
}
};
}

View File

@ -0,0 +1,52 @@
import { Observable, Scheduler } from 'rx';
import ReactDOM from 'react-dom/server';
import debug from 'debug';
import ProfessorContext from './Professor-Context';
const log = debug('fcc:professor');
export function fetch({ fetchContext = [] }) {
if (fetchContext.length === 0) {
log('empty fetch context found');
return Observable.just(fetchContext);
}
return Observable.from(fetchContext, null, null, Scheduler.default)
.doOnNext(({ name }) => log(`calling ${name} action creator`))
.map(({ action, actionArgs }) => action.apply(null, actionArgs))
.doOnNext(fetch$ => {
if (!Observable.isObservable(fetch$)) {
throw new Error(
'action creator should return an observable'
);
}
})
.map(fetch$ => fetch$.doOnNext(action => log('action', action.type)))
.mergeAll()
.doOnCompleted(() => log('all fetch observables completed'));
}
export default function renderToString(Component) {
const fetchContext = [];
const professor = { fetchContext };
let ContextedComponent;
try {
ContextedComponent = ProfessorContext.wrap(Component, professor);
log('initiating fetcher registration');
ReactDOM.renderToStaticMarkup(ContextedComponent);
log('fetcher registration completed');
} catch (e) {
return Observable.throw(e);
}
return fetch(professor)
.last()
.delay(0)
.map(() => {
const markup = ReactDOM.renderToString(Component);
return {
markup,
fetchContext
};
});
}

View File

@ -0,0 +1,26 @@
import ReactDOM from 'react-dom';
import { Disposable, Observable } from 'rx';
import ProfessorContext from './Professor-Context';
export default function render(Component, DOMContainer) {
let ContextedComponent;
try {
ContextedComponent = ProfessorContext.wrap(Component);
} catch (e) {
return Observable.throw(e);
}
return Observable.create(observer => {
try {
ReactDOM.render(ContextedComponent, DOMContainer, function() {
observer.onNext(this);
});
} catch (e) {
return observer.onError(e);
}
return Disposable.create(() => {
return ReactDOM.unmountComponentAtNode(DOMContainer);
});
});
}

View File

@ -0,0 +1,37 @@
// original sourc
// https://github.com/rackt/react-redux/blob/master/src/utils/shallowEqual.js
// MIT license
export default function shallowEqual(objA, objB) {
if (objA === objB) {
return true;
}
if (
typeof objA !== 'object' ||
objA === null ||
typeof objB !== 'object' ||
objB === null
) {
return false;
}
var keysA = Object.keys(objA);
var keysB = Object.keys(objB);
if (keysA.length !== keysB.length) {
return false;
}
// Test for A's keys different from B.
var bHasOwnProperty = Object.prototype.hasOwnProperty.bind(objB);
for (var i = 0; i < keysA.length; i++) {
if (
!bHasOwnProperty(keysA[i]) ||
objA[keysA[i]] !== objB[keysA[i]]
) {
return false;
}
}
return true;
}

View File

@ -10,7 +10,7 @@ import {
const { defaultProfileImage } = require('../utils/constantStrings.json'); const { defaultProfileImage } = require('../utils/constantStrings.json');
const githubRegex = (/github/i); const githubRegex = (/github/i);
const debug = debugFactory('freecc:models:userIdent'); const debug = debugFactory('fcc:models:userIdent');
function createAccessToken(user, ttl, cb) { function createAccessToken(user, ttl, cb) {
if (arguments.length === 2 && typeof ttl === 'function') { if (arguments.length === 2 && typeof ttl === 'function') {
@ -73,7 +73,7 @@ export default function(UserIdent) {
} }
); );
} }
cb(err, user, identity); return cb(err, user, identity);
}); });
}); });
} }
@ -99,12 +99,12 @@ export default function(UserIdent) {
} else { } else {
query = { username: userObj.username }; query = { username: userObj.username };
} }
userModel.findOrCreate({ where: query }, userObj, function(err, user) { return userModel.findOrCreate({ where: query }, userObj, (err, user) => {
if (err) { if (err) {
return cb(err); return cb(err);
} }
var date = new Date(); var date = new Date();
userIdentityModel.create({ return userIdentityModel.create({
provider: getSocialProvider(provider), provider: getSocialProvider(provider),
externalId: profile.id, externalId: profile.id,
authScheme: authScheme, authScheme: authScheme,
@ -122,7 +122,7 @@ export default function(UserIdent) {
} }
); );
} }
cb(err, user, identity); return cb(err, user, identity);
}); });
}); });
}); });
@ -134,7 +134,7 @@ export default function(UserIdent) {
debug('no user identity instance found'); debug('no user identity instance found');
return next(); return next();
} }
userIdent.user(function(err, user) { return userIdent.user(function(err, user) {
let userChanged = false; let userChanged = false;
if (err) { return next(err); } if (err) { return next(err); }
if (!user) { if (!user) {
@ -175,11 +175,11 @@ export default function(UserIdent) {
if (userChanged) { if (userChanged) {
return user.save(function(err) { return user.save(function(err) {
if (err) { return next(err); } if (err) { return next(err); }
next(); return next();
}); });
} }
debug('exiting after user identity before save'); debug('exiting after user identity before save');
next(); return next();
}); });
}); });
} }

View File

@ -1,7 +1,7 @@
import { isAlphanumeric, isHexadecimal } from 'validator'; import { isAlphanumeric, isHexadecimal } from 'validator';
import debug from 'debug'; import debug from 'debug';
const log = debug('freecc:models:promo'); const log = debug('fcc:models:promo');
export default function promo(Promo) { export default function promo(Promo) {
Promo.getButton = function getButton(id, code, type = 'isNot') { Promo.getButton = function getButton(id, code, type = 'isNot') {

View File

@ -7,7 +7,7 @@ import debugFactory from 'debug';
import { saveUser, observeMethod } from '../../server/utils/rx'; import { saveUser, observeMethod } from '../../server/utils/rx';
import { blacklistedUsernames } from '../../server/utils/constants'; import { blacklistedUsernames } from '../../server/utils/constants';
const debug = debugFactory('freecc:user:remote'); const debug = debugFactory('fcc:user:remote');
const BROWNIEPOINTS_TIMEOUT = [1, 'hour']; const BROWNIEPOINTS_TIMEOUT = [1, 'hour'];
function getAboutProfile({ function getAboutProfile({
@ -271,7 +271,7 @@ module.exports = function(User) {
)); ));
}); });
} }
User.findOne({ where: { username } }, (err, user) => { return User.findOne({ where: { username } }, (err, user) => {
if (err) { if (err) {
return cb(err); return cb(err);
} }
@ -327,7 +327,7 @@ module.exports = function(User) {
.valueOf(); .valueOf();
const user$ = findUser({ where: { username: receiver }}); const user$ = findUser({ where: { username: receiver }});
user$ return user$
.tapOnNext((user) => { .tapOnNext((user) => {
if (!user) { if (!user) {
throw new Error(`could not find receiver for ${ receiver }`); throw new Error(`could not find receiver for ${ receiver }`);

View File

@ -19,7 +19,7 @@
import debugFactory from 'debug'; import debugFactory from 'debug';
import { Observable, AnonymousObservable, helpers } from 'rx'; import { Observable, AnonymousObservable, helpers } from 'rx';
const debug = debugFactory('freecc:ajax$'); const debug = debugFactory('fcc:ajax$');
const root = typeof window !== 'undefined' ? window : {}; const root = typeof window !== 'undefined' ? window : {};
// Gets the proper XMLHttpRequest for support for older IE // Gets the proper XMLHttpRequest for support for older IE

View File

@ -0,0 +1,48 @@
import { Observable, Disposable } from 'rx';
import Fetchr from 'fetchr';
import stampit from 'stampit';
function callbackObserver(observer) {
return (err, res) => {
if (err) {
return observer.onError(err);
}
observer.onNext(res);
return observer.onCompleted();
};
}
export default stampit({
init({ args: [ options ] }) {
this.services = new Fetchr(options);
},
methods: {
readService$({ service: resource, params, config }) {
return Observable.create(observer => {
this.services.read(
resource,
params,
config,
callbackObserver(observer)
);
return Disposable.create(() => observer.dispose());
});
},
createService$({ service: resource, params, body, config }) {
return Observable.create(observer => {
this.services.create(
resource,
params,
body,
config,
callbackObserver(observer)
);
return Disposable.create(() => observer.dispose());
});
}
}
});

View File

@ -1,5 +1,5 @@
// enable debug for gulp // enable debug for gulp
process.env.DEBUG = process.env.DEBUG || 'freecc:*'; process.env.DEBUG = process.env.DEBUG || 'fcc:*';
require('babel-core/register'); require('babel-core/register');
var Rx = require('rx'), var Rx = require('rx'),
@ -12,7 +12,7 @@ var Rx = require('rx'),
gutil = require('gulp-util'), gutil = require('gulp-util'),
reduce = require('gulp-reduce-file'), reduce = require('gulp-reduce-file'),
sortKeys = require('sort-keys'), sortKeys = require('sort-keys'),
debug = require('debug')('freecc:gulp'), debug = require('debug')('fcc:gulp'),
yargs = require('yargs'), yargs = require('yargs'),
concat = require('gulp-concat'), concat = require('gulp-concat'),
uglify = require('gulp-uglify'), uglify = require('gulp-uglify'),
@ -98,7 +98,10 @@ var paths = {
'public/bower_components/bootstrap/dist/js/bootstrap.min.js', 'public/bower_components/bootstrap/dist/js/bootstrap.min.js',
'public/bower_components/d3/d3.min.js', 'public/bower_components/d3/d3.min.js',
'public/bower_components/moment/min/moment.min.js', 'public/bower_components/moment/min/moment.min.js',
'public/bower_components/moment-timezone/builds/moment-timezone-with-data.min.js',
'public/bower_components/' +
'moment-timezone/builds/moment-timezone-with-data.min.js',
'public/bower_components/mousetrap/mousetrap.min.js', 'public/bower_components/mousetrap/mousetrap.min.js',
'public/bower_components/lightbox2/dist/js/lightbox.min.js', 'public/bower_components/lightbox2/dist/js/lightbox.min.js',
'public/bower_components/rxjs/dist/rx.all.min.js' 'public/bower_components/rxjs/dist/rx.all.min.js'
@ -194,7 +197,7 @@ gulp.task('serve', ['build-manifest'], function(cb) {
exec: path.join(__dirname, 'node_modules/.bin/babel-node'), exec: path.join(__dirname, 'node_modules/.bin/babel-node'),
env: { env: {
'NODE_ENV': process.env.NODE_ENV || 'development', 'NODE_ENV': process.env.NODE_ENV || 'development',
'DEBUG': process.env.DEBUG || 'freecc:*' 'DEBUG': process.env.DEBUG || 'fcc:*'
} }
}) })
.on('start', function() { .on('start', function() {

View File

@ -49,7 +49,7 @@
"emmet-codemirror": "^1.2.5", "emmet-codemirror": "^1.2.5",
"errorhandler": "^1.4.2", "errorhandler": "^1.4.2",
"es6-map": "~0.1.1", "es6-map": "~0.1.1",
"eslint": "~1.10.2", "eslint": "^2.2.0",
"eslint-plugin-react": "^4.1.0", "eslint-plugin-react": "^4.1.0",
"express": "^4.13.3", "express": "^4.13.3",
"express-flash": "~0.0.2", "express-flash": "~0.0.2",
@ -61,7 +61,7 @@
"gulp": "^3.9.0", "gulp": "^3.9.0",
"gulp-babel": "^6.1.1", "gulp-babel": "^6.1.1",
"gulp-concat": "^2.6.0", "gulp-concat": "^2.6.0",
"gulp-eslint": "^1.1.0", "gulp-eslint": "^2.0.0",
"gulp-jsonlint": "^1.1.0", "gulp-jsonlint": "^1.1.0",
"gulp-less": "^3.0.3", "gulp-less": "^3.0.3",
"gulp-nodemon": "^2.0.3", "gulp-nodemon": "^2.0.3",
@ -74,7 +74,7 @@
"gulp-util": "^3.0.6", "gulp-util": "^3.0.6",
"helmet": "^1.1.0", "helmet": "^1.1.0",
"helmet-csp": "^1.0.3", "helmet-csp": "^1.0.3",
"history": "^1.17.0", "history": "^2.0.0",
"jade": "^1.11.0", "jade": "^1.11.0",
"json-loader": "~0.5.2", "json-loader": "~0.5.2",
"less": "^2.5.1", "less": "^2.5.1",
@ -92,6 +92,7 @@
"node-uuid": "^1.4.3", "node-uuid": "^1.4.3",
"nodemailer": "^2.1.0", "nodemailer": "^2.1.0",
"normalize-url": "^1.3.1", "normalize-url": "^1.3.1",
"normalizr": "^2.0.0",
"object.assign": "^4.0.3", "object.assign": "^4.0.3",
"passport-facebook": "^2.0.0", "passport-facebook": "^2.0.0",
"passport-github": "^1.0.0", "passport-github": "^1.0.0",
@ -105,11 +106,18 @@
"react-bootstrap": "~0.28.1", "react-bootstrap": "~0.28.1",
"react-dom": "~0.14.3", "react-dom": "~0.14.3",
"react-motion": "~0.4.2", "react-motion": "~0.4.2",
"react-router": "^1.0.0", "react-pure-render": "^1.0.2",
"react-router-bootstrap": "https://github.com/FreeCodeCamp/react-router-bootstrap.git#freecodecamp", "react-redux": "^4.0.6",
"react-router": "^2.0.0",
"react-router-bootstrap": "~0.20.1",
"react-toastr": "^2.4.0", "react-toastr": "^2.4.0",
"react-router-redux": "^2.1.0",
"react-vimeo": "~0.1.0", "react-vimeo": "~0.1.0",
"redux": "^3.0.5",
"redux-actions": "^0.9.1",
"redux-form": "^4.1.4",
"request": "^2.65.0", "request": "^2.65.0",
"reselect": "^2.0.2",
"rev-del": "^1.0.5", "rev-del": "^1.0.5",
"rx": "^4.0.0", "rx": "^4.0.0",
"sanitize-html": "^1.11.1", "sanitize-html": "^1.11.1",

View File

@ -1,7 +1,7 @@
import { Observable } from 'rx'; import { Observable } from 'rx';
import debugFactory from 'debug'; import debugFactory from 'debug';
const debug = debugFactory('freecc:user:remote'); const debug = debugFactory('fcc:user:remote');
function destroyAllRelated(id, Model) { function destroyAllRelated(id, Model) {
return Observable.fromNodeCallback( return Observable.fromNodeCallback(
@ -21,7 +21,7 @@ module.exports = function(app) {
if (!id) { if (!id) {
return next(); return next();
} }
Observable.combineLatest( return Observable.combineLatest(
destroyAllRelated(id, UserIdentity), destroyAllRelated(id, UserIdentity),
destroyAllRelated(id, UserCredential), destroyAllRelated(id, UserCredential),
function(identData, credData) { function(identData, credData) {
@ -30,19 +30,20 @@ module.exports = function(app) {
credData: credData credData: credData
}; };
} }
).subscribe( )
function(data) { .subscribe(
debug('deleted', data); function(data) {
}, debug('deleted', data);
function(err) { },
debug('error deleting user %s stuff', id, err); function(err) {
next(err); debug('error deleting user %s stuff', id, err);
}, next(err);
function() { },
debug('user stuff deleted for user %s', id); function() {
next(); debug('user stuff deleted for user %s', id);
} next();
); }
);
}); });
// set email varified false on user email signup // set email varified false on user email signup
@ -82,15 +83,15 @@ module.exports = function(app) {
}; };
debug('sending welcome email'); debug('sending welcome email');
Email.send(mailOptions, function(err) { return Email.send(mailOptions, function(err) {
if (err) { return next(err); } if (err) { return next(err); }
req.logIn(user, function(err) { return req.logIn(user, function(err) {
if (err) { return next(err); } if (err) { return next(err); }
req.flash('success', { req.flash('success', {
msg: [ "Welcome to Free Code Camp! We've created your account." ] msg: [ "Welcome to Free Code Camp! We've created your account." ]
}); });
res.redirect(redirect); return res.redirect(redirect);
}); });
}); });
}); });

View File

@ -1,4 +1,4 @@
import{ Observable } from 'rx'; import { Observable } from 'rx';
import debugFactory from 'debug'; import debugFactory from 'debug';
import dedent from 'dedent'; import dedent from 'dedent';

View File

@ -1,14 +1,13 @@
import React from 'react'; import React from 'react';
import { RoutingContext } from 'react-router'; import { RouterContext } from 'react-router';
import Fetchr from 'fetchr'; import debug from 'debug';
import { createLocation } from 'history';
import debugFactory from 'debug'; import renderToString from '../../common/app/utils/render-to-string';
import { dehydrate } from 'thundercats'; import provideStore from '../../common/app/provide-store';
import { renderToString$ } from 'thundercats-react';
import app$ from '../../common/app'; import app$ from '../../common/app';
const debug = debugFactory('freecc:react-server'); const log = debug('fcc:react-server');
// add routes here as they slowly get reactified // add routes here as they slowly get reactified
// remove their individual controllers // remove their individual controllers
@ -38,52 +37,43 @@ export default function reactSubRouter(app) {
app.use(router); app.use(router);
function serveReactApp(req, res, next) { function serveReactApp(req, res, next) {
const services = new Fetchr({ req }); const serviceOptions = { req };
const location = createLocation(req.path); app$({
location: req.path,
// returns a router wrapped app serviceOptions
app$({ location }) })
// if react-router does not find a route send down the chain // if react-router does not find a route send down the chain
.filter(function({ props }) { .filter(({ redirect, props }) => {
if (!props && redirect) {
res.redirect(redirect.pathname + redirect.search);
}
if (!props) { if (!props) {
debug('react tried to find %s but got 404', location.pathname); log(`react tried to find ${location.pathname} but got 404`);
return next(); return next();
} }
return !!props; return !!props;
}) })
.flatMap(function({ props, AppCat }) { .flatMap(({ props, store }) => {
const cat = AppCat(null, services); log('render react markup and pre-fetch data');
debug('render react markup and pre-fetch data');
const store = cat.getStore('appStore');
// primes store to observe action changes return renderToString(
// cleaned up by cat.dispose further down provideStore(React.createElement(RouterContext, props), store)
store.subscribe(() => {});
return renderToString$(
cat,
React.createElement(RoutingContext, props)
) )
.flatMap( .map(({ markup }) => ({ markup, store }));
dehydrate(cat),
({ markup }, data) => ({ markup, data, cat })
);
}) })
.flatMap(function({ data, markup, cat }) { .flatMap(function({ markup, store }) {
debug('react markup rendered, data fetched'); log('react markup rendered, data fetched');
cat.dispose(); const state = store.getState();
const { title } = data.AppStore; const { title } = state.app.title;
res.expose(data, 'data'); res.expose(state, 'data');
return res.render$( return res.render$(
'layout-react', 'layout-react',
{ markup, title } { markup, title }
); );
}) })
.doOnNext(markup => res.send(markup))
.subscribe( .subscribe(
function(markup) { () => log('html rendered and ready to send'),
debug('html rendered and ready to send');
res.send(markup);
},
next next
); );
} }

View File

@ -22,7 +22,7 @@ import {
import certTypes from '../utils/certTypes.json'; import certTypes from '../utils/certTypes.json';
const log = debug('freecc:certification'); const log = debug('fcc:certification');
const sendMessageToNonUser = ifNoUserSend( const sendMessageToNonUser = ifNoUserSend(
'must be logged in to complete.' 'must be logged in to complete.'
); );

View File

@ -26,7 +26,7 @@ import badIdMap from '../utils/bad-id-map';
const isDev = process.env.NODE_ENV !== 'production'; const isDev = process.env.NODE_ENV !== 'production';
const isBeta = !!process.env.BETA; const isBeta = !!process.env.BETA;
const log = debug('freecc:challenges'); const log = debug('fcc:challenges');
const challengesRegex = /^(bonfire|waypoint|zipline|basejump|checkpoint)/i; const challengesRegex = /^(bonfire|waypoint|zipline|basejump|checkpoint)/i;
const challengeView = { const challengeView = {
0: 'challenges/showHTML', 0: 'challenges/showHTML',
@ -120,6 +120,7 @@ function shouldShowNew(element, block) {
}, 0); }, 0);
return newCount / block.length * 100 === 100; return newCount / block.length * 100 === 100;
} }
return null;
} }
// meant to be used with a filter method // meant to be used with a filter method
@ -516,7 +517,7 @@ module.exports = function(app) {
if (data.id) { if (data.id) {
res.cookie('currentChallengeId', data.id); res.cookie('currentChallengeId', data.id);
} }
res.render(view, data); return res.render(view, data);
}, },
next, next,
function() {} function() {}
@ -525,14 +526,12 @@ module.exports = function(app) {
function completedChallenge(req, res, next) { function completedChallenge(req, res, next) {
req.checkBody('id', 'id must be a ObjectId').isMongoId(); req.checkBody('id', 'id must be a ObjectId').isMongoId();
req.checkBody('name', 'name must be at least 3 characters') req.checkBody('name', 'name must be at least 3 characters')
.isString() .isString()
.isLength({ min: 3 }); .isLength({ min: 3 });
req.checkBody('challengeType', 'challengeType must be an integer') req.checkBody('challengeType', 'challengeType must be an integer')
.isNumber() .isNumber();
.isInt();
const type = accepts(req).type('html', 'json', 'text'); const type = accepts(req).type('html', 'json', 'text');
const errors = req.validationErrors(true); const errors = req.validationErrors(true);
@ -585,7 +584,7 @@ module.exports = function(app) {
alreadyCompleted alreadyCompleted
}); });
} }
res.sendStatus(200); return res.sendStatus(200);
} }
); );
} }
@ -597,8 +596,7 @@ module.exports = function(app) {
.isString() .isString()
.isLength({ min: 3 }); .isLength({ min: 3 });
req.checkBody('challengeType', 'must be a number') req.checkBody('challengeType', 'must be a number')
.isNumber() .isNumber();
.isInt();
req.checkBody('solution', 'solution must be a url').isURL(); req.checkBody('solution', 'solution must be a url').isURL();
const errors = req.validationErrors(true); const errors = req.validationErrors(true);
@ -652,7 +650,7 @@ module.exports = function(app) {
user.progressTimestamps.length + 1 user.progressTimestamps.length + 1
}); });
} }
res.status(200).send(true); return res.status(200).send(true);
}) })
.subscribe(() => {}, next); .subscribe(() => {}, next);
} }

View File

@ -34,7 +34,7 @@ const sendNonUserToCommit = ifNoUserRedirectTo(
'info' 'info'
); );
const debug = debugFactory('freecc:commit'); const debug = debugFactory('fcc:commit');
function findNonprofit(name) { function findNonprofit(name) {
let nonprofit; let nonprofit;
@ -217,7 +217,7 @@ export default function commit(app) {
}) })
.subscribe( .subscribe(
pledge => { pledge => {
let msg = `You have successfully stopped your pledge.`; let msg = 'You have successfully stopped your pledge.';
if (!pledge) { if (!pledge) {
msg = `No pledge found for user ${user.username}.`; msg = `No pledge found for user ${user.username}.`;
} }

View File

@ -14,9 +14,9 @@ module.exports = function(app) {
return next(); return next();
} }
req.user.picture = defaultProfileImage; req.user.picture = defaultProfileImage;
req.user.save(function(err) { return req.user.save(function(err) {
if (err) { return next(err); } if (err) { return next(err); }
next(); return next();
}); });
} }
@ -24,6 +24,6 @@ module.exports = function(app) {
if (req.user) { if (req.user) {
return res.redirect('/challenges/current-challenge'); return res.redirect('/challenges/current-challenge');
} }
res.render('home', { title: message }); return res.render('home', { title: message });
} }
}; };

View File

@ -2,7 +2,7 @@ var Rx = require('rx'),
async = require('async'), async = require('async'),
moment = require('moment'), moment = require('moment'),
request = require('request'), request = require('request'),
debug = require('debug')('freecc:cntr:resources'), debug = require('debug')('fcc:cntr:resources'),
constantStrings = require('../utils/constantStrings.json'), constantStrings = require('../utils/constantStrings.json'),
labs = require('../resources/labs.json'), labs = require('../resources/labs.json'),
testimonials = require('../resources/testimonials.json'), testimonials = require('../resources/testimonials.json'),
@ -145,7 +145,7 @@ module.exports = function(app) {
if (err) { if (err) {
return next(err); return next(err);
} }
process.nextTick(function() { return process.nextTick(function() {
res.header('Content-Type', 'application/xml'); res.header('Content-Type', 'application/xml');
res.render('resources/sitemap', { res.render('resources/sitemap', {
appUrl: appUrl, appUrl: appUrl,
@ -227,14 +227,18 @@ module.exports = function(app) {
} }
function confirmStickers(req, res) { function confirmStickers(req, res) {
req.flash('success', { msg: 'Thank you for supporting our community! You should receive your stickers in the ' + req.flash('success', {
'mail soon!'}); msg: 'Thank you for supporting our community! You should receive ' +
res.redirect('/shop'); 'your stickers in the mail soon!'
});
res.redirect('/shop');
} }
function cancelStickers(req, res) { function cancelStickers(req, res) {
req.flash('info', { msg: 'You\'ve cancelled your purchase of our stickers. You can ' req.flash('info', {
+ 'support our community any time by buying some.'}); msg: 'You\'ve cancelled your purchase of our stickers. You can ' +
'support our community any time by buying some.'
});
res.redirect('/shop'); res.redirect('/shop');
} }
function submitCatPhoto(req, res) { function submitCatPhoto(req, res) {
@ -280,18 +284,14 @@ module.exports = function(app) {
function unsubscribe(req, res, next) { function unsubscribe(req, res, next) {
User.findOne({ where: { email: req.params.email } }, function(err, user) { User.findOne({ where: { email: req.params.email } }, function(err, user) {
if (user) { if (user) {
if (err) { if (err) { return next(err); }
return next(err);
}
user.sendMonthlyEmail = false; user.sendMonthlyEmail = false;
user.save(function() { return user.save(function() {
if (err) { if (err) { return next(err); }
return next(err); return res.redirect('/unsubscribed');
}
res.redirect('/unsubscribed');
}); });
} else { } else {
res.redirect('/unsubscribed'); return res.redirect('/unsubscribed');
} }
}); });
} }
@ -330,7 +330,7 @@ module.exports = function(app) {
Object.keys(JSON.parse(pulls)).length : Object.keys(JSON.parse(pulls)).length :
'Can\'t connect to github'; 'Can\'t connect to github';
request( return request(
[ [
'https://api.github.com/repos/freecodecamp/', 'https://api.github.com/repos/freecodecamp/',
'freecodecamp/issues?client_id=', 'freecodecamp/issues?client_id=',
@ -344,7 +344,7 @@ module.exports = function(app) {
issues = ((pulls === parseInt(pulls, 10)) && issues) ? issues = ((pulls === parseInt(pulls, 10)) && issues) ?
Object.keys(JSON.parse(issues)).length - pulls : Object.keys(JSON.parse(issues)).length - pulls :
"Can't connect to GitHub"; "Can't connect to GitHub";
res.send({ return res.send({
issues: issues, issues: issues,
pulls: pulls pulls: pulls
}); });
@ -364,7 +364,7 @@ module.exports = function(app) {
(JSON.parse(trello)) : (JSON.parse(trello)) :
'Can\'t connect to to Trello'; 'Can\'t connect to to Trello';
res.end(JSON.stringify(trello)); return res.end(JSON.stringify(trello));
}); });
} }
@ -379,7 +379,7 @@ module.exports = function(app) {
blog = (status && status.statusCode === 200) ? blog = (status && status.statusCode === 200) ?
JSON.parse(blog) : JSON.parse(blog) :
'Can\'t connect to Blogger'; 'Can\'t connect to Blogger';
res.end(JSON.stringify(blog)); return res.end(JSON.stringify(blog));
} }
); );
} }

View File

@ -2,7 +2,7 @@ var Rx = require('rx'),
assign = require('object.assign'), assign = require('object.assign'),
sanitizeHtml = require('sanitize-html'), sanitizeHtml = require('sanitize-html'),
moment = require('moment'), moment = require('moment'),
debug = require('debug')('freecc:cntr:story'), debug = require('debug')('fcc:cntr:story'),
utils = require('../utils'), utils = require('../utils'),
observeMethod = require('../utils/rx').observeMethod, observeMethod = require('../utils/rx').observeMethod,
saveUser = require('../utils/rx').saveUser, saveUser = require('../utils/rx').saveUser,
@ -207,7 +207,7 @@ module.exports = function(app) {
return upvote.upVotedByUsername === username; return upvote.upVotedByUsername === username;
}); });
res.render('stories/index', { return res.render('stories/index', {
title: story.headline, title: story.headline,
link: story.link, link: story.link,
originalStoryLink: dashedName, originalStoryLink: dashedName,
@ -357,7 +357,7 @@ module.exports = function(app) {
url = 'http://' + url; url = 'http://' + url;
} }
findStory({ where: { link: url } }) return findStory({ where: { link: url } })
.map(function(stories) { .map(function(stories) {
if (stories.length) { if (stories.length) {
return { return {

View File

@ -19,7 +19,7 @@ import {
calcLongestStreak calcLongestStreak
} from '../utils/user-stats'; } from '../utils/user-stats';
const debug = debugFactory('freecc:boot:user'); const debug = debugFactory('fcc:boot:user');
const sendNonUserToMap = ifNoUserRedirectTo('/map'); const sendNonUserToMap = ifNoUserRedirectTo('/map');
const certIds = { const certIds = {
[certTypes.frontEnd]: frontEndChallengeId, [certTypes.frontEnd]: frontEndChallengeId,
@ -195,7 +195,7 @@ module.exports = function(app) {
if (req.user) { if (req.user) {
return res.redirect('/'); return res.redirect('/');
} }
res.render('account/signin', { return res.render('account/signin', {
title: 'Sign in to Free Code Camp using a Social Media Account' title: 'Sign in to Free Code Camp using a Social Media Account'
}); });
} }
@ -209,7 +209,7 @@ module.exports = function(app) {
if (req.user) { if (req.user) {
return res.redirect('/'); return res.redirect('/');
} }
res.render('account/email-signin', { return res.render('account/email-signin', {
title: 'Sign in to Free Code Camp using your Email Address' title: 'Sign in to Free Code Camp using your Email Address'
}); });
} }
@ -218,7 +218,7 @@ module.exports = function(app) {
if (req.user) { if (req.user) {
return res.redirect('/'); return res.redirect('/');
} }
res.render('account/email-signup', { return res.render('account/email-signup', {
title: 'Sign up for Free Code Camp using your Email Address' title: 'Sign up for Free Code Camp using your Email Address'
}); });
} }
@ -387,7 +387,7 @@ module.exports = function(app) {
req.flash('errors', { req.flash('errors', {
msg: `Looks like user ${username} is not ${certText[certType]}` msg: `Looks like user ${username} is not ${certText[certType]}`
}); });
res.redirect('back'); return res.redirect('back');
}, },
next next
); );
@ -406,7 +406,7 @@ module.exports = function(app) {
section at the bottom of this page. section at the bottom of this page.
` `
}); });
res.redirect('/' + req.user.username); return res.redirect('/' + req.user.username);
}); });
} }
req.user.isLocked = true; req.user.isLocked = true;
@ -420,7 +420,7 @@ module.exports = function(app) {
section at the bottom of this page. section at the bottom of this page.
` `
}); });
res.redirect('/' + req.user.username); return res.redirect('/' + req.user.username);
}); });
} }
@ -429,7 +429,7 @@ module.exports = function(app) {
if (err) { return next(err); } if (err) { return next(err); }
req.logout(); req.logout();
req.flash('info', { msg: 'Your account has been deleted.' }); req.flash('info', { msg: 'Your account has been deleted.' });
res.redirect('/'); return res.redirect('/');
}); });
} }
@ -438,7 +438,7 @@ module.exports = function(app) {
req.flash('errors', { msg: 'access token invalid' }); req.flash('errors', { msg: 'access token invalid' });
return res.render('account/forgot'); return res.render('account/forgot');
} }
res.render('account/reset', { return res.render('account/reset', {
title: 'Reset your Password', title: 'Reset your Password',
accessToken: req.accessToken.id accessToken: req.accessToken.id
}); });
@ -453,14 +453,14 @@ module.exports = function(app) {
return res.redirect('back'); return res.redirect('back');
} }
User.findById(req.accessToken.userId, function(err, user) { return User.findById(req.accessToken.userId, function(err, user) {
if (err) { return next(err); }
user.updateAttribute('password', password, function(err) {
if (err) { return next(err); } if (err) { return next(err); }
return user.updateAttribute('password', password, function(err) {
if (err) { return next(err); }
debug('password reset processed successfully'); debug('password reset processed successfully');
req.flash('info', { msg: 'password reset processed successfully' }); req.flash('info', { msg: 'password reset processed successfully' });
res.redirect('/'); return res.redirect('/');
}); });
}); });
} }
@ -469,7 +469,7 @@ module.exports = function(app) {
if (req.isAuthenticated()) { if (req.isAuthenticated()) {
return res.redirect('/'); return res.redirect('/');
} }
res.render('account/forgot', { return res.render('account/forgot', {
title: 'Forgot Password' title: 'Forgot Password'
}); });
} }
@ -483,7 +483,7 @@ module.exports = function(app) {
return res.redirect('/forgot'); return res.redirect('/forgot');
} }
User.resetPassword({ return User.resetPassword({
email: email email: email
}, function(err) { }, function(err) {
if (err) { if (err) {
@ -496,7 +496,7 @@ module.exports = function(app) {
email + email +
' with further instructions.' ' with further instructions.'
}); });
res.render('account/forgot'); return res.render('account/forgot');
}); });
} }
@ -507,7 +507,7 @@ module.exports = function(app) {
if (err) { return next(err); } if (err) { return next(err); }
req.flash('success', { msg: 'Thanks for voting!' }); req.flash('success', { msg: 'Thanks for voting!' });
res.redirect('/map'); return res.redirect('/map');
}); });
} else { } else {
req.flash('error', { msg: 'You must be signed in to vote.' }); req.flash('error', { msg: 'You must be signed in to vote.' });
@ -522,7 +522,7 @@ module.exports = function(app) {
if (err) { return next(err); } if (err) { return next(err); }
req.flash('success', { msg: 'Thanks for voting!' }); req.flash('success', { msg: 'Thanks for voting!' });
res.redirect('/map'); return res.redirect('/map');
}); });
} else { } else {
req.flash('error', {msg: 'You must be signed in to vote.'}); req.flash('error', {msg: 'You must be signed in to vote.'});

View File

@ -36,8 +36,9 @@ export default function addReturnToUrl() {
) { ) {
return next(); return next();
} }
req.session.returnTo = req.originalUrl === '/map-aside' req.session.returnTo = req.originalUrl === '/map-aside' ?
? '/map' : req.originalUrl; '/map' :
next(); req.originalUrl;
return next();
}; };
} }

View File

@ -26,6 +26,6 @@ export default function({ globalPrepend = '' } = {}) {
// in production we take use the initially loaded manifest // in production we take use the initially loaded manifest
// since this should not change in production // since this should not change in production
res.locals.rev = boundRev; res.locals.rev = boundRev;
next(); return next();
}; };
} }

View File

@ -1,23 +1,23 @@
import debugFactory from 'debug'; import debugFactory from 'debug';
import assign from 'object.assign'; import assign from 'object.assign';
const debug = debugFactory('freecc:services:hikes'); const debug = debugFactory('fcc:services:hikes');
export default function hikesService(app) { export default function hikesService(app) {
const Challenge = app.models.Challenge; const Challenge = app.models.Challenge;
return { return {
name: 'hikes', name: 'hikes',
read: (req, resource, params, config, cb) => { read: (req, resource, { dashedName } = {}, config, cb) => {
const query = { const query = {
where: { challengeType: '6' }, where: { challengeType: '6' },
order: ['order ASC', 'suborder ASC' ] order: ['order ASC', 'suborder ASC' ]
}; };
debug('params', params); debug('dashedName', dashedName);
if (params) { if (dashedName) {
assign(query.where, { assign(query.where, {
dashedName: { like: params.dashedName, options: 'i' } dashedName: { like: dashedName, options: 'i' }
}); });
} }
debug('query', query); debug('query', query);
@ -25,7 +25,7 @@ export default function hikesService(app) {
if (err) { if (err) {
return cb(err); return cb(err);
} }
cb(null, hikes); return cb(null, hikes.map(hike => hike.toJSON()));
}); });
} }
}; };

View File

@ -22,18 +22,20 @@ export default function getJobServices(app) {
isApproved: false isApproved: false
}); });
Job.create(job, (err, savedJob) => { return Job.create(job, (err, savedJob) => {
cb(err, savedJob); cb(err, savedJob.toJSON());
}); });
}, },
read(req, resource, params, config, cb) { read(req, resource, params, config, cb) {
const id = params ? params.id : null; const id = params ? params.id : null;
if (id) { if (id) {
return Job.findById(id, cb); return Job.findById(id)
.then(job => cb(null, job.toJSON()))
.catch(cb);
} }
Job.find(whereFilt, (err, jobs) => { return Job.find(whereFilt)
cb(err, jobs); .then(jobs => cb(null, jobs.map(job => job.toJSON())))
}); .catch(cb);
} }
}; };
} }

View File

@ -2,7 +2,7 @@ import debugFactory from 'debug';
import assign from 'object.assign'; import assign from 'object.assign';
const censor = '**********************:P********'; const censor = '**********************:P********';
const debug = debugFactory('freecc:services:user'); const debug = debugFactory('fcc:services:user');
const protectedUserFields = { const protectedUserFields = {
id: censor, id: censor,
password: censor, password: censor,

Some files were not shown because too many files have changed in this diff Show More