Moves to next challenges
This commit is contained in:
@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
@ -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
|
||||||
];
|
];
|
||||||
|
@ -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)
|
||||||
);
|
);
|
||||||
|
@ -92,7 +92,7 @@ function handleAnswer(action, getState) {
|
|||||||
title: 'Saved',
|
title: 'Saved',
|
||||||
type: 'info'
|
type: 'info'
|
||||||
}),
|
}),
|
||||||
updatePoints(points),
|
updatePoints(points)
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.catch(createErrorObservable);
|
.catch(createErrorObservable);
|
||||||
|
@ -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(
|
||||||
|
@ -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);
|
||||||
|
106
common/app/routes/challenges/redux/completion-saga.js
Normal file
106
common/app/routes/challenges/redux/completion-saga.js
Normal 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'
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
@ -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) {
|
||||||
|
@ -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 ];
|
||||||
|
@ -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 = ''}) => ({
|
||||||
|
@ -29,6 +29,9 @@ export default createTypes([
|
|||||||
'initOutput',
|
'initOutput',
|
||||||
'updateTests',
|
'updateTests',
|
||||||
'checkChallenge',
|
'checkChallenge',
|
||||||
|
'showChallengeComplete',
|
||||||
|
'submitChallenge',
|
||||||
|
'moveToNextChallenge',
|
||||||
|
|
||||||
// code storage
|
// code storage
|
||||||
'saveCode',
|
'saveCode',
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
|
@ -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 };
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
@ -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() {
|
||||||
|
@ -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)) {
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
return Observable.just(null);
|
|
||||||
})
|
log('errors', errors);
|
||||||
.flatMap(user => {
|
return res.sendStatus(403);
|
||||||
return getRenderData$(user, challenge$, challengeName, solution);
|
}
|
||||||
|
|
||||||
|
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(
|
.subscribe(() => {}, next);
|
||||||
({ 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() {}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
Reference in New Issue
Block a user