feat(challenges): add backend challenge infrastructure (#11058)
* Feat: Initial backend view * Feat: Refactor frame runner * Feat: backend challenge submit runs tests * Feat: Backend challenge request * Feat: Whitelist hyperdev in csp * Fix: Use app tests instead of challenge tests * Feat: Allow hyperdev subdomains * Fix(csp): allow hypderdev.space subdomains * feat(challenge): submit backend * feat: Add timeout to test runner (5 sec) * chore(seed): Add more to test backend * fix(csp): s/hyperdev/gomix/g * fix(app): fix code mirror skeleton filepath * fix(app): remove Gitter saga import * fix(app): codemirrorskeleton does not need it's own folder fix(app): cmk needs to work with Null types * fix: No longer restart the browser when challenges change * fix(app): Update jquery for challenges * fix(seed): Remove to promise jquery call * fix(lint): Undo merge error undefined is no allowed * fix(app): linting errors due to bad merge * fix(seed): Remove old seed file
This commit is contained in:
committed by
Quincy Larson
parent
1b2b54e2b0
commit
f1d936198e
@ -146,7 +146,7 @@
|
|||||||
"no-unused-expressions": 2,
|
"no-unused-expressions": 2,
|
||||||
"no-unused-vars": 2,
|
"no-unused-vars": 2,
|
||||||
"no-use-before-define": 0,
|
"no-use-before-define": 0,
|
||||||
"no-void": 2,
|
"no-void": 0,
|
||||||
"no-warning-comments": [ 2, { "terms": [ "fixme" ], "location": "start" } ],
|
"no-warning-comments": [ 2, { "terms": [ "fixme" ], "location": "start" } ],
|
||||||
"no-with": 2,
|
"no-with": 2,
|
||||||
"one-var": 0,
|
"one-var": 0,
|
||||||
|
@ -1,11 +1,14 @@
|
|||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
var testTimeout = 5000;
|
||||||
var common = parent.__common;
|
var common = parent.__common;
|
||||||
var frameId = window.__frameId;
|
var frameId = window.__frameId;
|
||||||
var frameReady = common[frameId + 'Ready$'] || { onNext() {} };
|
var frameReady = common[frameId + 'Ready'] || { onNext() {} };
|
||||||
var Rx = document.Rx;
|
var Rx = document.Rx;
|
||||||
var helpers = Rx.helpers;
|
var helpers = Rx.helpers;
|
||||||
var chai = parent.chai;
|
var chai = parent.chai;
|
||||||
var source = document.__source;
|
var source = document.__source;
|
||||||
|
var __getUserInput = document.__getUserInput || (x => x);
|
||||||
|
var checkChallengePayload = document.__checkChallengePayload;
|
||||||
|
|
||||||
document.__getJsOutput = function getJsOutput() {
|
document.__getJsOutput = function getJsOutput() {
|
||||||
if (window.__err || !common.shouldRun()) {
|
if (window.__err || !common.shouldRun()) {
|
||||||
@ -23,13 +26,23 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
return output;
|
return output;
|
||||||
};
|
};
|
||||||
|
|
||||||
document.__runTests$ = function runTests$(tests = []) {
|
document.__runTests = function runTests(tests = []) {
|
||||||
/* eslint-disable no-unused-vars */
|
/* eslint-disable no-unused-vars */
|
||||||
const editor = { getValue() { return source; } };
|
const editor = { getValue() { return source; } };
|
||||||
const code = source;
|
const code = source;
|
||||||
/* eslint-enable no-unused-vars */
|
/* eslint-enable no-unused-vars */
|
||||||
if (window.__err) {
|
if (window.__err) {
|
||||||
return Rx.Observable.throw(window.__err);
|
return Rx.Observable.from(tests)
|
||||||
|
.map(test => {
|
||||||
|
return {
|
||||||
|
...test,
|
||||||
|
err: window.__err.message + '\n' + window.__err.stack,
|
||||||
|
message: window.__err.message,
|
||||||
|
stack: window.__err.stack
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.toArray()
|
||||||
|
.do(() => { window.__err = null; });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Iterate through the test one at a time
|
// Iterate through the test one at a time
|
||||||
@ -40,7 +53,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
/* eslint-disable no-unused-vars */
|
/* eslint-disable no-unused-vars */
|
||||||
.flatMap(({ text, testString }) => {
|
.flatMap(({ text, testString }) => {
|
||||||
const assert = chai.assert;
|
const assert = chai.assert;
|
||||||
/* eslint-enable no-unused-vars */
|
const getUserInput = __getUserInput;
|
||||||
|
/* eslint-enable no-unused-vars */
|
||||||
const newTest = { text, testString };
|
const newTest = { text, testString };
|
||||||
let test;
|
let test;
|
||||||
let __result;
|
let __result;
|
||||||
@ -57,18 +71,18 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
// the function could expect a callback
|
// the function could expect a callback
|
||||||
// or it could return a promise/observable
|
// or it could return a promise/observable
|
||||||
// or it could still be sync
|
// or it could still be sync
|
||||||
if (test.length === 0) {
|
if (test.length === 1) {
|
||||||
// a function with length 0 means it expects 0 args
|
// a function with length 0 means it expects 0 args
|
||||||
// We call it and store the result
|
// We call it and store the result
|
||||||
// This result may be a promise or an observable or undefined
|
// This result may be a promise or an observable or undefined
|
||||||
__result = test();
|
__result = test(getUserInput);
|
||||||
} else {
|
} else {
|
||||||
// if function takes arguments
|
// if function takes arguments
|
||||||
// we expect it to be of the form
|
// we expect it to be of the form
|
||||||
// function(cb) { /* ... */ }
|
// function(cb) { /* ... */ }
|
||||||
// and callback has the following signature
|
// and callback has the following signature
|
||||||
// function(err) { /* ... */ }
|
// function(err) { /* ... */ }
|
||||||
__result = Rx.Observable.fromNodeCallback(test)();
|
__result = Rx.Observable.fromNodeCallback(test)(getUserInput);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (helpers.isPromise(__result)) {
|
if (helpers.isPromise(__result)) {
|
||||||
@ -86,6 +100,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
__result = Rx.Observable.throw(e);
|
__result = Rx.Observable.throw(e);
|
||||||
}
|
}
|
||||||
return __result
|
return __result
|
||||||
|
.timeout(testTimeout)
|
||||||
.map(() => {
|
.map(() => {
|
||||||
// we don't need the result of a promise/observable/cb here
|
// we don't need the result of a promise/observable/cb here
|
||||||
// all data asserts should happen further up the chain
|
// all data asserts should happen further up the chain
|
||||||
@ -96,7 +111,15 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
.catch(err => {
|
.catch(err => {
|
||||||
// we catch the error here to prevent the error from bubbling up
|
// we catch the error here to prevent the error from bubbling up
|
||||||
// and collapsing the pipe
|
// and collapsing the pipe
|
||||||
|
let message = (err.message || '');
|
||||||
|
const assertIndex = message.indexOf(': expected');
|
||||||
|
if (assertIndex !== -1) {
|
||||||
|
message = message.slice(0, assertIndex);
|
||||||
|
}
|
||||||
|
message = message.replace(/<code>(.*)<\/code>/, '$1');
|
||||||
newTest.err = err.message + '\n' + err.stack;
|
newTest.err = err.message + '\n' + err.stack;
|
||||||
|
newTest.stack = err.stack;
|
||||||
|
newTest.message = message;
|
||||||
// RxJS catch expects an observable as a return
|
// RxJS catch expects an observable as a return
|
||||||
return Rx.Observable.of(newTest);
|
return Rx.Observable.of(newTest);
|
||||||
});
|
});
|
||||||
@ -106,5 +129,5 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// notify that the window methods are ready to run
|
// notify that the window methods are ready to run
|
||||||
frameReady.onNext(null);
|
frameReady.onNext({ checkChallengePayload });
|
||||||
});
|
});
|
||||||
|
58
client/sagas/build-challenge-epic.js
Normal file
58
client/sagas/build-challenge-epic.js
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { Scheduler, Observable } from 'rx';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildClassic,
|
||||||
|
buildBackendChallenge
|
||||||
|
} from '../utils/build.js';
|
||||||
|
import { ofType } from '../../common/utils/get-actions-of-type.js';
|
||||||
|
import {
|
||||||
|
challengeSelector
|
||||||
|
} from '../../common/app/routes/challenges/redux/selectors';
|
||||||
|
import types from '../../common/app/routes/challenges/redux/types';
|
||||||
|
import { createErrorObservable } from '../../common/app/redux/actions';
|
||||||
|
import {
|
||||||
|
frameMain,
|
||||||
|
frameTests,
|
||||||
|
initOutput,
|
||||||
|
saveCode
|
||||||
|
} from '../../common/app/routes/challenges/redux/actions';
|
||||||
|
|
||||||
|
export default function buildChallengeEpic(actions, getState) {
|
||||||
|
return actions
|
||||||
|
::ofType(types.executeChallenge, types.updateMain)
|
||||||
|
// if isCodeLocked do not run challenges
|
||||||
|
.filter(() => !getState().challengesApp.isCodeLocked)
|
||||||
|
.debounce(750)
|
||||||
|
.flatMapLatest(({ type }) => {
|
||||||
|
const shouldProxyConsole = type === types.updateMain;
|
||||||
|
const state = getState();
|
||||||
|
const { files } = state.challengesApp;
|
||||||
|
const {
|
||||||
|
challenge: {
|
||||||
|
required = [],
|
||||||
|
type: challengeType
|
||||||
|
}
|
||||||
|
} = challengeSelector(state);
|
||||||
|
if (challengeType === 'backend') {
|
||||||
|
return buildBackendChallenge(state)
|
||||||
|
.map(frameTests)
|
||||||
|
.startWith(initOutput('// running test'));
|
||||||
|
}
|
||||||
|
return buildClassic(files, required, shouldProxyConsole)
|
||||||
|
.flatMap(payload => {
|
||||||
|
const actions = [
|
||||||
|
frameMain(payload)
|
||||||
|
];
|
||||||
|
if (type === types.executeChallenge) {
|
||||||
|
actions.push(saveCode(), frameTests(payload));
|
||||||
|
}
|
||||||
|
return Observable.from(actions, null, null, Scheduler.default);
|
||||||
|
})
|
||||||
|
.startWith((
|
||||||
|
type === types.executeChallenge ?
|
||||||
|
initOutput('// running test') :
|
||||||
|
null
|
||||||
|
))
|
||||||
|
.catch(createErrorObservable);
|
||||||
|
});
|
||||||
|
}
|
@ -1,181 +0,0 @@
|
|||||||
import { Scheduler, Observable } from 'rx';
|
|
||||||
|
|
||||||
import {
|
|
||||||
challengeSelector
|
|
||||||
} from '../../common/app/routes/challenges/redux/selectors';
|
|
||||||
import { ajax$ } from '../../common/utils/ajax-stream';
|
|
||||||
import throwers from '../rechallenge/throwers';
|
|
||||||
import transformers from '../rechallenge/transformers';
|
|
||||||
import types from '../../common/app/routes/challenges/redux/types';
|
|
||||||
import { createErrorObservable } from '../../common/app/redux/actions';
|
|
||||||
import {
|
|
||||||
frameMain,
|
|
||||||
frameTests,
|
|
||||||
initOutput,
|
|
||||||
saveCode
|
|
||||||
} from '../../common/app/routes/challenges/redux/actions';
|
|
||||||
import { setExt, updateContents } from '../../common/utils/polyvinyl';
|
|
||||||
|
|
||||||
// createFileStream(files: Dictionary[Path, PolyVinyl]) =>
|
|
||||||
// Observable[...Observable[...PolyVinyl]]
|
|
||||||
function createFileStream(files = {}) {
|
|
||||||
return Observable.just(
|
|
||||||
Observable.from(Object.keys(files)).map(key => files[key])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const globalRequires = [{
|
|
||||||
link: 'https://cdnjs.cloudflare.com/' +
|
|
||||||
'ajax/libs/normalize/4.2.0/normalize.min.css'
|
|
||||||
}, {
|
|
||||||
src: 'https://cdnjs.cloudflare.com/ajax/libs/jquery/2.2.4/jquery.js'
|
|
||||||
}];
|
|
||||||
|
|
||||||
const scriptCache = new Map();
|
|
||||||
const linkCache = new Map();
|
|
||||||
|
|
||||||
function cacheScript({ src } = {}, crossDomain = true) {
|
|
||||||
if (!src) {
|
|
||||||
throw new Error('No source provided for script');
|
|
||||||
}
|
|
||||||
if (scriptCache.has(src)) {
|
|
||||||
return scriptCache.get(src);
|
|
||||||
}
|
|
||||||
const script$ = ajax$({ url: src, crossDomain })
|
|
||||||
.doOnNext(res => {
|
|
||||||
if (res.status !== 200) {
|
|
||||||
throw new Error('Request errror: ' + res.status);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.map(({ response }) => response)
|
|
||||||
.map(script => `<script>${script}</script>`)
|
|
||||||
.shareReplay();
|
|
||||||
|
|
||||||
scriptCache.set(src, script$);
|
|
||||||
return script$;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cacheLink({ link } = {}, crossDomain = true) {
|
|
||||||
if (!link) {
|
|
||||||
return Observable.throw(new Error('No source provided for link'));
|
|
||||||
}
|
|
||||||
if (linkCache.has(link)) {
|
|
||||||
return linkCache.get(link);
|
|
||||||
}
|
|
||||||
const link$ = ajax$({ url: link, crossDomain })
|
|
||||||
.doOnNext(res => {
|
|
||||||
if (res.status !== 200) {
|
|
||||||
throw new Error('Request errror: ' + res.status);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.map(({ response }) => response)
|
|
||||||
.map(script => `<style>${script}</style>`)
|
|
||||||
.catch(() => Observable.just(''))
|
|
||||||
.shareReplay();
|
|
||||||
|
|
||||||
linkCache.set(link, link$);
|
|
||||||
return link$;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const htmlCatch = '\n<!--fcc-->';
|
|
||||||
const jsCatch = '\n;/*fcc*/\n';
|
|
||||||
|
|
||||||
export default function executeChallengeSaga(action$, getState) {
|
|
||||||
const frameRunner$ = cacheScript(
|
|
||||||
{ src: '/js/frame-runner.js' },
|
|
||||||
false
|
|
||||||
);
|
|
||||||
return action$
|
|
||||||
.filter(({ type }) => (
|
|
||||||
type === types.executeChallenge ||
|
|
||||||
type === types.updateMain
|
|
||||||
))
|
|
||||||
// if isCodeLockedTrue do not run challenges
|
|
||||||
.filter(() => !getState().challengesApp.isCodeLocked)
|
|
||||||
.debounce(750)
|
|
||||||
.flatMapLatest(({ type }) => {
|
|
||||||
const state = getState();
|
|
||||||
const { files } = state.challengesApp;
|
|
||||||
const { challenge: { required = [] } } = challengeSelector(state);
|
|
||||||
const finalRequires = [...globalRequires, ...required ];
|
|
||||||
return createFileStream(files)
|
|
||||||
::throwers()
|
|
||||||
::transformers()
|
|
||||||
// createbuild
|
|
||||||
.flatMap(file$ => file$.reduce((build, file) => {
|
|
||||||
let finalFile;
|
|
||||||
const finalContents = [
|
|
||||||
file.head,
|
|
||||||
file.contents,
|
|
||||||
file.tail
|
|
||||||
];
|
|
||||||
if (file.ext === 'js') {
|
|
||||||
finalFile = setExt('html', updateContents(
|
|
||||||
`<script>${finalContents.join(jsCatch)}${jsCatch}</script>`,
|
|
||||||
file
|
|
||||||
));
|
|
||||||
} else if (file.ext === 'css') {
|
|
||||||
finalFile = setExt('html', updateContents(
|
|
||||||
`<style>${finalContents.join(htmlCatch)}</style>`,
|
|
||||||
file
|
|
||||||
));
|
|
||||||
} else {
|
|
||||||
finalFile = file;
|
|
||||||
}
|
|
||||||
return build + finalFile.contents + htmlCatch;
|
|
||||||
}, ''))
|
|
||||||
// add required scripts and links here
|
|
||||||
.flatMap(source => {
|
|
||||||
const head$ = Observable.from(finalRequires)
|
|
||||||
.flatMap(required => {
|
|
||||||
if (required.src) {
|
|
||||||
return cacheScript(required, required.crossDomain);
|
|
||||||
}
|
|
||||||
// css files with `url(...` may not work in style tags
|
|
||||||
// so we put them in raw links
|
|
||||||
if (required.link && required.raw) {
|
|
||||||
return Observable.just(
|
|
||||||
`<link href=${required.link} rel='stylesheet' />`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (required.link) {
|
|
||||||
return cacheLink(required, required.crossDomain);
|
|
||||||
}
|
|
||||||
return Observable.just('');
|
|
||||||
})
|
|
||||||
.reduce((head, required) => head + required, '')
|
|
||||||
.map(head => `<head>${head}</head>`);
|
|
||||||
|
|
||||||
return Observable.combineLatest(head$, frameRunner$)
|
|
||||||
.map(([ head, frameRunner ]) => {
|
|
||||||
const body = `
|
|
||||||
<body style='margin:8px;'>
|
|
||||||
<!-- fcc-start-source -->
|
|
||||||
${source}
|
|
||||||
<!-- fcc-end-source -->
|
|
||||||
</body>`;
|
|
||||||
return {
|
|
||||||
build: head + body + frameRunner,
|
|
||||||
source,
|
|
||||||
head
|
|
||||||
};
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.flatMap(payload => {
|
|
||||||
const actions = [
|
|
||||||
frameMain(payload)
|
|
||||||
];
|
|
||||||
if (type === types.executeChallenge) {
|
|
||||||
actions.push(saveCode(), frameTests(payload));
|
|
||||||
}
|
|
||||||
return Observable.from(actions, null, null, Scheduler.default);
|
|
||||||
})
|
|
||||||
.startWith((
|
|
||||||
type === types.executeChallenge ?
|
|
||||||
initOutput('// running test') :
|
|
||||||
null
|
|
||||||
))
|
|
||||||
.catch(createErrorObservable);
|
|
||||||
});
|
|
||||||
}
|
|
@ -10,13 +10,20 @@ import {
|
|||||||
updateTests
|
updateTests
|
||||||
} from '../../common/app/routes/challenges/redux/actions';
|
} from '../../common/app/routes/challenges/redux/actions';
|
||||||
|
|
||||||
// we use three different frames to make them all essentially pure functions
|
// we use two different frames to make them all essentially pure functions
|
||||||
|
// main iframe is responsible rendering the preview and is where we proxy the
|
||||||
|
// console.log
|
||||||
const mainId = 'fcc-main-frame';
|
const mainId = 'fcc-main-frame';
|
||||||
|
// the test frame is responsible for running the assert tests
|
||||||
const testId = 'fcc-test-frame';
|
const testId = 'fcc-test-frame';
|
||||||
|
|
||||||
const createHeader = (id = mainId) => `
|
const createHeader = (id = mainId) => `
|
||||||
<script>
|
<script>
|
||||||
window.__frameId = '${id}';
|
window.__frameId = '${id}';
|
||||||
|
window.onerror = function(msg, url, ln, col, err) {
|
||||||
|
window.__err = err;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@ -46,78 +53,79 @@ function getFrameDocument(document, id = mainId) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const consoleReg = /(?:\b)console(\.log\S+)/g;
|
function buildProxyConsole(window, proxyLogger) {
|
||||||
const sourceReg =
|
|
||||||
/(<!-- fcc-start-source -->)([\s\S]*?)(?=<!-- fcc-end-source -->)/g;
|
|
||||||
function proxyConsole(build, source) {
|
|
||||||
const newSource = source.replace(consoleReg, (match, methodCall) => {
|
|
||||||
return 'window.__console' + methodCall;
|
|
||||||
});
|
|
||||||
return build.replace(sourceReg, '\$1' + newSource);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildProxyConsole(window, proxyLogger$) {
|
|
||||||
const oldLog = window.console.log.bind(console);
|
const oldLog = window.console.log.bind(console);
|
||||||
window.__console = {};
|
window.__console = {};
|
||||||
window.__console.log = function proxyConsole(...args) {
|
window.__console.log = function proxyConsole(...args) {
|
||||||
proxyLogger$.onNext(args);
|
proxyLogger.onNext(args);
|
||||||
return oldLog(...args);
|
return oldLog(...args);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function frameMain({ build, source } = {}, document, proxyLogger$) {
|
function frameMain({ build } = {}, document, proxyLogger) {
|
||||||
const { frame: main, frameWindow } = getFrameDocument(document);
|
const { frame: main, frameWindow } = getFrameDocument(document);
|
||||||
refreshFrame(main);
|
refreshFrame(main);
|
||||||
buildProxyConsole(frameWindow, proxyLogger$);
|
buildProxyConsole(frameWindow, proxyLogger);
|
||||||
main.Rx = Rx;
|
main.Rx = Rx;
|
||||||
main.open();
|
main.open();
|
||||||
main.write(createHeader() + proxyConsole(build, source));
|
main.write(createHeader() + build);
|
||||||
main.close();
|
main.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
function frameTests({ build, source } = {}, document) {
|
function frameTests({ build, source, checkChallengePayload } = {}, document) {
|
||||||
const { frame: tests } = getFrameDocument(document, testId);
|
const { frame: tests } = getFrameDocument(document, testId);
|
||||||
refreshFrame(tests);
|
refreshFrame(tests);
|
||||||
tests.Rx = Rx;
|
tests.Rx = Rx;
|
||||||
tests.__source = source;
|
tests.__source = source;
|
||||||
|
tests.__getUserInput = key => source[key];
|
||||||
|
tests.__checkChallengePayload = checkChallengePayload;
|
||||||
tests.open();
|
tests.open();
|
||||||
tests.write(createHeader(testId) + build);
|
tests.write(createHeader(testId) + build);
|
||||||
tests.close();
|
tests.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function frameSaga(actions$, getState, { window, document }) {
|
export default function frameEpic(actions, getState, { window, document }) {
|
||||||
|
// we attach a common place for the iframes to pull in functions from
|
||||||
|
// the main process
|
||||||
window.__common = {};
|
window.__common = {};
|
||||||
window.__common.shouldRun = () => true;
|
window.__common.shouldRun = () => true;
|
||||||
const proxyLogger$ = new Subject();
|
// this will proxy console.log calls
|
||||||
const runTests$ = window.__common[testId + 'Ready$'] =
|
const proxyLogger = new Subject();
|
||||||
new Subject();
|
// frameReady will let us know when the test iframe is ready to run
|
||||||
const result$ = actions$::ofType(
|
const frameReady = window.__common[testId + 'Ready'] = new Subject();
|
||||||
types.frameMain,
|
const result = actions
|
||||||
types.frameTests,
|
::ofType(types.frameMain, types.frameTests)
|
||||||
types.frameOutput
|
|
||||||
)
|
|
||||||
// if isCodeLocked is true do not frame user code
|
// if isCodeLocked is true do not frame user code
|
||||||
.filter(() => !getState().challengesApp.isCodeLocked)
|
.filter(() => !getState().challengesApp.isCodeLocked)
|
||||||
.map(action => {
|
.map(action => {
|
||||||
if (action.type === types.frameMain) {
|
if (action.type === types.frameMain) {
|
||||||
return frameMain(action.payload, document, proxyLogger$);
|
return frameMain(action.payload, document, proxyLogger);
|
||||||
}
|
}
|
||||||
return frameTests(action.payload, document);
|
return frameTests(action.payload, document);
|
||||||
});
|
})
|
||||||
|
.ignoreElements();
|
||||||
|
|
||||||
return Observable.merge(
|
return Observable.merge(
|
||||||
proxyLogger$.map(updateOutput),
|
proxyLogger.map(updateOutput),
|
||||||
runTests$.flatMap(() => {
|
frameReady.flatMap(({ checkChallengePayload }) => {
|
||||||
const { frame } = getFrameDocument(document, testId);
|
const { frame } = getFrameDocument(document, testId);
|
||||||
const { tests } = getState().challengesApp;
|
const { tests } = getState().challengesApp;
|
||||||
const postTests = Observable.of(
|
const postTests = Observable.of(
|
||||||
updateOutput('// tests completed'),
|
updateOutput('// tests completed'),
|
||||||
checkChallenge()
|
checkChallenge(checkChallengePayload)
|
||||||
).delay(250);
|
).delay(250);
|
||||||
return frame.__runTests$(tests)
|
// run the tests within the test iframe
|
||||||
|
return frame.__runTests(tests)
|
||||||
|
.do(tests => {
|
||||||
|
tests.forEach(test => {
|
||||||
|
if (typeof test.message === 'string') {
|
||||||
|
proxyLogger.onNext(test.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
.map(updateTests)
|
.map(updateTests)
|
||||||
.concat(postTests);
|
.concat(postTests);
|
||||||
}),
|
}),
|
||||||
result$
|
result
|
||||||
);
|
);
|
||||||
}
|
}
|
@ -1,23 +1,23 @@
|
|||||||
import errSaga from './err-saga';
|
import analyticsSaga from './analytics-saga.js';
|
||||||
import titleSaga from './title-saga';
|
import codeStorageSaga from './code-storage-saga.js';
|
||||||
import hardGoToSaga from './hard-go-to-saga';
|
import errSaga from './err-saga.js';
|
||||||
import windowSaga from './window-saga';
|
import executeChallengeSaga from './build-challenge-epic.js';
|
||||||
import executeChallengeSaga from './execute-challenge-saga';
|
import frameEpic from './frame-epic.js';
|
||||||
import frameSaga from './frame-saga';
|
import hardGoToSaga from './hard-go-to-saga.js';
|
||||||
import codeStorageSaga from './code-storage-saga';
|
import mouseTrapSaga from './mouse-trap-saga.js';
|
||||||
import mouseTrapSaga from './mouse-trap-saga';
|
import nightModeSaga from './night-mode-saga.js';
|
||||||
import analyticsSaga from './analytics-saga';
|
import titleSaga from './title-saga.js';
|
||||||
import nightModeSaga from './night-mode-saga';
|
import windowSaga from './window-saga.js';
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
errSaga,
|
|
||||||
titleSaga,
|
|
||||||
hardGoToSaga,
|
|
||||||
windowSaga,
|
|
||||||
executeChallengeSaga,
|
|
||||||
frameSaga,
|
|
||||||
codeStorageSaga,
|
|
||||||
mouseTrapSaga,
|
|
||||||
analyticsSaga,
|
analyticsSaga,
|
||||||
nightModeSaga
|
codeStorageSaga,
|
||||||
|
errSaga,
|
||||||
|
executeChallengeSaga,
|
||||||
|
frameEpic,
|
||||||
|
hardGoToSaga,
|
||||||
|
mouseTrapSaga,
|
||||||
|
nightModeSaga,
|
||||||
|
titleSaga,
|
||||||
|
windowSaga
|
||||||
];
|
];
|
||||||
|
173
client/utils/build.js
Normal file
173
client/utils/build.js
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
import { Observable } from 'rx';
|
||||||
|
import { getValues } from 'redux-form';
|
||||||
|
|
||||||
|
import { ajax$ } from '../../common/utils/ajax-stream';
|
||||||
|
import throwers from '../rechallenge/throwers';
|
||||||
|
import transformers from '../rechallenge/transformers';
|
||||||
|
import { setExt, updateContents } from '../../common/utils/polyvinyl';
|
||||||
|
|
||||||
|
const consoleReg = /(?:\b)console(\.log\S+)/g;
|
||||||
|
// const sourceReg =
|
||||||
|
// /(<!-- fcc-start-source -->)([\s\S]*?)(?=<!-- fcc-end-source -->)/g;
|
||||||
|
|
||||||
|
// useConsoleLogProxy(source: String) => String
|
||||||
|
export function useConsoleLogProxy(source) {
|
||||||
|
return source.replace(consoleReg, (match, methodCall) => {
|
||||||
|
return 'window.__console' + methodCall;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// createFileStream(files: Dictionary[Path, PolyVinyl]) =>
|
||||||
|
// Observable[...Observable[...PolyVinyl]]
|
||||||
|
export function createFileStream(files = {}) {
|
||||||
|
return Observable.just(
|
||||||
|
Observable.from(Object.keys(files)).map(key => files[key])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const jQuery = {
|
||||||
|
src: 'https://cdnjs.cloudflare.com/ajax/libs/jquery/3.1.1/jquery.min.js'
|
||||||
|
};
|
||||||
|
const globalRequires = [
|
||||||
|
{
|
||||||
|
link: 'https://cdnjs.cloudflare.com/' +
|
||||||
|
'ajax/libs/normalize/4.2.0/normalize.min.css'
|
||||||
|
},
|
||||||
|
jQuery
|
||||||
|
];
|
||||||
|
|
||||||
|
const scriptCache = new Map();
|
||||||
|
export function cacheScript({ src } = {}, crossDomain = true) {
|
||||||
|
if (!src) {
|
||||||
|
throw new Error('No source provided for script');
|
||||||
|
}
|
||||||
|
if (scriptCache.has(src)) {
|
||||||
|
return scriptCache.get(src);
|
||||||
|
}
|
||||||
|
const script$ = ajax$({ url: src, crossDomain })
|
||||||
|
.doOnNext(res => {
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Request errror: ' + res.status);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map(({ response }) => response)
|
||||||
|
.map(script => `<script>${script}</script>`)
|
||||||
|
.shareReplay();
|
||||||
|
|
||||||
|
scriptCache.set(src, script$);
|
||||||
|
return script$;
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkCache = new Map();
|
||||||
|
export function cacheLink({ link } = {}, crossDomain = true) {
|
||||||
|
if (!link) {
|
||||||
|
return Observable.throw(new Error('No source provided for link'));
|
||||||
|
}
|
||||||
|
if (linkCache.has(link)) {
|
||||||
|
return linkCache.get(link);
|
||||||
|
}
|
||||||
|
const link$ = ajax$({ url: link, crossDomain })
|
||||||
|
.doOnNext(res => {
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Request errror: ' + res.status);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map(({ response }) => response)
|
||||||
|
.map(script => `<style>${script}</style>`)
|
||||||
|
.catch(() => Observable.just(''))
|
||||||
|
.shareReplay();
|
||||||
|
|
||||||
|
linkCache.set(link, link$);
|
||||||
|
return link$;
|
||||||
|
}
|
||||||
|
|
||||||
|
const htmlCatch = '\n<!--fcc-->';
|
||||||
|
const jsCatch = '\n;/*fcc*/\n';
|
||||||
|
// we add a cache breaker to prevent browser from caching ajax request
|
||||||
|
const frameRunner = cacheScript({
|
||||||
|
src: `/js/frame-runner.js?cacheBreaker=${Math.random()}` },
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
export function buildClassic(files, required, shouldProxyConsole) {
|
||||||
|
const finalRequires = [...globalRequires, ...required ];
|
||||||
|
return createFileStream(files)
|
||||||
|
::throwers()
|
||||||
|
::transformers()
|
||||||
|
// createbuild
|
||||||
|
.flatMap(file$ => file$.reduce((build, file) => {
|
||||||
|
let finalFile;
|
||||||
|
const finalContents = [
|
||||||
|
file.head,
|
||||||
|
file.contents,
|
||||||
|
file.tail
|
||||||
|
].map(
|
||||||
|
// if shouldProxyConsole then we change instances of console log
|
||||||
|
// to `window.__console.log`
|
||||||
|
// this let's us tap into logging into the console.
|
||||||
|
// currently we only do this to the main window and not the test window
|
||||||
|
source => shouldProxyConsole ? useConsoleLogProxy(source) : source
|
||||||
|
);
|
||||||
|
if (file.ext === 'js') {
|
||||||
|
finalFile = setExt('html', updateContents(
|
||||||
|
`<script>${finalContents.join(jsCatch)}${jsCatch}</script>`,
|
||||||
|
file
|
||||||
|
));
|
||||||
|
} else if (file.ext === 'css') {
|
||||||
|
finalFile = setExt('html', updateContents(
|
||||||
|
`<style>${finalContents.join(htmlCatch)}</style>`,
|
||||||
|
file
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
finalFile = file;
|
||||||
|
}
|
||||||
|
return build + finalFile.contents + htmlCatch;
|
||||||
|
}, ''))
|
||||||
|
// add required scripts and links here
|
||||||
|
.flatMap(source => {
|
||||||
|
const head$ = Observable.from(finalRequires)
|
||||||
|
.flatMap(required => {
|
||||||
|
if (required.src) {
|
||||||
|
return cacheScript(required, required.crossDomain);
|
||||||
|
}
|
||||||
|
// css files with `url(...` may not work in style tags
|
||||||
|
// so we put them in raw links
|
||||||
|
if (required.link && required.raw) {
|
||||||
|
return Observable.just(
|
||||||
|
`<link href=${required.link} rel='stylesheet' />`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (required.link) {
|
||||||
|
return cacheLink(required, required.crossDomain);
|
||||||
|
}
|
||||||
|
return Observable.just('');
|
||||||
|
})
|
||||||
|
.reduce((head, required) => head + required, '')
|
||||||
|
.map(head => `<head>${head}</head>`);
|
||||||
|
|
||||||
|
return Observable.combineLatest(head$, frameRunner)
|
||||||
|
.map(([ head, frameRunner ]) => {
|
||||||
|
const body = `
|
||||||
|
<body style='margin:8px;'>
|
||||||
|
<!-- fcc-start-source -->
|
||||||
|
${source}
|
||||||
|
<!-- fcc-end-source -->
|
||||||
|
</body>`;
|
||||||
|
return {
|
||||||
|
build: head + body + frameRunner,
|
||||||
|
source,
|
||||||
|
head
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBackendChallenge(state) {
|
||||||
|
const { solution: url } = getValues(state.form.BackEndChallenge);
|
||||||
|
return Observable.combineLatest(frameRunner, cacheScript(jQuery))
|
||||||
|
.map(([ frameRunner, jQuery ]) => ({
|
||||||
|
build: jQuery + frameRunner,
|
||||||
|
source: { url },
|
||||||
|
checkChallengePayload: { solution: url }
|
||||||
|
}));
|
||||||
|
}
|
@ -25,7 +25,7 @@ export default class CodeMirrorSkeleton extends PureComponent {
|
|||||||
const {
|
const {
|
||||||
content
|
content
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const editorLines = content.split('\n');
|
const editorLines = (content || '').split('\n');
|
||||||
return (
|
return (
|
||||||
<div className='ReactCodeMirror'>
|
<div className='ReactCodeMirror'>
|
||||||
<div className='CodeMirror cm-s-monokai CodeMirror-wrap'>
|
<div className='CodeMirror cm-s-monokai CodeMirror-wrap'>
|
@ -1,9 +1,8 @@
|
|||||||
import React, { PropTypes } from 'react';
|
import React, { PureComponent, PropTypes } from 'react';
|
||||||
import PureComponent from 'react-pure-render/component';
|
|
||||||
import NoSSR from 'react-no-ssr';
|
import NoSSR from 'react-no-ssr';
|
||||||
import Codemirror from 'react-codemirror';
|
import Codemirror from 'react-codemirror';
|
||||||
|
|
||||||
import CodeMirrorSkeleton from '../skeleton/CodeMirrorSkeleton.jsx';
|
import CodeMirrorSkeleton from './CodeMirrorSkeleton.jsx';
|
||||||
|
|
||||||
const defaultOptions = {
|
const defaultOptions = {
|
||||||
lineNumbers: false,
|
lineNumbers: false,
|
||||||
@ -13,22 +12,24 @@ const defaultOptions = {
|
|||||||
lineWrapping: true
|
lineWrapping: true
|
||||||
};
|
};
|
||||||
|
|
||||||
export default class extends PureComponent {
|
export default class Output extends PureComponent {
|
||||||
static displayName = 'Output';
|
|
||||||
static propTypes = {
|
|
||||||
output: PropTypes.string
|
|
||||||
};
|
|
||||||
render() {
|
render() {
|
||||||
const { output } = this.props;
|
const { output, defaultOutput } = this.props;
|
||||||
return (
|
return (
|
||||||
<div className='challenge-log'>
|
<div className='challenge-log'>
|
||||||
<NoSSR onSSR={ <CodeMirrorSkeleton content={ output } /> }>
|
<NoSSR onSSR={ <CodeMirrorSkeleton content={ output } /> }>
|
||||||
<Codemirror
|
<Codemirror
|
||||||
options={ defaultOptions }
|
options={ defaultOptions }
|
||||||
value={ output }
|
value={ output || defaultOutput }
|
||||||
/>
|
/>
|
||||||
</NoSSR>
|
</NoSSR>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Output.displayName = 'Output';
|
||||||
|
Output.propTypes = {
|
||||||
|
output: PropTypes.string,
|
||||||
|
defaultOutput: PropTypes.string
|
||||||
|
};
|
@ -9,6 +9,7 @@ import Classic from './classic/Classic.jsx';
|
|||||||
import Step from './step/Step.jsx';
|
import Step from './step/Step.jsx';
|
||||||
import Project from './project/Project.jsx';
|
import Project from './project/Project.jsx';
|
||||||
import Video from './video/Video.jsx';
|
import Video from './video/Video.jsx';
|
||||||
|
import BackEnd from './backend/Back-End.jsx';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
fetchChallenge,
|
fetchChallenge,
|
||||||
@ -25,7 +26,8 @@ const views = {
|
|||||||
classic: Classic,
|
classic: Classic,
|
||||||
project: Project,
|
project: Project,
|
||||||
simple: Project,
|
simple: Project,
|
||||||
video: Video
|
video: Video,
|
||||||
|
backend: BackEnd
|
||||||
};
|
};
|
||||||
|
|
||||||
const bindableActions = {
|
const bindableActions = {
|
||||||
|
30
common/app/routes/challenges/components/Solution-Input.jsx
Normal file
30
common/app/routes/challenges/components/Solution-Input.jsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import React, { PropTypes } from 'react';
|
||||||
|
import { HelpBlock, FormGroup, FormControl } from 'react-bootstrap';
|
||||||
|
import { getValidationState, DOMOnlyProps } from '../../../utils/form';
|
||||||
|
|
||||||
|
export default function SolutionInput({ solution, placeholder }) {
|
||||||
|
const validationState = getValidationState(solution);
|
||||||
|
return (
|
||||||
|
<FormGroup
|
||||||
|
controlId='solution'
|
||||||
|
validationState={ validationState }
|
||||||
|
>
|
||||||
|
<FormControl
|
||||||
|
name='solution'
|
||||||
|
placeholder={ placeholder }
|
||||||
|
type='url'
|
||||||
|
{ ...DOMOnlyProps(solution) }
|
||||||
|
/>
|
||||||
|
{
|
||||||
|
validationState === 'error' ?
|
||||||
|
<HelpBlock>Make sure you provide a proper URL.</HelpBlock> :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
</FormGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
SolutionInput.propTypes = {
|
||||||
|
solution: PropTypes.object,
|
||||||
|
placeholder: PropTypes.string
|
||||||
|
};
|
@ -1,14 +1,12 @@
|
|||||||
import React, { PropTypes } from 'react';
|
import React, { PropTypes, PureComponent } from 'react';
|
||||||
import classnames from 'classnames';
|
import classnames from 'classnames';
|
||||||
import PureComponent from 'react-pure-render/component';
|
|
||||||
import { Col, Row } from 'react-bootstrap';
|
import { Col, Row } from 'react-bootstrap';
|
||||||
|
|
||||||
export default class extends PureComponent {
|
const propTypes = {
|
||||||
static displayName = 'TestSuite';
|
tests: PropTypes.arrayOf(PropTypes.object)
|
||||||
static propTypes = {
|
};
|
||||||
tests: PropTypes.arrayOf(PropTypes.object)
|
|
||||||
};
|
|
||||||
|
|
||||||
|
export default class TestSuite extends PureComponent {
|
||||||
renderTests(tests = []) {
|
renderTests(tests = []) {
|
||||||
// err && pass > invalid state
|
// err && pass > invalid state
|
||||||
// err && !pass > failed tests
|
// err && !pass > failed tests
|
||||||
@ -52,3 +50,6 @@ export default class extends PureComponent {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TestSuite.displayName = 'TestSuite';
|
||||||
|
TestSuite.propTypes = propTypes;
|
173
common/app/routes/challenges/components/backend/Back-End.jsx
Normal file
173
common/app/routes/challenges/components/backend/Back-End.jsx
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
import React, { PropTypes, PureComponent } from 'react';
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
import { reduxForm } from 'redux-form';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Col,
|
||||||
|
Row
|
||||||
|
} from 'react-bootstrap';
|
||||||
|
|
||||||
|
import SolutionInput from '../Solution-Input.jsx';
|
||||||
|
import TestSuite from '../Test-Suite.jsx';
|
||||||
|
import Output from '../Output.jsx';
|
||||||
|
import { submitChallenge, executeChallenge } from '../../redux/actions.js';
|
||||||
|
import { challengeSelector } from '../../redux/selectors.js';
|
||||||
|
import { descriptionRegex } from '../../utils.js';
|
||||||
|
import {
|
||||||
|
isValidURL,
|
||||||
|
makeRequired,
|
||||||
|
createFormValidator
|
||||||
|
} from '../../../../utils/form.js';
|
||||||
|
|
||||||
|
const propTypes = {
|
||||||
|
id: PropTypes.string,
|
||||||
|
title: PropTypes.string,
|
||||||
|
description: PropTypes.arrayOf(PropTypes.string),
|
||||||
|
tests: PropTypes.array,
|
||||||
|
output: PropTypes.string,
|
||||||
|
executeChallenge: PropTypes.func.isRequired,
|
||||||
|
submitChallenge: PropTypes.func.isRequired,
|
||||||
|
// provided by redux form
|
||||||
|
submitting: PropTypes.bool,
|
||||||
|
fields: PropTypes.object,
|
||||||
|
resetForm: PropTypes.func.isRequired,
|
||||||
|
handleSubmit: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
const fields = [ 'solution' ];
|
||||||
|
|
||||||
|
const fieldValidators = {
|
||||||
|
solution: makeRequired(isValidURL)
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapStateToProps = createSelector(
|
||||||
|
challengeSelector,
|
||||||
|
state => state.challengesApp.output,
|
||||||
|
state => state.challengesApp.tests,
|
||||||
|
(
|
||||||
|
{
|
||||||
|
challenge: {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
description
|
||||||
|
} = {}
|
||||||
|
},
|
||||||
|
output,
|
||||||
|
tests
|
||||||
|
) => ({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
tests,
|
||||||
|
description,
|
||||||
|
output
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const mapDispatchToActions = {
|
||||||
|
executeChallenge,
|
||||||
|
submitChallenge
|
||||||
|
};
|
||||||
|
|
||||||
|
export class BackEnd extends PureComponent {
|
||||||
|
|
||||||
|
renderDescription(description) {
|
||||||
|
if (!Array.isArray(description)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return description.map((line, index) => {
|
||||||
|
if (descriptionRegex.test(line)) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
dangerouslySetInnerHTML={{ __html: line }}
|
||||||
|
key={ line.slice(-6) + index }
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
className='wrappable'
|
||||||
|
dangerouslySetInnerHTML= {{ __html: line }}
|
||||||
|
key={ line.slice(-6) + index }
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
tests,
|
||||||
|
output,
|
||||||
|
// provided by redux-form
|
||||||
|
fields: { solution },
|
||||||
|
submitting,
|
||||||
|
handleSubmit,
|
||||||
|
executeChallenge
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
const buttonCopy = submitting ?
|
||||||
|
'Submit and go to my next challenge' :
|
||||||
|
"I've completed this challenge";
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Col
|
||||||
|
md={ 6 }
|
||||||
|
mdOffset={ 3 }
|
||||||
|
xs={ 12 }
|
||||||
|
>
|
||||||
|
<Row>
|
||||||
|
<h3>{ title }</h3>
|
||||||
|
{ this.renderDescription(description) }
|
||||||
|
</Row>
|
||||||
|
<Row>
|
||||||
|
<form
|
||||||
|
name='BackEndChallenge'
|
||||||
|
onSubmit={ handleSubmit(executeChallenge) }
|
||||||
|
>
|
||||||
|
<SolutionInput
|
||||||
|
placeholder='https://your-app.com'
|
||||||
|
solution={ solution }
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
block={ true }
|
||||||
|
bsStyle='primary'
|
||||||
|
className='btn-big'
|
||||||
|
onClick={ submitting ? null : null }
|
||||||
|
type={ submitting ? null : 'submit' }
|
||||||
|
>
|
||||||
|
{ buttonCopy } (ctrl + enter)
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Row>
|
||||||
|
<Row>
|
||||||
|
<Output
|
||||||
|
defaultOutput={
|
||||||
|
`/**
|
||||||
|
* Test output will go here
|
||||||
|
*/`
|
||||||
|
}
|
||||||
|
output={ output }
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
<Row>
|
||||||
|
<TestSuite tests={ tests } />
|
||||||
|
</Row>
|
||||||
|
</Col>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BackEnd.displayName = 'BackEnd';
|
||||||
|
BackEnd.propTypes = propTypes;
|
||||||
|
|
||||||
|
export default reduxForm(
|
||||||
|
{
|
||||||
|
form: 'BackEndChallenge',
|
||||||
|
fields,
|
||||||
|
validate: createFormValidator(fieldValidators)
|
||||||
|
},
|
||||||
|
mapStateToProps,
|
||||||
|
mapDispatchToActions
|
||||||
|
)(BackEnd);
|
@ -7,7 +7,7 @@ import Codemirror from 'react-codemirror';
|
|||||||
import NoSSR from 'react-no-ssr';
|
import NoSSR from 'react-no-ssr';
|
||||||
import PureComponent from 'react-pure-render/component';
|
import PureComponent from 'react-pure-render/component';
|
||||||
|
|
||||||
import CodeMirrorSkeleton from '../skeleton/CodeMirrorSkeleton.jsx';
|
import CodeMirrorSkeleton from '../CodeMirrorSkeleton.jsx';
|
||||||
|
|
||||||
const mapStateToProps = createSelector(
|
const mapStateToProps = createSelector(
|
||||||
state => state.app.windowHeight,
|
state => state.app.windowHeight,
|
||||||
|
@ -5,8 +5,8 @@ import { connect } from 'react-redux';
|
|||||||
import PureComponent from 'react-pure-render/component';
|
import PureComponent from 'react-pure-render/component';
|
||||||
import { Col, Row } from 'react-bootstrap';
|
import { Col, Row } from 'react-bootstrap';
|
||||||
|
|
||||||
import TestSuite from './Test-Suite.jsx';
|
import TestSuite from '../Test-Suite.jsx';
|
||||||
import Output from './Output.jsx';
|
import Output from '../Output.jsx';
|
||||||
import ToolPanel from './Tool-Panel.jsx';
|
import ToolPanel from './Tool-Panel.jsx';
|
||||||
import { challengeSelector } from '../../redux/selectors';
|
import { challengeSelector } from '../../redux/selectors';
|
||||||
import {
|
import {
|
||||||
@ -15,6 +15,7 @@ import {
|
|||||||
executeChallenge,
|
executeChallenge,
|
||||||
unlockUntrustedCode
|
unlockUntrustedCode
|
||||||
} from '../../redux/actions';
|
} from '../../redux/actions';
|
||||||
|
import { descriptionRegex } from '../../utils';
|
||||||
import { makeToast } from '../../../../toasts/redux/actions';
|
import { makeToast } from '../../../../toasts/redux/actions';
|
||||||
|
|
||||||
const mapDispatchToProps = {
|
const mapDispatchToProps = {
|
||||||
@ -61,10 +62,6 @@ const mapStateToProps = createSelector(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export class SidePanel extends PureComponent {
|
export class SidePanel extends PureComponent {
|
||||||
constructor(...args) {
|
|
||||||
super(...args);
|
|
||||||
this.descriptionRegex = /\<blockquote|\<ol|\<h4|\<table/;
|
|
||||||
}
|
|
||||||
static displayName = 'SidePanel';
|
static displayName = 'SidePanel';
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
@ -84,7 +81,7 @@ export class SidePanel extends PureComponent {
|
|||||||
updateHint: PropTypes.func
|
updateHint: PropTypes.func
|
||||||
};
|
};
|
||||||
|
|
||||||
renderDescription(description = [ 'Happy Coding!' ], descriptionRegex) {
|
renderDescription(description = [ 'Happy Coding!' ]) {
|
||||||
return description.map((line, index) => {
|
return description.map((line, index) => {
|
||||||
if (descriptionRegex.test(line)) {
|
if (descriptionRegex.test(line)) {
|
||||||
return (
|
return (
|
||||||
@ -146,7 +143,7 @@ export class SidePanel extends PureComponent {
|
|||||||
className='challenge-instructions'
|
className='challenge-instructions'
|
||||||
xs={ 12 }
|
xs={ 12 }
|
||||||
>
|
>
|
||||||
{ this.renderDescription(description, this.descriptionRegex) }
|
{ this.renderDescription(description) }
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
</div>
|
</div>
|
||||||
@ -160,7 +157,16 @@ export class SidePanel extends PureComponent {
|
|||||||
unlockUntrustedCode={ unlockUntrustedCode }
|
unlockUntrustedCode={ unlockUntrustedCode }
|
||||||
updateHint={ updateHint }
|
updateHint={ updateHint }
|
||||||
/>
|
/>
|
||||||
<Output output={ output }/>
|
<Output
|
||||||
|
defaultOutput={
|
||||||
|
`/**
|
||||||
|
* Your output will go here.
|
||||||
|
* Any console.log() statements
|
||||||
|
* will appear in here as well.
|
||||||
|
*/`
|
||||||
|
}
|
||||||
|
output={ output }
|
||||||
|
/>
|
||||||
<br />
|
<br />
|
||||||
<TestSuite tests={ tests } />
|
<TestSuite tests={ tests } />
|
||||||
</div>
|
</div>
|
||||||
|
@ -6,6 +6,7 @@ import {
|
|||||||
FormControl
|
FormControl
|
||||||
} from 'react-bootstrap';
|
} from 'react-bootstrap';
|
||||||
|
|
||||||
|
import SolutionInput from '../Solution-Input.jsx';
|
||||||
import {
|
import {
|
||||||
isValidURL,
|
isValidURL,
|
||||||
makeRequired,
|
makeRequired,
|
||||||
@ -43,27 +44,6 @@ const backEndFieldValidators = {
|
|||||||
githubLink: makeRequired(isValidURL)
|
githubLink: makeRequired(isValidURL)
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SolutionInput({ solution, placeholder }) {
|
|
||||||
return (
|
|
||||||
<FormGroup
|
|
||||||
controlId='solution'
|
|
||||||
validationState={ getValidationState(solution) }
|
|
||||||
>
|
|
||||||
<FormControl
|
|
||||||
name='solution'
|
|
||||||
placeholder={ placeholder }
|
|
||||||
type='url'
|
|
||||||
{ ...solution}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
SolutionInput.propTypes = {
|
|
||||||
solution: PropTypes.object,
|
|
||||||
placeholder: PropTypes.string
|
|
||||||
};
|
|
||||||
|
|
||||||
export function _FrontEndForm({
|
export function _FrontEndForm({
|
||||||
fields,
|
fields,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
|
@ -1,20 +1,21 @@
|
|||||||
import { Observable } from 'rx';
|
import { Observable } from 'rx';
|
||||||
|
|
||||||
import types from './types';
|
import types from './types.js';
|
||||||
import {
|
import {
|
||||||
moveToNextChallenge,
|
moveToNextChallenge,
|
||||||
clearSavedCode
|
clearSavedCode
|
||||||
} from './actions';
|
} from './actions.js';
|
||||||
|
|
||||||
import { challengeSelector } from './selectors';
|
import { challengeSelector } from './selectors.js';
|
||||||
import {
|
import {
|
||||||
createErrorObservable,
|
createErrorObservable,
|
||||||
updateUserPoints,
|
updateUserPoints,
|
||||||
updateUserChallenge
|
updateUserChallenge
|
||||||
} from '../../../redux/actions';
|
} from '../../../redux/actions.js';
|
||||||
import { backEndProject } from '../../../utils/challengeTypes';
|
import { backEndProject } from '../../../utils/challengeTypes.js';
|
||||||
import { makeToast } from '../../../toasts/redux/actions';
|
import { makeToast } from '../../../toasts/redux/actions.js';
|
||||||
import { postJSON$ } from '../../../../utils/ajax-stream';
|
import { postJSON$ } from '../../../../utils/ajax-stream.js';
|
||||||
|
import { ofType } from '../../../../utils/get-actions-of-type.js';
|
||||||
|
|
||||||
function postChallenge(url, username, _csrf, challengeInfo) {
|
function postChallenge(url, username, _csrf, challengeInfo) {
|
||||||
const body = { ...challengeInfo, _csrf };
|
const body = { ...challengeInfo, _csrf };
|
||||||
@ -97,8 +98,42 @@ function submitSimpleChallenge(type, state) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const submitTypes = {
|
function submitBackendChallenge(type, state, { solution }) {
|
||||||
|
const { tests } = state.challengesApp;
|
||||||
|
if (
|
||||||
|
type === types.checkChallenge &&
|
||||||
|
tests.length > 0 &&
|
||||||
|
tests.every(test => test.pass && !test.err)
|
||||||
|
) {
|
||||||
|
/*
|
||||||
|
return Observable.of(
|
||||||
|
makeToast({
|
||||||
|
message: `${randomCompliment()} Go to your next challenge.`,
|
||||||
|
action: 'Submit',
|
||||||
|
actionCreator: 'submitChallenge',
|
||||||
|
timeout: 10000
|
||||||
|
})
|
||||||
|
);
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { challenge: { id } } = challengeSelector(state);
|
||||||
|
const { app: { user, csrfToken } } = state;
|
||||||
|
const challengeInfo = { id, solution };
|
||||||
|
return postChallenge(
|
||||||
|
'/backend-challenge-completed',
|
||||||
|
user,
|
||||||
|
csrfToken,
|
||||||
|
challengeInfo
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Observable.just(
|
||||||
|
makeToast({ message: 'Keep trying.' })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitters = {
|
||||||
tests: submitModern,
|
tests: submitModern,
|
||||||
|
backend: submitBackendChallenge,
|
||||||
step: submitSimpleChallenge,
|
step: submitSimpleChallenge,
|
||||||
video: submitSimpleChallenge,
|
video: submitSimpleChallenge,
|
||||||
'project.frontEnd': submitProject,
|
'project.frontEnd': submitProject,
|
||||||
@ -108,14 +143,11 @@ const submitTypes = {
|
|||||||
|
|
||||||
export default function completionSaga(actions$, getState) {
|
export default function completionSaga(actions$, getState) {
|
||||||
return actions$
|
return actions$
|
||||||
.filter(({ type }) => (
|
::ofType(types.checkChallenge, types.submitChallenge)
|
||||||
type === types.checkChallenge ||
|
|
||||||
type === types.submitChallenge
|
|
||||||
))
|
|
||||||
.flatMap(({ type, payload }) => {
|
.flatMap(({ type, payload }) => {
|
||||||
const state = getState();
|
const state = getState();
|
||||||
const { submitType } = challengeSelector(state);
|
const { submitType } = challengeSelector(state);
|
||||||
const submitter = submitTypes[submitType] ||
|
const submitter = submitters[submitType] ||
|
||||||
(() => Observable.just(null));
|
(() => Observable.just(null));
|
||||||
return submitter(type, state, payload);
|
return submitter(type, state, payload);
|
||||||
});
|
});
|
||||||
|
@ -24,13 +24,7 @@ const initialUiState = {
|
|||||||
isLightBoxOpen: false,
|
isLightBoxOpen: false,
|
||||||
// project is ready to submit
|
// project is ready to submit
|
||||||
isSubmitting: false,
|
isSubmitting: false,
|
||||||
output: `/**
|
output: null,
|
||||||
* Your output will go here.
|
|
||||||
* Any console.log() - type
|
|
||||||
* statements will appear in
|
|
||||||
* your browser\'s DevTools
|
|
||||||
* JavaScript console as well.
|
|
||||||
*/`,
|
|
||||||
// video
|
// video
|
||||||
// 1 indexed
|
// 1 indexed
|
||||||
currentQuestion: 1,
|
currentQuestion: 1,
|
||||||
|
@ -1,39 +1,8 @@
|
|||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
|
|
||||||
import * as challengeTypes from '../../../utils/challengeTypes';
|
import { viewTypes, submitTypes, getNode } from '../utils';
|
||||||
import { getNode } from '../utils';
|
|
||||||
import blockNameify from '../../../utils/blockNameify';
|
import blockNameify from '../../../utils/blockNameify';
|
||||||
|
import { html } from '../../../utils/challengeTypes';
|
||||||
const viewTypes = {
|
|
||||||
[ challengeTypes.html]: 'classic',
|
|
||||||
[ challengeTypes.js ]: 'classic',
|
|
||||||
[ challengeTypes.bonfire ]: 'classic',
|
|
||||||
[ challengeTypes.frontEndProject]: 'project',
|
|
||||||
[ challengeTypes.backEndProject]: 'project',
|
|
||||||
// might not be used anymore
|
|
||||||
[ challengeTypes.simpleProject]: 'project',
|
|
||||||
// formally hikes
|
|
||||||
[ challengeTypes.video ]: 'video',
|
|
||||||
[ challengeTypes.step ]: 'step'
|
|
||||||
};
|
|
||||||
|
|
||||||
const submitTypes = {
|
|
||||||
[ challengeTypes.html ]: 'tests',
|
|
||||||
[ challengeTypes.js ]: 'tests',
|
|
||||||
[ challengeTypes.bonfire ]: 'tests',
|
|
||||||
// requires just a button press
|
|
||||||
[ challengeTypes.simpleProject ]: 'project.simple',
|
|
||||||
// requires just a single url
|
|
||||||
// like codepen.com/my-project
|
|
||||||
[ challengeTypes.frontEndProject ]: 'project.frontEnd',
|
|
||||||
// requires two urls
|
|
||||||
// a hosted URL where the app is running live
|
|
||||||
// project code url like GitHub
|
|
||||||
[ challengeTypes.backEndProject ]: 'project.backEnd',
|
|
||||||
// formally hikes
|
|
||||||
[ challengeTypes.video ]: 'video',
|
|
||||||
[ challengeTypes.step ]: 'step'
|
|
||||||
};
|
|
||||||
|
|
||||||
export const challengeSelector = createSelector(
|
export const challengeSelector = createSelector(
|
||||||
state => state.challengesApp.challenge,
|
state => state.challengesApp.challenge,
|
||||||
@ -44,6 +13,8 @@ export const challengeSelector = createSelector(
|
|||||||
}
|
}
|
||||||
const challenge = challengeMap[challengeName];
|
const challenge = challengeMap[challengeName];
|
||||||
const challengeType = challenge && challenge.challengeType;
|
const challengeType = challenge && challenge.challengeType;
|
||||||
|
const type = challenge && challenge.type;
|
||||||
|
const viewType = viewTypes[type] || viewTypes[challengeType] || 'classic';
|
||||||
const blockName = blockNameify(challenge.block);
|
const blockName = blockNameify(challenge.block);
|
||||||
const title = blockName && challenge.title ?
|
const title = blockName && challenge.title ?
|
||||||
`${blockName}: ${challenge.title}` :
|
`${blockName}: ${challenge.title}` :
|
||||||
@ -51,10 +22,13 @@ export const challengeSelector = createSelector(
|
|||||||
return {
|
return {
|
||||||
challenge,
|
challenge,
|
||||||
title,
|
title,
|
||||||
viewType: viewTypes[challengeType] || 'classic',
|
viewType,
|
||||||
submitType: submitTypes[challengeType] || 'tests',
|
submitType:
|
||||||
showPreview: challengeType === challengeTypes.html,
|
submitTypes[challengeType] ||
|
||||||
mode: challenge && challengeType === challengeTypes.html ?
|
submitTypes[challenge && challenge.type] ||
|
||||||
|
'tests',
|
||||||
|
showPreview: challengeType === html,
|
||||||
|
mode: challenge && challengeType === html ?
|
||||||
'text/html' :
|
'text/html' :
|
||||||
'javascript'
|
'javascript'
|
||||||
};
|
};
|
||||||
|
@ -1,7 +1,46 @@
|
|||||||
import flow from 'lodash/flow';
|
import flow from 'lodash/flow';
|
||||||
import { bonfire, html, js } from '../../utils/challengeTypes';
|
import * as challengeTypes from '../../utils/challengeTypes';
|
||||||
import { decodeScriptTags } from '../../../utils/encode-decode';
|
|
||||||
import protect from '../../utils/empty-protector';
|
import protect from '../../utils/empty-protector';
|
||||||
|
import { decodeScriptTags } from '../../../utils/encode-decode';
|
||||||
|
|
||||||
|
// determine the component to view for each challenge
|
||||||
|
export const viewTypes = {
|
||||||
|
[ challengeTypes.html ]: 'classic',
|
||||||
|
[ challengeTypes.js ]: 'classic',
|
||||||
|
[ challengeTypes.bonfire ]: 'classic',
|
||||||
|
[ challengeTypes.frontEndProject ]: 'project',
|
||||||
|
[ challengeTypes.backEndProject ]: 'project',
|
||||||
|
// might not be used anymore
|
||||||
|
[ challengeTypes.simpleProject ]: 'project',
|
||||||
|
// formally hikes
|
||||||
|
[ challengeTypes.video ]: 'video',
|
||||||
|
[ challengeTypes.step ]: 'step',
|
||||||
|
backend: 'backend'
|
||||||
|
};
|
||||||
|
|
||||||
|
// determine the type of submit function to use for the challenge on completion
|
||||||
|
export const submitTypes = {
|
||||||
|
[ challengeTypes.html ]: 'tests',
|
||||||
|
[ challengeTypes.js ]: 'tests',
|
||||||
|
[ challengeTypes.bonfire ]: 'tests',
|
||||||
|
// requires just a button press
|
||||||
|
[ challengeTypes.simpleProject ]: 'project.simple',
|
||||||
|
// requires just a single url
|
||||||
|
// like codepen.com/my-project
|
||||||
|
[ challengeTypes.frontEndProject ]: 'project.frontEnd',
|
||||||
|
// requires two urls
|
||||||
|
// a hosted URL where the app is running live
|
||||||
|
// project code url like GitHub
|
||||||
|
[ challengeTypes.backEndProject ]: 'project.backEnd',
|
||||||
|
// formally hikes
|
||||||
|
[ challengeTypes.video ]: 'video',
|
||||||
|
[ challengeTypes.step ]: 'step',
|
||||||
|
backend: 'backend'
|
||||||
|
};
|
||||||
|
|
||||||
|
// determines if a line in a challenge description
|
||||||
|
// has html that should be rendered
|
||||||
|
export const descriptionRegex = /\<blockquote|\<ol|\<h4|\<table/;
|
||||||
|
|
||||||
export function arrayToString(seedData = ['']) {
|
export function arrayToString(seedData = ['']) {
|
||||||
seedData = Array.isArray(seedData) ? seedData : [seedData];
|
seedData = Array.isArray(seedData) ? seedData : [seedData];
|
||||||
@ -16,9 +55,9 @@ export function buildSeed({ challengeSeed = [] } = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pathsMap = {
|
const pathsMap = {
|
||||||
[html]: 'html',
|
[ challengeTypes.html ]: 'html',
|
||||||
[js]: 'js',
|
[ challengeTypes.js ]: 'js',
|
||||||
[bonfire]: 'js'
|
[ challengeTypes.bonfire ]: 'js'
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getPreFile({ challengeType }) {
|
export function getPreFile({ challengeType }) {
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import omit from 'lodash/omit';
|
||||||
import normalizeUrl from 'normalize-url';
|
import normalizeUrl from 'normalize-url';
|
||||||
import { isURL } from 'validator';
|
import { isURL } from 'validator';
|
||||||
|
|
||||||
@ -68,3 +69,22 @@ export function getValidationState(field) {
|
|||||||
'error' :
|
'error' :
|
||||||
'success';
|
'success';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// this should filter out none-dom props to silence React warnings
|
||||||
|
export function DOMOnlyProps(field) {
|
||||||
|
return omit(field, [
|
||||||
|
'initialValue',
|
||||||
|
'autofill',
|
||||||
|
'autocompleted',
|
||||||
|
'onUpdate',
|
||||||
|
'valid',
|
||||||
|
'invalid',
|
||||||
|
'dirty',
|
||||||
|
'pristine',
|
||||||
|
'active',
|
||||||
|
'touched',
|
||||||
|
'visited',
|
||||||
|
'autofilled',
|
||||||
|
'error'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
@ -422,15 +422,9 @@ var watchDependents = [
|
|||||||
'dev-server'
|
'dev-server'
|
||||||
];
|
];
|
||||||
|
|
||||||
gulp.task('reload', function() {
|
|
||||||
notify({ message: 'test changed' });
|
|
||||||
reload();
|
|
||||||
});
|
|
||||||
|
|
||||||
gulp.task('watch', watchDependents, function() {
|
gulp.task('watch', watchDependents, function() {
|
||||||
gulp.watch(paths.lessFiles, ['less']);
|
gulp.watch(paths.lessFiles, ['less']);
|
||||||
gulp.watch(paths.js.concat(paths.vendorChallenges), ['js']);
|
gulp.watch(paths.js.concat(paths.vendorChallenges), ['js']);
|
||||||
gulp.watch(paths.challenges, ['test-challenges', 'reload']);
|
|
||||||
gulp.watch(paths.js, ['js']);
|
gulp.watch(paths.js, ['js']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -4,6 +4,29 @@
|
|||||||
"time": "150 hours",
|
"time": "150 hours",
|
||||||
"helpRoom": "HelpBackend",
|
"helpRoom": "HelpBackend",
|
||||||
"challenges": [
|
"challenges": [
|
||||||
|
{
|
||||||
|
"id": "57ed709d334ad35e8fe79acb",
|
||||||
|
"title": "New Backend Format",
|
||||||
|
"isBeta": "true",
|
||||||
|
"meta": "This is just a test",
|
||||||
|
"description": [
|
||||||
|
"This is just a test of the new backend challenge test framework"
|
||||||
|
],
|
||||||
|
"tests": [{
|
||||||
|
"text": "website should return 200",
|
||||||
|
"testString": "getUserInput => $.ajax({ url: getUserInput('url'), method: 'HEAD' }).then(null, (err) => assert.fail(err));"
|
||||||
|
}, {
|
||||||
|
"text": "package.json should have a valid \"keywords\" key",
|
||||||
|
"testString": "getUserInput => ($.get(getUserInput('url') + '/_api/package.json').then(function(data){ var packJson = JSON.parse(data); assert(packJson.keywords); }, err => { throw new Error('Err: ' + err.statusText);}))"
|
||||||
|
}, {
|
||||||
|
"text": "\"keywords\" field should be an Array",
|
||||||
|
"testString": "getUserInput => ($.get(getUserInput('url') + '/_api/package.json').then(function(data){ var packJson = JSON.parse(data); assert.isArray(packJson.keywords); }, err => { throw new Error('Err: ' + err.statusText);}))"
|
||||||
|
}, {
|
||||||
|
"text": "\"keywords\" should include \"freecodecamp\"",
|
||||||
|
"testString": "getUserInput => ($.get(getUserInput('url') + '/_api/package.json').then(function(data){ var packJson = JSON.parse(data); assert.include(packJson.keywords, 'freecodecamp'); }, err => { throw new Error('Err: ' + err.statusText); }))"
|
||||||
|
}],
|
||||||
|
"type": "backend"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "bd7158d8c443edefaeb5bdef",
|
"id": "bd7158d8c443edefaeb5bdef",
|
||||||
"title": "Timestamp Microservice",
|
"title": "Timestamp Microservice",
|
||||||
|
@ -110,6 +110,12 @@ export default function(app) {
|
|||||||
projectCompleted
|
projectCompleted
|
||||||
);
|
);
|
||||||
|
|
||||||
|
api.post(
|
||||||
|
'/backend-challenge-completed',
|
||||||
|
send200toNonUser,
|
||||||
|
backendChallengeCompleted
|
||||||
|
);
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/challenges/current-challenge',
|
'/challenges/current-challenge',
|
||||||
redirectToCurrentChallenge
|
redirectToCurrentChallenge
|
||||||
@ -287,6 +293,55 @@ export default function(app) {
|
|||||||
.subscribe(() => {}, next);
|
.subscribe(() => {}, next);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function backendChallengeCompleted(req, res, next) {
|
||||||
|
const type = accepts(req).type('html', 'json', 'text');
|
||||||
|
req.checkBody('id', 'id must be an ObjectId').isMongoId();
|
||||||
|
req.checkBody('solution', 'solution must be a URL').isURL();
|
||||||
|
|
||||||
|
const errors = req.validationErrors(true);
|
||||||
|
|
||||||
|
if (errors) {
|
||||||
|
if (type === 'json') {
|
||||||
|
return res.status(403).send({ errors });
|
||||||
|
}
|
||||||
|
log('errors', errors);
|
||||||
|
return res.sendStatus(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user, body = {} } = req;
|
||||||
|
|
||||||
|
const completedChallenge = _.pick(
|
||||||
|
body,
|
||||||
|
[ 'id', 'solution' ]
|
||||||
|
);
|
||||||
|
completedChallenge.completedDate = Date.now();
|
||||||
|
|
||||||
|
|
||||||
|
return user.getChallengeMap$()
|
||||||
|
.flatMap(() => {
|
||||||
|
const {
|
||||||
|
alreadyCompleted,
|
||||||
|
updateData,
|
||||||
|
lastUpdated
|
||||||
|
} = buildUserUpdate(user, completedChallenge.id, completedChallenge);
|
||||||
|
|
||||||
|
return user.update$(updateData)
|
||||||
|
.doOnNext(({ count }) => log('%s documents updated', count))
|
||||||
|
.doOnNext(() => {
|
||||||
|
if (type === 'json') {
|
||||||
|
return res.send({
|
||||||
|
alreadyCompleted,
|
||||||
|
points: alreadyCompleted ? user.points : user.points + 1,
|
||||||
|
completedDate: completedChallenge.completedDate,
|
||||||
|
lastUpdated
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return res.status(200).send(true);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.subscribe(() => {}, next);
|
||||||
|
}
|
||||||
|
|
||||||
function redirectToCurrentChallenge(req, res, next) {
|
function redirectToCurrentChallenge(req, res, next) {
|
||||||
const { user } = req;
|
const { user } = req;
|
||||||
return map$
|
return map$
|
||||||
|
@ -18,6 +18,12 @@ export default function csp() {
|
|||||||
'*.cloudflare.com',
|
'*.cloudflare.com',
|
||||||
'https://*.optimizely.com'
|
'https://*.optimizely.com'
|
||||||
]),
|
]),
|
||||||
|
connectSrc: trusted.concat([
|
||||||
|
'https://gomix.com',
|
||||||
|
'https://*.gomix.com',
|
||||||
|
'https://*.gomix.me',
|
||||||
|
'https://*.cloudflare.com'
|
||||||
|
]),
|
||||||
scriptSrc: [
|
scriptSrc: [
|
||||||
"'unsafe-eval'",
|
"'unsafe-eval'",
|
||||||
"'unsafe-inline'",
|
"'unsafe-inline'",
|
||||||
|
Reference in New Issue
Block a user