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-vars": 2,
|
||||
"no-use-before-define": 0,
|
||||
"no-void": 2,
|
||||
"no-void": 0,
|
||||
"no-warning-comments": [ 2, { "terms": [ "fixme" ], "location": "start" } ],
|
||||
"no-with": 2,
|
||||
"one-var": 0,
|
||||
|
@ -1,11 +1,14 @@
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var testTimeout = 5000;
|
||||
var common = parent.__common;
|
||||
var frameId = window.__frameId;
|
||||
var frameReady = common[frameId + 'Ready$'] || { onNext() {} };
|
||||
var frameReady = common[frameId + 'Ready'] || { onNext() {} };
|
||||
var Rx = document.Rx;
|
||||
var helpers = Rx.helpers;
|
||||
var chai = parent.chai;
|
||||
var source = document.__source;
|
||||
var __getUserInput = document.__getUserInput || (x => x);
|
||||
var checkChallengePayload = document.__checkChallengePayload;
|
||||
|
||||
document.__getJsOutput = function getJsOutput() {
|
||||
if (window.__err || !common.shouldRun()) {
|
||||
@ -23,13 +26,23 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
return output;
|
||||
};
|
||||
|
||||
document.__runTests$ = function runTests$(tests = []) {
|
||||
document.__runTests = function runTests(tests = []) {
|
||||
/* eslint-disable no-unused-vars */
|
||||
const editor = { getValue() { return source; } };
|
||||
const code = source;
|
||||
/* eslint-enable no-unused-vars */
|
||||
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
|
||||
@ -40,7 +53,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
/* eslint-disable no-unused-vars */
|
||||
.flatMap(({ text, testString }) => {
|
||||
const assert = chai.assert;
|
||||
/* eslint-enable no-unused-vars */
|
||||
const getUserInput = __getUserInput;
|
||||
/* eslint-enable no-unused-vars */
|
||||
const newTest = { text, testString };
|
||||
let test;
|
||||
let __result;
|
||||
@ -57,18 +71,18 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
// the function could expect a callback
|
||||
// or it could return a promise/observable
|
||||
// or it could still be sync
|
||||
if (test.length === 0) {
|
||||
if (test.length === 1) {
|
||||
// a function with length 0 means it expects 0 args
|
||||
// We call it and store the result
|
||||
// This result may be a promise or an observable or undefined
|
||||
__result = test();
|
||||
__result = test(getUserInput);
|
||||
} else {
|
||||
// if function takes arguments
|
||||
// we expect it to be of the form
|
||||
// function(cb) { /* ... */ }
|
||||
// and callback has the following signature
|
||||
// function(err) { /* ... */ }
|
||||
__result = Rx.Observable.fromNodeCallback(test)();
|
||||
__result = Rx.Observable.fromNodeCallback(test)(getUserInput);
|
||||
}
|
||||
|
||||
if (helpers.isPromise(__result)) {
|
||||
@ -86,6 +100,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
__result = Rx.Observable.throw(e);
|
||||
}
|
||||
return __result
|
||||
.timeout(testTimeout)
|
||||
.map(() => {
|
||||
// we don't need the result of a promise/observable/cb here
|
||||
// all data asserts should happen further up the chain
|
||||
@ -96,7 +111,15 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
.catch(err => {
|
||||
// we catch the error here to prevent the error from bubbling up
|
||||
// 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.stack = err.stack;
|
||||
newTest.message = message;
|
||||
// RxJS catch expects an observable as a return
|
||||
return Rx.Observable.of(newTest);
|
||||
});
|
||||
@ -106,5 +129,5 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
};
|
||||
|
||||
// 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
|
||||
} 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';
|
||||
// the test frame is responsible for running the assert tests
|
||||
const testId = 'fcc-test-frame';
|
||||
|
||||
const createHeader = (id = mainId) => `
|
||||
<script>
|
||||
window.__frameId = '${id}';
|
||||
window.onerror = function(msg, url, ln, col, err) {
|
||||
window.__err = err;
|
||||
return true;
|
||||
};
|
||||
</script>
|
||||
`;
|
||||
|
||||
@ -46,78 +53,79 @@ function getFrameDocument(document, id = mainId) {
|
||||
};
|
||||
}
|
||||
|
||||
const consoleReg = /(?:\b)console(\.log\S+)/g;
|
||||
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$) {
|
||||
function buildProxyConsole(window, proxyLogger) {
|
||||
const oldLog = window.console.log.bind(console);
|
||||
window.__console = {};
|
||||
window.__console.log = function proxyConsole(...args) {
|
||||
proxyLogger$.onNext(args);
|
||||
proxyLogger.onNext(args);
|
||||
return oldLog(...args);
|
||||
};
|
||||
}
|
||||
|
||||
function frameMain({ build, source } = {}, document, proxyLogger$) {
|
||||
function frameMain({ build } = {}, document, proxyLogger) {
|
||||
const { frame: main, frameWindow } = getFrameDocument(document);
|
||||
refreshFrame(main);
|
||||
buildProxyConsole(frameWindow, proxyLogger$);
|
||||
buildProxyConsole(frameWindow, proxyLogger);
|
||||
main.Rx = Rx;
|
||||
main.open();
|
||||
main.write(createHeader() + proxyConsole(build, source));
|
||||
main.write(createHeader() + build);
|
||||
main.close();
|
||||
}
|
||||
|
||||
function frameTests({ build, source } = {}, document) {
|
||||
function frameTests({ build, source, checkChallengePayload } = {}, document) {
|
||||
const { frame: tests } = getFrameDocument(document, testId);
|
||||
refreshFrame(tests);
|
||||
tests.Rx = Rx;
|
||||
tests.__source = source;
|
||||
tests.__getUserInput = key => source[key];
|
||||
tests.__checkChallengePayload = checkChallengePayload;
|
||||
tests.open();
|
||||
tests.write(createHeader(testId) + build);
|
||||
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.shouldRun = () => true;
|
||||
const proxyLogger$ = new Subject();
|
||||
const runTests$ = window.__common[testId + 'Ready$'] =
|
||||
new Subject();
|
||||
const result$ = actions$::ofType(
|
||||
types.frameMain,
|
||||
types.frameTests,
|
||||
types.frameOutput
|
||||
)
|
||||
// this will proxy console.log calls
|
||||
const proxyLogger = new Subject();
|
||||
// frameReady will let us know when the test iframe is ready to run
|
||||
const frameReady = window.__common[testId + 'Ready'] = new Subject();
|
||||
const result = actions
|
||||
::ofType(types.frameMain, types.frameTests)
|
||||
// if isCodeLocked is true do not frame user code
|
||||
.filter(() => !getState().challengesApp.isCodeLocked)
|
||||
.map(action => {
|
||||
if (action.type === types.frameMain) {
|
||||
return frameMain(action.payload, document, proxyLogger$);
|
||||
return frameMain(action.payload, document, proxyLogger);
|
||||
}
|
||||
return frameTests(action.payload, document);
|
||||
});
|
||||
})
|
||||
.ignoreElements();
|
||||
|
||||
return Observable.merge(
|
||||
proxyLogger$.map(updateOutput),
|
||||
runTests$.flatMap(() => {
|
||||
proxyLogger.map(updateOutput),
|
||||
frameReady.flatMap(({ checkChallengePayload }) => {
|
||||
const { frame } = getFrameDocument(document, testId);
|
||||
const { tests } = getState().challengesApp;
|
||||
const postTests = Observable.of(
|
||||
updateOutput('// tests completed'),
|
||||
checkChallenge()
|
||||
checkChallenge(checkChallengePayload)
|
||||
).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)
|
||||
.concat(postTests);
|
||||
}),
|
||||
result$
|
||||
result
|
||||
);
|
||||
}
|
@ -1,23 +1,23 @@
|
||||
import errSaga from './err-saga';
|
||||
import titleSaga from './title-saga';
|
||||
import hardGoToSaga from './hard-go-to-saga';
|
||||
import windowSaga from './window-saga';
|
||||
import executeChallengeSaga from './execute-challenge-saga';
|
||||
import frameSaga from './frame-saga';
|
||||
import codeStorageSaga from './code-storage-saga';
|
||||
import mouseTrapSaga from './mouse-trap-saga';
|
||||
import analyticsSaga from './analytics-saga';
|
||||
import nightModeSaga from './night-mode-saga';
|
||||
import analyticsSaga from './analytics-saga.js';
|
||||
import codeStorageSaga from './code-storage-saga.js';
|
||||
import errSaga from './err-saga.js';
|
||||
import executeChallengeSaga from './build-challenge-epic.js';
|
||||
import frameEpic from './frame-epic.js';
|
||||
import hardGoToSaga from './hard-go-to-saga.js';
|
||||
import mouseTrapSaga from './mouse-trap-saga.js';
|
||||
import nightModeSaga from './night-mode-saga.js';
|
||||
import titleSaga from './title-saga.js';
|
||||
import windowSaga from './window-saga.js';
|
||||
|
||||
export default [
|
||||
errSaga,
|
||||
titleSaga,
|
||||
hardGoToSaga,
|
||||
windowSaga,
|
||||
executeChallengeSaga,
|
||||
frameSaga,
|
||||
codeStorageSaga,
|
||||
mouseTrapSaga,
|
||||
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 {
|
||||
content
|
||||
} = this.props;
|
||||
const editorLines = content.split('\n');
|
||||
const editorLines = (content || '').split('\n');
|
||||
return (
|
||||
<div className='ReactCodeMirror'>
|
||||
<div className='CodeMirror cm-s-monokai CodeMirror-wrap'>
|
@ -1,9 +1,8 @@
|
||||
import React, { PropTypes } from 'react';
|
||||
import PureComponent from 'react-pure-render/component';
|
||||
import React, { PureComponent, PropTypes } from 'react';
|
||||
import NoSSR from 'react-no-ssr';
|
||||
import Codemirror from 'react-codemirror';
|
||||
|
||||
import CodeMirrorSkeleton from '../skeleton/CodeMirrorSkeleton.jsx';
|
||||
import CodeMirrorSkeleton from './CodeMirrorSkeleton.jsx';
|
||||
|
||||
const defaultOptions = {
|
||||
lineNumbers: false,
|
||||
@ -13,22 +12,24 @@ const defaultOptions = {
|
||||
lineWrapping: true
|
||||
};
|
||||
|
||||
export default class extends PureComponent {
|
||||
static displayName = 'Output';
|
||||
static propTypes = {
|
||||
output: PropTypes.string
|
||||
};
|
||||
export default class Output extends PureComponent {
|
||||
render() {
|
||||
const { output } = this.props;
|
||||
const { output, defaultOutput } = this.props;
|
||||
return (
|
||||
<div className='challenge-log'>
|
||||
<NoSSR onSSR={ <CodeMirrorSkeleton content={ output } /> }>
|
||||
<Codemirror
|
||||
options={ defaultOptions }
|
||||
value={ output }
|
||||
value={ output || defaultOutput }
|
||||
/>
|
||||
</NoSSR>
|
||||
</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 Project from './project/Project.jsx';
|
||||
import Video from './video/Video.jsx';
|
||||
import BackEnd from './backend/Back-End.jsx';
|
||||
|
||||
import {
|
||||
fetchChallenge,
|
||||
@ -25,7 +26,8 @@ const views = {
|
||||
classic: Classic,
|
||||
project: Project,
|
||||
simple: Project,
|
||||
video: Video
|
||||
video: Video,
|
||||
backend: BackEnd
|
||||
};
|
||||
|
||||
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 PureComponent from 'react-pure-render/component';
|
||||
import { Col, Row } from 'react-bootstrap';
|
||||
|
||||
export default class extends PureComponent {
|
||||
static displayName = 'TestSuite';
|
||||
static propTypes = {
|
||||
tests: PropTypes.arrayOf(PropTypes.object)
|
||||
};
|
||||
const propTypes = {
|
||||
tests: PropTypes.arrayOf(PropTypes.object)
|
||||
};
|
||||
|
||||
export default class TestSuite extends PureComponent {
|
||||
renderTests(tests = []) {
|
||||
// err && pass > invalid state
|
||||
// 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 PureComponent from 'react-pure-render/component';
|
||||
|
||||
import CodeMirrorSkeleton from '../skeleton/CodeMirrorSkeleton.jsx';
|
||||
import CodeMirrorSkeleton from '../CodeMirrorSkeleton.jsx';
|
||||
|
||||
const mapStateToProps = createSelector(
|
||||
state => state.app.windowHeight,
|
||||
|
@ -5,8 +5,8 @@ import { connect } from 'react-redux';
|
||||
import PureComponent from 'react-pure-render/component';
|
||||
import { Col, Row } from 'react-bootstrap';
|
||||
|
||||
import TestSuite from './Test-Suite.jsx';
|
||||
import Output from './Output.jsx';
|
||||
import TestSuite from '../Test-Suite.jsx';
|
||||
import Output from '../Output.jsx';
|
||||
import ToolPanel from './Tool-Panel.jsx';
|
||||
import { challengeSelector } from '../../redux/selectors';
|
||||
import {
|
||||
@ -15,6 +15,7 @@ import {
|
||||
executeChallenge,
|
||||
unlockUntrustedCode
|
||||
} from '../../redux/actions';
|
||||
import { descriptionRegex } from '../../utils';
|
||||
import { makeToast } from '../../../../toasts/redux/actions';
|
||||
|
||||
const mapDispatchToProps = {
|
||||
@ -61,10 +62,6 @@ const mapStateToProps = createSelector(
|
||||
);
|
||||
|
||||
export class SidePanel extends PureComponent {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
this.descriptionRegex = /\<blockquote|\<ol|\<h4|\<table/;
|
||||
}
|
||||
static displayName = 'SidePanel';
|
||||
|
||||
static propTypes = {
|
||||
@ -84,7 +81,7 @@ export class SidePanel extends PureComponent {
|
||||
updateHint: PropTypes.func
|
||||
};
|
||||
|
||||
renderDescription(description = [ 'Happy Coding!' ], descriptionRegex) {
|
||||
renderDescription(description = [ 'Happy Coding!' ]) {
|
||||
return description.map((line, index) => {
|
||||
if (descriptionRegex.test(line)) {
|
||||
return (
|
||||
@ -146,7 +143,7 @@ export class SidePanel extends PureComponent {
|
||||
className='challenge-instructions'
|
||||
xs={ 12 }
|
||||
>
|
||||
{ this.renderDescription(description, this.descriptionRegex) }
|
||||
{ this.renderDescription(description) }
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
@ -160,7 +157,16 @@ export class SidePanel extends PureComponent {
|
||||
unlockUntrustedCode={ unlockUntrustedCode }
|
||||
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 />
|
||||
<TestSuite tests={ tests } />
|
||||
</div>
|
||||
|
@ -6,6 +6,7 @@ import {
|
||||
FormControl
|
||||
} from 'react-bootstrap';
|
||||
|
||||
import SolutionInput from '../Solution-Input.jsx';
|
||||
import {
|
||||
isValidURL,
|
||||
makeRequired,
|
||||
@ -43,27 +44,6 @@ const backEndFieldValidators = {
|
||||
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({
|
||||
fields,
|
||||
handleSubmit,
|
||||
|
@ -1,20 +1,21 @@
|
||||
import { Observable } from 'rx';
|
||||
|
||||
import types from './types';
|
||||
import types from './types.js';
|
||||
import {
|
||||
moveToNextChallenge,
|
||||
clearSavedCode
|
||||
} from './actions';
|
||||
} from './actions.js';
|
||||
|
||||
import { challengeSelector } from './selectors';
|
||||
import { challengeSelector } from './selectors.js';
|
||||
import {
|
||||
createErrorObservable,
|
||||
updateUserPoints,
|
||||
updateUserChallenge
|
||||
} from '../../../redux/actions';
|
||||
import { backEndProject } from '../../../utils/challengeTypes';
|
||||
import { makeToast } from '../../../toasts/redux/actions';
|
||||
import { postJSON$ } from '../../../../utils/ajax-stream';
|
||||
} from '../../../redux/actions.js';
|
||||
import { backEndProject } from '../../../utils/challengeTypes.js';
|
||||
import { makeToast } from '../../../toasts/redux/actions.js';
|
||||
import { postJSON$ } from '../../../../utils/ajax-stream.js';
|
||||
import { ofType } from '../../../../utils/get-actions-of-type.js';
|
||||
|
||||
function postChallenge(url, username, _csrf, challengeInfo) {
|
||||
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,
|
||||
backend: submitBackendChallenge,
|
||||
step: submitSimpleChallenge,
|
||||
video: submitSimpleChallenge,
|
||||
'project.frontEnd': submitProject,
|
||||
@ -108,14 +143,11 @@ const submitTypes = {
|
||||
|
||||
export default function completionSaga(actions$, getState) {
|
||||
return actions$
|
||||
.filter(({ type }) => (
|
||||
type === types.checkChallenge ||
|
||||
type === types.submitChallenge
|
||||
))
|
||||
::ofType(types.checkChallenge, types.submitChallenge)
|
||||
.flatMap(({ type, payload }) => {
|
||||
const state = getState();
|
||||
const { submitType } = challengeSelector(state);
|
||||
const submitter = submitTypes[submitType] ||
|
||||
const submitter = submitters[submitType] ||
|
||||
(() => Observable.just(null));
|
||||
return submitter(type, state, payload);
|
||||
});
|
||||
|
@ -24,13 +24,7 @@ const initialUiState = {
|
||||
isLightBoxOpen: false,
|
||||
// project is ready to submit
|
||||
isSubmitting: false,
|
||||
output: `/**
|
||||
* Your output will go here.
|
||||
* Any console.log() - type
|
||||
* statements will appear in
|
||||
* your browser\'s DevTools
|
||||
* JavaScript console as well.
|
||||
*/`,
|
||||
output: null,
|
||||
// video
|
||||
// 1 indexed
|
||||
currentQuestion: 1,
|
||||
|
@ -1,39 +1,8 @@
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import * as challengeTypes from '../../../utils/challengeTypes';
|
||||
import { getNode } from '../utils';
|
||||
import { viewTypes, submitTypes, getNode } from '../utils';
|
||||
import blockNameify from '../../../utils/blockNameify';
|
||||
|
||||
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'
|
||||
};
|
||||
import { html } from '../../../utils/challengeTypes';
|
||||
|
||||
export const challengeSelector = createSelector(
|
||||
state => state.challengesApp.challenge,
|
||||
@ -44,6 +13,8 @@ export const challengeSelector = createSelector(
|
||||
}
|
||||
const challenge = challengeMap[challengeName];
|
||||
const challengeType = challenge && challenge.challengeType;
|
||||
const type = challenge && challenge.type;
|
||||
const viewType = viewTypes[type] || viewTypes[challengeType] || 'classic';
|
||||
const blockName = blockNameify(challenge.block);
|
||||
const title = blockName && challenge.title ?
|
||||
`${blockName}: ${challenge.title}` :
|
||||
@ -51,10 +22,13 @@ export const challengeSelector = createSelector(
|
||||
return {
|
||||
challenge,
|
||||
title,
|
||||
viewType: viewTypes[challengeType] || 'classic',
|
||||
submitType: submitTypes[challengeType] || 'tests',
|
||||
showPreview: challengeType === challengeTypes.html,
|
||||
mode: challenge && challengeType === challengeTypes.html ?
|
||||
viewType,
|
||||
submitType:
|
||||
submitTypes[challengeType] ||
|
||||
submitTypes[challenge && challenge.type] ||
|
||||
'tests',
|
||||
showPreview: challengeType === html,
|
||||
mode: challenge && challengeType === html ?
|
||||
'text/html' :
|
||||
'javascript'
|
||||
};
|
||||
|
@ -1,7 +1,46 @@
|
||||
import flow from 'lodash/flow';
|
||||
import { bonfire, html, js } from '../../utils/challengeTypes';
|
||||
import { decodeScriptTags } from '../../../utils/encode-decode';
|
||||
import * as challengeTypes from '../../utils/challengeTypes';
|
||||
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 = ['']) {
|
||||
seedData = Array.isArray(seedData) ? seedData : [seedData];
|
||||
@ -16,9 +55,9 @@ export function buildSeed({ challengeSeed = [] } = {}) {
|
||||
}
|
||||
|
||||
const pathsMap = {
|
||||
[html]: 'html',
|
||||
[js]: 'js',
|
||||
[bonfire]: 'js'
|
||||
[ challengeTypes.html ]: 'html',
|
||||
[ challengeTypes.js ]: 'js',
|
||||
[ challengeTypes.bonfire ]: 'js'
|
||||
};
|
||||
|
||||
export function getPreFile({ challengeType }) {
|
||||
|
@ -1,3 +1,4 @@
|
||||
import omit from 'lodash/omit';
|
||||
import normalizeUrl from 'normalize-url';
|
||||
import { isURL } from 'validator';
|
||||
|
||||
@ -68,3 +69,22 @@ export function getValidationState(field) {
|
||||
'error' :
|
||||
'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'
|
||||
];
|
||||
|
||||
gulp.task('reload', function() {
|
||||
notify({ message: 'test changed' });
|
||||
reload();
|
||||
});
|
||||
|
||||
gulp.task('watch', watchDependents, function() {
|
||||
gulp.watch(paths.lessFiles, ['less']);
|
||||
gulp.watch(paths.js.concat(paths.vendorChallenges), ['js']);
|
||||
gulp.watch(paths.challenges, ['test-challenges', 'reload']);
|
||||
gulp.watch(paths.js, ['js']);
|
||||
});
|
||||
|
||||
|
@ -4,6 +4,29 @@
|
||||
"time": "150 hours",
|
||||
"helpRoom": "HelpBackend",
|
||||
"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",
|
||||
"title": "Timestamp Microservice",
|
||||
|
@ -110,6 +110,12 @@ export default function(app) {
|
||||
projectCompleted
|
||||
);
|
||||
|
||||
api.post(
|
||||
'/backend-challenge-completed',
|
||||
send200toNonUser,
|
||||
backendChallengeCompleted
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/challenges/current-challenge',
|
||||
redirectToCurrentChallenge
|
||||
@ -287,6 +293,55 @@ export default function(app) {
|
||||
.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) {
|
||||
const { user } = req;
|
||||
return map$
|
||||
|
@ -18,6 +18,12 @@ export default function csp() {
|
||||
'*.cloudflare.com',
|
||||
'https://*.optimizely.com'
|
||||
]),
|
||||
connectSrc: trusted.concat([
|
||||
'https://gomix.com',
|
||||
'https://*.gomix.com',
|
||||
'https://*.gomix.me',
|
||||
'https://*.cloudflare.com'
|
||||
]),
|
||||
scriptSrc: [
|
||||
"'unsafe-eval'",
|
||||
"'unsafe-inline'",
|
||||
|
Reference in New Issue
Block a user