Moves to next challenges

This commit is contained in:
Berkeley Martinez
2016-06-01 15:52:08 -07:00
parent 515051d817
commit cc8b608cb9
16 changed files with 327 additions and 546 deletions

View File

@ -1,31 +0,0 @@
import { Observable } from 'rx';
import types from '../../common/app/routes/challenges/redux/types';
import { makeToast } from '../../common/app/redux/actions';
import { randomCompliment } from '../../common/app/utils/get-words';
/*
import {
updateOutput,
checkChallenge,
updateTests
} from '../../common/app/routes/challenges/redux/actions';
*/
export default function completionSaga(actions$, getState) {
return actions$
.filter(({ type }) => (
type === types.checkChallenge
))
.flatMap(() => {
const { tests } = getState().challengesApp;
if (tests.length > 1 && tests.every(test => test.pass && !test.err)) {
return Observable.of(
makeToast({
type: 'success',
message: randomCompliment()
})
);
}
return Observable.just(null);
});
}

View File

@ -5,7 +5,6 @@ import hardGoToSaga from './hard-go-to-saga';
import windowSaga from './window-saga'; import windowSaga from './window-saga';
import executeChallengeSaga from './execute-challenge-saga'; import executeChallengeSaga from './execute-challenge-saga';
import frameSaga from './frame-saga'; import frameSaga from './frame-saga';
import completionSaga from './completion-saga';
import codeStorageSaga from './code-storage-saga'; import codeStorageSaga from './code-storage-saga';
export default [ export default [
@ -16,6 +15,5 @@ export default [
windowSaga, windowSaga,
executeChallengeSaga, executeChallengeSaga,
frameSaga, frameSaga,
completionSaga,
codeStorageSaga codeStorageSaga
]; ];

View File

@ -1,5 +1,5 @@
import React, { PropTypes } from 'react'; import React, { PropTypes } from 'react';
import { Row } from 'react-bootstrap'; import { Button, Row } from 'react-bootstrap';
import { ToastMessage, ToastContainer } from 'react-toastr'; import { ToastMessage, ToastContainer } from 'react-toastr';
import { compose } from 'redux'; import { compose } from 'redux';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
@ -12,25 +12,41 @@ import {
updateNavHeight updateNavHeight
} from './redux/actions'; } from './redux/actions';
import { submitChallenge } from './routes/challenges/redux/actions';
import Nav from './components/Nav'; import Nav from './components/Nav';
import { randomCompliment } from './utils/get-words';
const toastMessageFactory = React.createFactory(ToastMessage.animation); const toastMessageFactory = React.createFactory(ToastMessage.animation);
const mapStateToProps = createSelector( const mapStateToProps = createSelector(
state => state.app, state => state.app.username,
({ state => state.app.points,
state => state.app.picture,
state => state.app.toast,
state => state.challengesApp.toast,
(
username, username,
points, points,
picture, picture,
toast toast,
}) => ({ showChallengeComplete
) => ({
username, username,
points, points,
picture, picture,
toast toast,
showChallengeComplete
}) })
); );
const bindableActions = {
initWindowHeight,
updateNavHeight,
fetchUser,
submitChallenge
};
const fetchContainerOptions = { const fetchContainerOptions = {
fetchAction: 'fetchUser', fetchAction: 'fetchUser',
isPrimed({ username }) { isPrimed({ username }) {
@ -49,11 +65,19 @@ export class FreeCodeCamp extends React.Component {
picture: PropTypes.string, picture: PropTypes.string,
toast: PropTypes.object, toast: PropTypes.object,
updateNavHeight: PropTypes.func, updateNavHeight: PropTypes.func,
initWindowHeight: PropTypes.func initWindowHeight: PropTypes.func,
showChallengeComplete: PropTypes.number,
submitChallenge: PropTypes.func
}; };
componentWillReceiveProps({ toast: nextToast = {} }) { componentWillReceiveProps({
const { toast = {} } = this.props; toast: nextToast = {},
showChallengeComplete: nextCC = 0
}) {
const {
toast = {},
showChallengeComplete
} = this.props;
if (toast.id !== nextToast.id) { if (toast.id !== nextToast.id) {
this.refs.toaster[nextToast.type || 'success']( this.refs.toaster[nextToast.type || 'success'](
nextToast.message, nextToast.message,
@ -64,12 +88,39 @@ export class FreeCodeCamp extends React.Component {
} }
); );
} }
if (nextCC !== showChallengeComplete) {
this.refs.toaster.success(
this.renderChallengeComplete(),
randomCompliment(),
{
closeButton: true,
timeOut: 0,
extendedTimeOut: 0
}
);
}
} }
componentDidMount() { componentDidMount() {
this.props.initWindowHeight(); this.props.initWindowHeight();
} }
renderChallengeComplete() {
const { submitChallenge } = this.props;
return (
<Button
block={ true }
bsSize='small'
bsStyle='primary'
className='animated fadeIn'
onClick={ submitChallenge }
>
Submit and go to my next challenge
</Button>
);
}
render() { render() {
const { username, points, picture, updateNavHeight } = this.props; const { username, points, picture, updateNavHeight } = this.props;
const navProps = { username, points, picture, updateNavHeight }; const navProps = { username, points, picture, updateNavHeight };
@ -91,7 +142,7 @@ export class FreeCodeCamp extends React.Component {
const wrapComponent = compose( const wrapComponent = compose(
// connect Component to Redux Store // connect Component to Redux Store
connect(mapStateToProps, { initWindowHeight, updateNavHeight, fetchUser }), connect(mapStateToProps, bindableActions),
// handles prefetching data // handles prefetching data
contain(fetchContainerOptions) contain(fetchContainerOptions)
); );

View File

@ -92,7 +92,7 @@ function handleAnswer(action, getState) {
title: 'Saved', title: 'Saved',
type: 'info' type: 'info'
}), }),
updatePoints(points), updatePoints(points)
); );
}) })
.catch(createErrorObservable); .catch(createErrorObservable);

