diff --git a/client/sagas/completion-saga.js b/client/sagas/completion-saga.js
deleted file mode 100644
index e66a0b2a5d..0000000000
--- a/client/sagas/completion-saga.js
+++ /dev/null
@@ -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);
- });
-}
diff --git a/client/sagas/index.js b/client/sagas/index.js
index 409cc245ee..e1c78d0243 100644
--- a/client/sagas/index.js
+++ b/client/sagas/index.js
@@ -5,7 +5,6 @@ import hardGoToSaga from './hard-go-to-saga';
import windowSaga from './window-saga';
import executeChallengeSaga from './execute-challenge-saga';
import frameSaga from './frame-saga';
-import completionSaga from './completion-saga';
import codeStorageSaga from './code-storage-saga';
export default [
@@ -16,6 +15,5 @@ export default [
windowSaga,
executeChallengeSaga,
frameSaga,
- completionSaga,
codeStorageSaga
];
diff --git a/common/app/App.jsx b/common/app/App.jsx
index 9e1be2fc7d..0b16145fbb 100644
--- a/common/app/App.jsx
+++ b/common/app/App.jsx
@@ -1,5 +1,5 @@
import React, { PropTypes } from 'react';
-import { Row } from 'react-bootstrap';
+import { Button, Row } from 'react-bootstrap';
import { ToastMessage, ToastContainer } from 'react-toastr';
import { compose } from 'redux';
import { connect } from 'react-redux';
@@ -12,25 +12,41 @@ import {
updateNavHeight
} from './redux/actions';
+import { submitChallenge } from './routes/challenges/redux/actions';
+
import Nav from './components/Nav';
+import { randomCompliment } from './utils/get-words';
const toastMessageFactory = React.createFactory(ToastMessage.animation);
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,
points,
picture,
- toast
- }) => ({
+ toast,
+ showChallengeComplete
+ ) => ({
username,
points,
picture,
- toast
+ toast,
+ showChallengeComplete
})
);
+const bindableActions = {
+ initWindowHeight,
+ updateNavHeight,
+ fetchUser,
+ submitChallenge
+};
+
const fetchContainerOptions = {
fetchAction: 'fetchUser',
isPrimed({ username }) {
@@ -49,11 +65,19 @@ export class FreeCodeCamp extends React.Component {
picture: PropTypes.string,
toast: PropTypes.object,
updateNavHeight: PropTypes.func,
- initWindowHeight: PropTypes.func
+ initWindowHeight: PropTypes.func,
+ showChallengeComplete: PropTypes.number,
+ submitChallenge: PropTypes.func
};
- componentWillReceiveProps({ toast: nextToast = {} }) {
- const { toast = {} } = this.props;
+ componentWillReceiveProps({
+ toast: nextToast = {},
+ showChallengeComplete: nextCC = 0
+ }) {
+ const {
+ toast = {},
+ showChallengeComplete
+ } = this.props;
if (toast.id !== nextToast.id) {
this.refs.toaster[nextToast.type || 'success'](
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() {
this.props.initWindowHeight();
}
+ renderChallengeComplete() {
+ const { submitChallenge } = this.props;
+ return (
+
+ );
+ }
+
render() {
const { username, points, picture, updateNavHeight } = this.props;
const navProps = { username, points, picture, updateNavHeight };
@@ -91,7 +142,7 @@ export class FreeCodeCamp extends React.Component {
const wrapComponent = compose(
// connect Component to Redux Store
- connect(mapStateToProps, { initWindowHeight, updateNavHeight, fetchUser }),
+ connect(mapStateToProps, bindableActions),
// handles prefetching data
contain(fetchContainerOptions)
);
diff --git a/common/app/routes/Hikes/redux/answer-saga.js b/common/app/routes/Hikes/redux/answer-saga.js
index b80ccf67fe..3b2ee5ad6a 100644
--- a/common/app/routes/Hikes/redux/answer-saga.js
+++ b/common/app/routes/Hikes/redux/answer-saga.js
@@ -92,7 +92,7 @@ function handleAnswer(action, getState) {
title: 'Saved',
type: 'info'
}),
- updatePoints(points),
+ updatePoints(points)
);
})
.catch(createErrorObservable);
diff --git a/common/app/routes/challenges/components/Show.jsx b/common/app/routes/challenges/components/Show.jsx
index f6e0b2e2fb..3da7ee70d2 100644
--- a/common/app/routes/challenges/components/Show.jsx
+++ b/common/app/routes/challenges/components/Show.jsx
@@ -7,11 +7,12 @@ import PureComponent from 'react-pure-render/component';
import Classic from './classic/Classic.jsx';
import Step from './step/Step.jsx';
-import { fetchChallenge } from '../redux/actions';
+import { fetchChallenge, fetchChallenges } from '../redux/actions';
import { challengeSelector } from '../redux/selectors';
const bindableActions = {
- fetchChallenge
+ fetchChallenge,
+ fetchChallenges
};
const mapStateToProps = createSelector(
@@ -32,14 +33,30 @@ const fetchOptions = {
export class Challenges extends PureComponent {
static displayName = 'Challenges';
- static propTypes = { isStep: PropTypes.bool };
+ static propTypes = {
+ isStep: PropTypes.bool,
+ fetchChallenges: PropTypes.func
+ };
- render() {
- if (this.props.isStep) {
+ componentDidMount() {
+ this.props.fetchChallenges();
+ }
+
+ renderView(isStep) {
+ if (isStep) {
return ;
}
return ;
}
+
+ render() {
+ const { isStep } = this.props;
+ return (
+
+ { this.renderView(isStep) }
+
+ );
+ }
}
export default compose(
diff --git a/common/app/routes/challenges/redux/actions.js b/common/app/routes/challenges/redux/actions.js
index afec078e65..a8c23782bf 100644
--- a/common/app/routes/challenges/redux/actions.js
+++ b/common/app/routes/challenges/redux/actions.js
@@ -58,6 +58,18 @@ export const updateOutput = createAction(types.updateOutput, loggerToStr);
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
export const saveCode = createAction(types.saveCode);
export const loadCode = createAction(types.loadCode);
diff --git a/common/app/routes/challenges/redux/completion-saga.js b/common/app/routes/challenges/redux/completion-saga.js
new file mode 100644
index 0000000000..76bf2fcd56
--- /dev/null
+++ b/common/app/routes/challenges/redux/completion-saga.js
@@ -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'
+ }));
+ });
+}
diff --git a/common/app/routes/challenges/redux/fetch-challenges-saga.js b/common/app/routes/challenges/redux/fetch-challenges-saga.js
index c217f20863..5e6906dc2b 100644
--- a/common/app/routes/challenges/redux/fetch-challenges-saga.js
+++ b/common/app/routes/challenges/redux/fetch-challenges-saga.js
@@ -9,9 +9,10 @@ import {
export default function fetchChallengesSaga(action$, getState, { services }) {
return action$
- .filter(
- ({ type }) => type === fetchChallenges || type === fetchChallenge
- )
+ .filter(({ type }) => (
+ type === fetchChallenges ||
+ type === fetchChallenge
+ ))
.flatMap(({ type, payload })=> {
const options = { service: 'map' };
if (type === fetchChallenge) {
diff --git a/common/app/routes/challenges/redux/index.js b/common/app/routes/challenges/redux/index.js
index 79b8c842e4..35165ea9f2 100644
--- a/common/app/routes/challenges/redux/index.js
+++ b/common/app/routes/challenges/redux/index.js
@@ -3,4 +3,6 @@ export reducer from './reducer';
export types from './types';
import fetchChallengesSaga from './fetch-challenges-saga';
-export const sagas = [ fetchChallengesSaga ];
+import completionSaga from './completion-saga';
+
+export const sagas = [ fetchChallengesSaga, completionSaga ];
diff --git a/common/app/routes/challenges/redux/reducer.js b/common/app/routes/challenges/redux/reducer.js
index 5f48df4db9..6f78ad1d28 100644
--- a/common/app/routes/challenges/redux/reducer.js
+++ b/common/app/routes/challenges/redux/reducer.js
@@ -19,7 +19,8 @@ const initialState = {
previousStep: -1,
filter: '',
files: {},
- superBlocks: []
+ superBlocks: [],
+ toast: 0
};
const mainReducer = handleActions(
@@ -45,6 +46,10 @@ const mainReducer = handleActions(
...state,
tests: state.tests.map(test => ({ ...test, err: false, pass: false }))
}),
+ [types.showChallengeComplete]: (state, { payload: toast }) => ({
+ ...state,
+ toast
+ }),
// map
[types.updateFilter]: (state, { payload = ''}) => ({
diff --git a/common/app/routes/challenges/redux/types.js b/common/app/routes/challenges/redux/types.js
index a940a7ec95..fc6db656d2 100644
--- a/common/app/routes/challenges/redux/types.js
+++ b/common/app/routes/challenges/redux/types.js
@@ -29,6 +29,9 @@ export default createTypes([
'initOutput',
'updateTests',
'checkChallenge',
+ 'showChallengeComplete',
+ 'submitChallenge',
+ 'moveToNextChallenge',
// code storage
'saveCode',
diff --git a/common/app/routes/challenges/utils.js b/common/app/routes/challenges/utils.js
index 991afd8a91..7689ca88cf 100644
--- a/common/app/routes/challenges/utils.js
+++ b/common/app/routes/challenges/utils.js
@@ -1,5 +1,6 @@
import { compose } from 'redux';
import { BONFIRE, HTML, JS } from '../../utils/challengeTypes';
+import { dashify } from '../../../utils';
export function encodeScriptTags(value) {
return value
@@ -77,3 +78,34 @@ export function loggerToStr(args) {
})
.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);
+}
diff --git a/common/utils/index.js b/common/utils/index.js
index 85f49c563f..64e6beeac2 100644
--- a/common/utils/index.js
+++ b/common/utils/index.js
@@ -1,16 +1,7 @@
-export function nameSpacedTransformer(ns, transformer) {
- if (!transformer) {
- return nameSpacedTransformer.bind(null, ns);
- }
- return (state) => {
- const newState = transformer(state[ns]);
-
- // nothing has changed
- // noop
- if (!newState || newState === state[ns]) {
- return null;
- }
-
- return { ...state, [ns]: newState };
- };
+export function dashify(str) {
+ return ('' + str)
+ .toLowerCase()
+ .replace(/\s/g, '-')
+ .replace(/[^a-z0-9\-\.]/gi, '')
+ .replace(/\:/g, '');
}
diff --git a/gulpfile.js b/gulpfile.js
index a4eb1c69c8..126e931acf 100644
--- a/gulpfile.js
+++ b/gulpfile.js
@@ -196,8 +196,7 @@ gulp.task('serve', function(cb) {
var syncDepenedents = [
'serve',
'js',
- 'less',
- 'dependents'
+ 'less'
];
gulp.task('sync', syncDepenedents, function() {
diff --git a/server/boot/challenge.js b/server/boot/challenge.js
index 380a6c9b03..65271d5d77 100644
--- a/server/boot/challenge.js
+++ b/server/boot/challenge.js
@@ -1,56 +1,11 @@
import _ from 'lodash';
-import dedent from 'dedent';
-import moment from 'moment';
-import { Observable, Scheduler } from 'rx';
+// import { Observable, Scheduler } from 'rx';
import debug from 'debug';
import accepts from 'accepts';
-import { isMongoId } from 'validator';
-import {
- dasherize,
- unDasherize,
- getMDNLinks,
- randomVerb,
- randomPhrase,
- randomCompliment
-} from '../utils';
+import { ifNoUserSend } from '../utils/middleware';
-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 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(
user,
@@ -103,434 +58,80 @@ function buildUserUpdate(
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) {
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);
+ router.post(
+ '/modern-challenge-completed',
+ send200toNonUser,
+ modernChallengeCompleted
+ );
+
router.post(
'/completed-challenge/',
send200toNonUser,
completedChallenge
);
+
router.post(
'/completed-zipline-or-basejump',
send200toNonUser,
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);
- function redirectToCurrentChallenge(req, res, next) {
- let challengeId = req.query.id || req.cookies.currentChallengeId;
- // prevent serialized null/undefined from breaking things
+ function modernChallengeCompleted(req, res, next) {
+ const type = accepts(req).type('html', 'json', 'text');
+ 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]) {
- challengeId = badIdMap[challengeId];
- }
-
- if (!isMongoId('' + challengeId)) {
- challengeId = null;
- }
-
- getChallengeById$(challenge$, challengeId)
- .doOnNext(({ dashedName })=> {
- if (!dashedName) {
- log('no challenge found for %s', challengeId);
- req.flash('info', {
- msg: `We coudn't find a challenge with the id ${challengeId}`
- });
- res.redirect('/map');
- }
- res.redirect('/challenges/' + dashedName);
- })
- .subscribe(() => {}, next);
- }
-
- function redirectToNextChallenge(req, res, next) {
- let challengeId = req.query.id || req.cookies.currentChallengeId;
-
- if (badIdMap[challengeId]) {
- challengeId = badIdMap[challengeId];
- }
-
- 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);
+ const errors = req.validationErrors(true);
+ if (errors) {
+ if (type === 'json') {
+ return res.status(403).send({ errors });
}
- return Observable.just(null);
- })
- .flatMap(user => {
- return getRenderData$(user, challenge$, challengeName, solution);
+
+ log('errors', errors);
+ return res.sendStatus(403);
+ }
+
+ const user = req.user;
+ return user.getChallengeMap$()
+ .flatMap(() => {
+ const completedDate = Date.now();
+ const {
+ id,
+ files
+ } = req.body;
+
+ const { alreadyCompleted, updateData } = buildUserUpdate(
+ user,
+ id,
+ {
+ id,
+ files,
+ completedDate
+ }
+ );
+
+ 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(
- ({ 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() {}
- );
+ .subscribe(() => {}, next);
}
function completedChallenge(req, res, next) {
@@ -662,24 +263,4 @@ module.exports = function(app) {
})
.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
- );
- }
};
diff --git a/server/middlewares/validator.js b/server/middlewares/validator.js
index 7405525bef..80a7ede590 100644
--- a/server/middlewares/validator.js
+++ b/server/middlewares/validator.js
@@ -1,4 +1,7 @@
import validator from 'express-validator';
+import { isPoly } from '../../common/utils/polyvinyl';
+
+const isObject = val => !!val && typeof val === 'object';
export default function() {
return validator({
@@ -11,6 +14,17 @@ export default function() {
},
isNumber(value) {
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));
}
}
});