View File

@ -7,11 +7,12 @@ import PureComponent from 'react-pure-render/component';
import Classic from './classic/Classic.jsx'; import Classic from './classic/Classic.jsx';
import Step from './step/Step.jsx'; import Step from './step/Step.jsx';
import { fetchChallenge } from '../redux/actions'; import { fetchChallenge, fetchChallenges } from '../redux/actions';
import { challengeSelector } from '../redux/selectors'; import { challengeSelector } from '../redux/selectors';
const bindableActions = { const bindableActions = {
fetchChallenge fetchChallenge,
fetchChallenges
}; };
const mapStateToProps = createSelector( const mapStateToProps = createSelector(
@ -32,14 +33,30 @@ const fetchOptions = {
export class Challenges extends PureComponent { export class Challenges extends PureComponent {
static displayName = 'Challenges'; static displayName = 'Challenges';
static propTypes = { isStep: PropTypes.bool }; static propTypes = {
isStep: PropTypes.bool,
fetchChallenges: PropTypes.func
};
render() { componentDidMount() {
if (this.props.isStep) { this.props.fetchChallenges();
}
renderView(isStep) {
if (isStep) {
return <Step />; return <Step />;
} }
return <Classic />; return <Classic />;
} }
render() {
const { isStep } = this.props;
return (
<div>
{ this.renderView(isStep) }
</div>
);
}
} }
export default compose( export default compose(

View File

@ -58,6 +58,18 @@ export const updateOutput = createAction(types.updateOutput, loggerToStr);
export const checkChallenge = createAction(types.checkChallenge); export const checkChallenge = createAction(types.checkChallenge);
let id = 0;
export const showChallengeComplete = createAction(
types.showChallengeComplete,
() => {
id += 1;
return id;
}
);
export const submitChallenge = createAction(types.submitChallenge);
export const moveToNextChallenge = createAction(types.moveToNextChallenge);
// code storage // code storage
export const saveCode = createAction(types.saveCode); export const saveCode = createAction(types.saveCode);
export const loadCode = createAction(types.loadCode); export const loadCode = createAction(types.loadCode);

View File

@ -0,0 +1,106 @@
import { Observable } from 'rx';
import { push } from 'react-router-redux';
import types from './types';
import {
showChallengeComplete,
moveToNextChallenge,
updateCurrentChallenge
} from './actions';
import {
createErrorObservable,
makeToast,
updatePoints
} from '../../../redux/actions';
import { getNextChallenge } from '../utils';
import { challengeSelector } from './selectors';
import { postJSON$ } from '../../../../utils/ajax-stream';
function completedChallenge(state) {
let body;
let isSignedIn = false;
try {
const {
challenge: { id }
} = challengeSelector(state);
const {
app: { isSignedIn: _isSignedId, csrfToken },
challengesApp: { files }
} = state;
isSignedIn = _isSignedId;
body = {
id,
_csrf: csrfToken,
files
};
} catch (err) {
return createErrorObservable(err);
}
const saveChallenge$ = postJSON$('/modern-challenge-completed', body)
.retry(3)
.flatMap(({ alreadyCompleted, points }) => {
return Observable.of(
makeToast({
message:
'Challenge saved.' +
(alreadyCompleted ? '' : ' First time Completed!'),
title: 'Saved',
type: 'info'
}),
updatePoints(points)
);
})
.catch(createErrorObservable);
const challengeCompleted$ = Observable.of(
moveToNextChallenge(),
makeToast({
title: 'Congratulations!',
message: isSignedIn ? ' Saving...' : 'Moving on to next challenge',
type: 'success'
})
);
return Observable.merge(saveChallenge$, challengeCompleted$);
}
export default function completionSaga(actions$, getState) {
return actions$
.filter(({ type }) => (
type === types.checkChallenge ||
type === types.submitChallenge ||
type === types.moveToNextChallenge
))
.flatMap(({ type }) => {
const state = getState();
const { tests } = state.challengesApp;
if (tests.length > 1 && tests.every(test => test.pass && !test.err)) {
if (type === types.checkChallenge) {
return Observable.of(
showChallengeComplete()
);
}
if (type === types.submitChallenge) {
return completedChallenge(state);
}
if (type === types.moveToNextChallenge) {
const nextChallenge = getNextChallenge(
state.challengesApp.challenge,
state.entities,
state.challengesApp.superBlocks
);
return Observable.of(
updateCurrentChallenge(nextChallenge),
push(`/challenges/${nextChallenge.dashedName}`)
);
}
}
return Observable.just(makeToast({
message: 'Not all tests are passing',
title: 'Not quite there',
type: 'info'
}));
});
}

View File

@ -9,9 +9,10 @@ import {
export default function fetchChallengesSaga(action$, getState, { services }) { export default function fetchChallengesSaga(action$, getState, { services }) {
return action$ return action$
.filter( .filter(({ type }) => (
({ type }) => type === fetchChallenges || type === fetchChallenge type === fetchChallenges ||
) type === fetchChallenge
))
.flatMap(({ type, payload })=> { .flatMap(({ type, payload })=> {
const options = { service: 'map' }; const options = { service: 'map' };
if (type === fetchChallenge) { if (type === fetchChallenge) {

View File

@ -3,4 +3,6 @@ export reducer from './reducer';
export types from './types'; export types from './types';
import fetchChallengesSaga from './fetch-challenges-saga'; import fetchChallengesSaga from './fetch-challenges-saga';
export const sagas = [ fetchChallengesSaga ]; import completionSaga from './completion-saga';
export const sagas = [ fetchChallengesSaga, completionSaga ];

View File

@ -19,7 +19,8 @@ const initialState = {
previousStep: -1, previousStep: -1,
filter: '', filter: '',
files: {}, files: {},
superBlocks: [] superBlocks: [],
toast: 0
}; };
const mainReducer = handleActions( const mainReducer = handleActions(
@ -45,6 +46,10 @@ const mainReducer = handleActions(
...state, ...state,
tests: state.tests.map(test => ({ ...test, err: false, pass: false })) tests: state.tests.map(test => ({ ...test, err: false, pass: false }))
}), }),
[types.showChallengeComplete]: (state, { payload: toast }) => ({
...state,
toast
}),
// map // map
[types.updateFilter]: (state, { payload = ''}) => ({ [types.updateFilter]: (state, { payload = ''}) => ({

View File

@ -29,6 +29,9 @@ export default createTypes([
'initOutput', 'initOutput',
'updateTests', 'updateTests',
'checkChallenge', 'checkChallenge',
'showChallengeComplete',
'submitChallenge',
'moveToNextChallenge',
// code storage // code storage
'saveCode', 'saveCode',

View File

@ -1,5 +1,6 @@
import { compose } from 'redux'; import { compose } from 'redux';
import { BONFIRE, HTML, JS } from '../../utils/challengeTypes'; import { BONFIRE, HTML, JS } from '../../utils/challengeTypes';
import { dashify } from '../../../utils';
export function encodeScriptTags(value) { export function encodeScriptTags(value) {
return value return value
@ -77,3 +78,34 @@ export function loggerToStr(args) {
}) })
.reduce((str, arg) => str + arg + '\n', ''); .reduce((str, arg) => str + arg + '\n', '');
} }
export function getFirstChallenge(
{ superBlock, block, challenge },
result
) {
return challenge[
block[
superBlock[
result[0]
].blocks[0]
].challenges[0]
];
}
export function getNextChallenge(
current,
entites,
superBlocks
) {
const { challenge: challengeMap, block: blockMap } = entites;
// find current challenge
// find current block
// find next challenge in block
const currentChallenge = challengeMap[current];
if (currentChallenge) {
const block = blockMap[dashify(currentChallenge.block)];
const index = block.challenges.indexOf(currentChallenge.dashedName);
return challengeMap[block.challenges[index + 1]];
}
return getFirstChallenge(entites, superBlocks);
}

View File

@ -1,16 +1,7 @@
export function nameSpacedTransformer(ns, transformer) { export function dashify(str) {
if (!transformer) { return ('' + str)
return nameSpacedTransformer.bind(null, ns); .toLowerCase()
} .replace(/\s/g, '-')
return (state) => { .replace(/[^a-z0-9\-\.]/gi, '')
const newState = transformer(state[ns]); .replace(/\:/g, '');
// nothing has changed
// noop
if (!newState || newState === state[ns]) {
return null;
}
return { ...state, [ns]: newState };
};
} }

View File

@ -196,8 +196,7 @@ gulp.task('serve', function(cb) {
var syncDepenedents = [ var syncDepenedents = [
'serve', 'serve',
'js', 'js',
'less', 'less'
'dependents'
]; ];
gulp.task('sync', syncDepenedents, function() { gulp.task('sync', syncDepenedents, function() {

View File

@ -1,56 +1,11 @@
import _ from 'lodash'; import _ from 'lodash';
import dedent from 'dedent'; // import { Observable, Scheduler } from 'rx';
import moment from 'moment';
import { Observable, Scheduler } from 'rx';
import debug from 'debug'; import debug from 'debug';
import accepts from 'accepts'; import accepts from 'accepts';
import { isMongoId } from 'validator';
import { import { ifNoUserSend } from '../utils/middleware';
dasherize,
unDasherize,
getMDNLinks,
randomVerb,
randomPhrase,
randomCompliment
} from '../utils';
import { observeMethod } from '../utils/rx';
import {
ifNoUserSend,
flashIfNotVerified
} from '../utils/middleware';
import getFromDisk$ from '../utils/getFromDisk$';
import badIdMap from '../utils/bad-id-map';
const isDev = process.env.NODE_ENV !== 'production';
const isBeta = !!process.env.BETA;
const log = debug('fcc:challenges'); const log = debug('fcc:challenges');
const challengesRegex = /^(bonfire|waypoint|zipline|basejump|checkpoint)/i;
const challengeView = {
0: 'challenges/showHTML',
1: 'challenges/showJS',
2: 'challenges/showVideo',
3: 'challenges/showZiplineOrBasejump',
4: 'challenges/showZiplineOrBasejump',
5: 'challenges/showBonfire',
7: 'challenges/showStep'
};
function isChallengeCompleted(user, challengeId) {
if (!user) {
return false;
}
return !!user.challengeMap[challengeId];
}
/*
function numberWithCommas(x) {
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
*/
function buildUserUpdate( function buildUserUpdate(
user, user,
@ -103,434 +58,80 @@ function buildUserUpdate(
return { alreadyCompleted, updateData }; return { alreadyCompleted, updateData };
} }
// small helper function to determine whether to mark something as new
const dateFormat = 'MMM MMMM DD, YYYY';
function shouldShowNew(element, block) {
if (element) {
return typeof element.releasedOn !== 'undefined' &&
moment(element.releasedOn, dateFormat).diff(moment(), 'days') >= -60;
}
if (block) {
const newCount = block.reduce((sum, { markNew }) => {
if (markNew) {
return sum + 1;
}
return sum;
}, 0);
return newCount / block.length * 100 === 100;
}
return null;
}
// meant to be used with a filter method
// on an array or observable stream
// true if challenge should be passed through
// false if should filter challenge out of array or stream
function shouldNotFilterComingSoon({ isComingSoon, isBeta: challengeIsBeta }) {
return isDev ||
!isComingSoon ||
(isBeta && challengeIsBeta);
}
function getRenderData$(user, challenge$, origChallengeName, solution) {
const challengeName = unDasherize(origChallengeName)
.replace(challengesRegex, '');
const testChallengeName = new RegExp(challengeName, 'i');
log('looking for %s', testChallengeName);
return challenge$
.map(challenge => challenge.toJSON())
.filter(challenge => {
return shouldNotFilterComingSoon(challenge) &&
challenge.type !== 'hike' &&
testChallengeName.test(challenge.name);
})
.last({ defaultValue: null })
.flatMap(challenge => {
if (challenge && isDev) {
return getFromDisk$(challenge);
}
return Observable.just(challenge);
})
.flatMap(challenge => {
// Handle not found
if (!challenge) {
log('did not find challenge for ' + origChallengeName);
return Observable.just({
type: 'redirect',
redirectUrl: '/map',
message: dedent`
We couldn't find a challenge with the name ${origChallengeName}.
Please double check the name.
`
});
}
if (dasherize(challenge.name) !== origChallengeName) {
let redirectUrl = `/challenges/${dasherize(challenge.name)}`;
if (solution) {
redirectUrl += `?solution=${encodeURIComponent(solution)}`;
}
return Observable.just({
type: 'redirect',
redirectUrl
});
}
// save user does nothing if user does not exist
return Observable.just({
data: {
...challenge,
// identifies if a challenge is completed
isCompleted: isChallengeCompleted(user, challenge.id),
// video challenges
video: challenge.challengeSeed[0],
// bonfires specific
bonfires: challenge,
MDNkeys: challenge.MDNlinks,
MDNlinks: getMDNLinks(challenge.MDNlinks),
// htmls specific
verb: randomVerb(),
phrase: randomPhrase(),
compliment: randomCompliment(),
// Google Analytics
gaName: challenge.title + '~' + challenge.checksum
}
});
});
}
// create a stream of an array of all the challenge blocks
function getSuperBlocks$(challenge$, challengeMap) {
return challenge$
// mark challenge completed
.map(challengeModel => {
const challenge = challengeModel.toJSON();
challenge.completed = !!challengeMap[challenge.id];
challenge.markNew = shouldShowNew(challenge);
if (challenge.type === 'hike') {
challenge.url = '/videos/' + challenge.dashedName;
} else {
challenge.url = '/challenges/' + challenge.dashedName;
}
return challenge;
})
// group challenges by block | returns a stream of observables
.groupBy(challenge => challenge.block)
// turn block group stream into an array
.flatMap(block$ => block$.toArray())
.map(blockArray => {
const completedCount = blockArray.reduce((sum, { completed }) => {
if (completed) {
return sum + 1;
}
return sum;
}, 0);
const isBeta = _.every(blockArray, 'isBeta');
const isComingSoon = _.every(blockArray, 'isComingSoon');
const isRequired = _.every(blockArray, 'isRequired');
return {
isBeta,
isComingSoon,
isRequired,
name: blockArray[0].block,
superBlock: blockArray[0].superBlock,
dashedName: dasherize(blockArray[0].block),
markNew: shouldShowNew(null, blockArray),
challenges: blockArray,
completed: completedCount / blockArray.length * 100,
time: blockArray[0] && blockArray[0].time || '???'
};
})
.toArray()
.flatMap(blocks => Observable.from(blocks, null, null, Scheduler.default))
.groupBy(block => block.superBlock)
.flatMap(blocks$ => blocks$.toArray())
.map(superBlockArray => ({
name: superBlockArray[0].superBlock,
blocks: superBlockArray
}))
.toArray();
}
function getChallengeById$(challenge$, challengeId) {
// return first challenge if no id is given
if (!challengeId) {
return challenge$
.map(challenge => challenge.toJSON())
.filter(shouldNotFilterComingSoon)
// filter out hikes
.filter(({ superBlock }) => !(/^videos/gi).test(superBlock))
.first();
}
return challenge$
.map(challenge => challenge.toJSON())
// filter out challenges coming soon
.filter(shouldNotFilterComingSoon)
// filter out hikes
.filter(({ superBlock }) => !(/^videos/gi).test(superBlock))
.filter(({ id }) => id === challengeId);
}
function getNextChallenge$(challenge$, blocks$, challengeId) {
return getChallengeById$(challenge$, challengeId)
// now lets find the block it belongs to
.flatMap(challenge => {
// find the index of the block this challenge resides in
const blockIndex$ = blocks$
.findIndex(({ name }) => name === challenge.block);
return blockIndex$
.flatMap(blockIndex => {
// could not find block?
if (blockIndex === -1) {
return Observable.throw(
'could not find challenge block for ' + challenge.block
);
}
const firstChallengeOfNextBlock$ = blocks$
.elementAt(blockIndex + 1, {})
.map(({ challenges = [] }) => challenges[0]);
return blocks$
.filter(shouldNotFilterComingSoon)
.elementAt(blockIndex)
.flatMap(block => {
// find where our challenge lies in the block
const challengeIndex$ = Observable.from(
block.challenges,
null,
null,
Scheduler.default
)
.findIndex(({ id }) => id === challengeId);
// grab next challenge in this block
return challengeIndex$
.map(index => {
return block.challenges[index + 1];
})
.flatMap(nextChallenge => {
if (!nextChallenge) {
return firstChallengeOfNextBlock$;
}
return Observable.just(nextChallenge);
});
});
});
})
.first();
}
module.exports = function(app) { module.exports = function(app) {
const router = app.loopback.Router(); const router = app.loopback.Router();
const challengesQuery = {
order: [
'superOrder ASC',
'order ASC',
'suborder ASC'
]
};
// challenge model
const Challenge = app.models.Challenge;
// challenge find query stream
const findChallenge$ = observeMethod(Challenge, 'find');
// create a stream of all the challenges
const challenge$ = findChallenge$(challengesQuery)
.flatMap(challenges => Observable.from(
challenges,
null,
null,
Scheduler.default
))
// filter out all challenges that have isBeta flag set
// except in development or beta site
.filter(challenge => isDev || isBeta || !challenge.isBeta)
.shareReplay();
// create a stream of challenge blocks
const blocks$ = challenge$
.map(challenge => challenge.toJSON())
.filter(shouldNotFilterComingSoon)
// group challenges by block | returns a stream of observables
.groupBy(challenge => challenge.block)
// turn block group stream into an array
.flatMap(blocks$ => blocks$.toArray())
// turn array into stream of object
.map(blocksArray => ({
name: blocksArray[0].block,
dashedName: dasherize(blocksArray[0].block),
challenges: blocksArray,
superBlock: blocksArray[0].superBlock,
order: blocksArray[0].order
}))
// filter out hikes
.filter(({ superBlock }) => {
return !(/^videos/gi).test(superBlock);
})
.shareReplay();
const firstChallenge$ = challenge$
.first()
.map(challenge => challenge.toJSON())
.shareReplay();
const lastChallenge$ = challenge$
.last()
.map(challenge => challenge.toJSON())
.shareReplay();
const send200toNonUser = ifNoUserSend(true); const send200toNonUser = ifNoUserSend(true);
router.post(
'/modern-challenge-completed',
send200toNonUser,
modernChallengeCompleted
);
router.post( router.post(
'/completed-challenge/', '/completed-challenge/',
send200toNonUser, send200toNonUser,
completedChallenge completedChallenge
); );
router.post( router.post(
'/completed-zipline-or-basejump', '/completed-zipline-or-basejump',
send200toNonUser, send200toNonUser,
completedZiplineOrBasejump completedZiplineOrBasejump
); );
router.get('/map', showMap.bind(null, false));
router.get('/map-aside', showMap.bind(null, true));
router.get(
'/challenges/current-challenge',
redirectToCurrentChallenge
);
router.get(
'/challenges/next-challenge',
redirectToNextChallenge
);
router.get('/challenges/:challengeName',
flashIfNotVerified,
showChallenge
);
app.use(router); app.use(router);
function redirectToCurrentChallenge(req, res, next) { function modernChallengeCompleted(req, res, next) {
let challengeId = req.query.id || req.cookies.currentChallengeId; const type = accepts(req).type('html', 'json', 'text');
// prevent serialized null/undefined from breaking things req.checkBody('id', 'id must be an ObjectId').isMongoId();
req.checkBody('files', 'files must be an object with polyvinyls for keys')
.isFiles();
if (badIdMap[challengeId]) { const errors = req.validationErrors(true);
challengeId = badIdMap[challengeId]; if (errors) {
if (type === 'json') {
return res.status(403).send({ errors });
} }
if (!isMongoId('' + challengeId)) { log('errors', errors);
challengeId = null; return res.sendStatus(403);
} }
getChallengeById$(challenge$, challengeId) const user = req.user;
.doOnNext(({ dashedName })=> { return user.getChallengeMap$()
if (!dashedName) { .flatMap(() => {
log('no challenge found for %s', challengeId); const completedDate = Date.now();
req.flash('info', { const {
msg: `We coudn't find a challenge with the id ${challengeId}` id,
}); files
res.redirect('/map'); } = req.body;
}
res.redirect('/challenges/' + dashedName);
})
.subscribe(() => {}, next);
}
function redirectToNextChallenge(req, res, next) { const { alreadyCompleted, updateData } = buildUserUpdate(
let challengeId = req.query.id || req.cookies.currentChallengeId; user,
id,
if (badIdMap[challengeId]) { {
challengeId = badIdMap[challengeId]; id,
files,
completedDate
} }
if (!isMongoId('' + challengeId)) {
challengeId = null;
}
Observable.combineLatest(
firstChallenge$,
lastChallenge$
)
.flatMap(([firstChallenge, { id: lastChallengeId } ]) => {
// no id supplied, load first challenge
if (!challengeId) {
return Observable.just(firstChallenge);
}
// camper just completed last challenge
if (challengeId === lastChallengeId) {
return Observable.just()
.doOnCompleted(() => {
req.flash('info', {
msg: 'You\'ve completed the last challenge!'
});
return res.redirect('/map');
});
}
return getNextChallenge$(challenge$, blocks$, challengeId);
})
.doOnNext(({ dashedName } = {}) => {
if (!dashedName) {
log('no challenge found for %s', challengeId);
res.redirect('/map');
}
res.redirect('/challenges/' + dashedName);
})
.subscribe(() => {}, next);
}
function showChallenge(req, res, next) {
const solution = req.query.solution;
const challengeName = req.params.challengeName.replace(challengesRegex, '');
const { user } = req;
Observable.defer(() => {
if (user && user.getChallengeMap$) {
return user.getChallengeMap$().map(user);
}
return Observable.just(null);
})
.flatMap(user => {
return getRenderData$(user, challenge$, challengeName, solution);
})
.subscribe(
({ type, redirectUrl, message, data }) => {
if (message) {
req.flash('info', {
msg: message
});
}
if (type === 'redirect') {
log('redirecting to %s', redirectUrl);
return res.redirect(redirectUrl);
}
var view = challengeView[data.challengeType];
if (data.id) {
res.cookie('currentChallengeId', data.id, {
expires: new Date(2147483647000)});
}
return res.render(view, data);
},
next,
function() {}
); );
const points = alreadyCompleted ? user.points : user.points + 1;
return user.update$(updateData)
.doOnNext(({ count }) => log('%s documents updated', count))
.map(() => {
if (type === 'json') {
return res.json({
points,
alreadyCompleted
});
}
return res.sendStatus(200);
});
})
.subscribe(() => {}, next);
} }
function completedChallenge(req, res, next) { function completedChallenge(req, res, next) {
@ -662,24 +263,4 @@ module.exports = function(app) {
}) })
.subscribe(() => {}, next); .subscribe(() => {}, next);
} }
function showMap(showAside, { user }, res, next) {
return Observable.defer(() => {
if (user && typeof user.getChallengeMap$ === 'function') {
return user.getChallengeMap$();
}
return Observable.just({});
})
.flatMap(challengeMap => getSuperBlocks$(challenge$, challengeMap))
.subscribe(
superBlocks => {
res.render('map/show', {
superBlocks,
title: 'A Map to Learn to Code and Become a Software Engineer',
showAside
});
},
next
);
}
}; };

View File

@ -1,4 +1,7 @@
import validator from 'express-validator'; import validator from 'express-validator';
import { isPoly } from '../../common/utils/polyvinyl';
const isObject = val => !!val && typeof val === 'object';
export default function() { export default function() {
return validator({ return validator({
@ -11,6 +14,17 @@ export default function() {
}, },
isNumber(value) { isNumber(value) {
return typeof value === 'number'; return typeof value === 'number';
},
isFiles(value) {
if (!isObject(value)) {
return false;
}
const keys = Object.keys(value);
return !!keys.length &&
// every key is a file
keys.every(key => isObject(value[key])) &&
// every file has contents
keys.map(key => value[key]).every(file => isPoly(file));
} }
} }
}); });