Feat(Challenges): no js preview (#16149)
* fix(files): Decouple files from challenges * feat(server/react): Remove action logger use redux remote devtools instead! * feat(Challenges): Disable js on edit, enable on execute * feat(Challenge/Preview): Show message when js is disabled * refactor(frameEpic): Reduce code by using lodash * feat(frameEpic): Disable js in preview by state * feat(frameEpic): Colocate epic in Challenges/redux * refactor(ExecuteChallengeEpic): CoLocated with Challenges * refactor(executeChallengesEpic): Separate tests from main logic * feat(Challenge/Preview): Update main on edit * feat(frameEpuc): Replace frame on edit/execute This allows for sandbox to work properly * fix(Challenges/Utils): Require utisl * revert(frameEpic): Hoist function to mount code in frame * fix(frameEpic): Ensure new frame is given classname * feat(executeChallenge): Update main on code unlocked * fix(frameEpic): Filter out empty test message * fix(Challenge/Preview): Remove unnessary quote in classname * feat(codeStorageEpic): Separate localstorage from solutions loading * fix(fetchUser): Merge user actions into one prefer many effects from one action over one action to one effect * fix(themes): Centralize theme utils and defs * fix(entities.user): Fix user reducer namespacing * feat(frame): Refactor frameEpic to util * feat(Challenges.redux): Should not attempt to update main from storage * fix(loadPreviousChallengeEpic): Refactor for RFR * fix(Challenges.Modern): Show preview plane
This commit is contained in:
committed by
Quincy Larson
parent
9051faee79
commit
2e410330f1
@ -1,62 +0,0 @@
|
|||||||
import { Scheduler, Observable } from 'rx';
|
|
||||||
|
|
||||||
import { ofType } from 'redux-epic';
|
|
||||||
|
|
||||||
import {
|
|
||||||
buildFromFiles,
|
|
||||||
buildBackendChallenge
|
|
||||||
} from '../utils/build.js';
|
|
||||||
import {
|
|
||||||
createErrorObservable,
|
|
||||||
|
|
||||||
challengeSelector
|
|
||||||
} from '../../common/app/redux';
|
|
||||||
import {
|
|
||||||
types,
|
|
||||||
|
|
||||||
frameMain,
|
|
||||||
frameTests,
|
|
||||||
initOutput,
|
|
||||||
|
|
||||||
codeLockedSelector
|
|
||||||
} from '../../common/app/routes/Challenges/redux';
|
|
||||||
|
|
||||||
import { filesSelector } from '../../common/app/files';
|
|
||||||
|
|
||||||
export default function executeChallengeEpic(actions, { getState }) {
|
|
||||||
return actions::ofType(types.executeChallenge, types.updateMain)
|
|
||||||
// if isCodeLocked do not run challenges
|
|
||||||
.filter(() => !codeLockedSelector(getState()))
|
|
||||||
.debounce(750)
|
|
||||||
.flatMapLatest(({ type }) => {
|
|
||||||
const shouldProxyConsole = type === types.updateMain;
|
|
||||||
const state = getState();
|
|
||||||
const files = filesSelector(state);
|
|
||||||
const {
|
|
||||||
required = [],
|
|
||||||
type: challengeType
|
|
||||||
} = challengeSelector(state);
|
|
||||||
if (challengeType === 'backend') {
|
|
||||||
return buildBackendChallenge(state)
|
|
||||||
.map(frameTests)
|
|
||||||
.startWith(initOutput('// running test'));
|
|
||||||
}
|
|
||||||
return buildFromFiles(files, required, shouldProxyConsole)
|
|
||||||
.flatMap(payload => {
|
|
||||||
const actions = [
|
|
||||||
frameMain(payload)
|
|
||||||
];
|
|
||||||
if (type === types.executeChallenge) {
|
|
||||||
actions.push(frameTests(payload));
|
|
||||||
}
|
|
||||||
return Observable.from(actions, null, null, Scheduler.default);
|
|
||||||
})
|
|
||||||
.startWith((
|
|
||||||
type === types.executeChallenge ?
|
|
||||||
initOutput('// running test') :
|
|
||||||
null
|
|
||||||
))
|
|
||||||
.filter(Boolean)
|
|
||||||
.catch(createErrorObservable);
|
|
||||||
});
|
|
||||||
}
|
|
@ -1,151 +0,0 @@
|
|||||||
import Rx, { Observable, Subject } from 'rx';
|
|
||||||
import { ofType } from 'redux-epic';
|
|
||||||
/* eslint-disable import/no-unresolved */
|
|
||||||
import loopProtect from 'loop-protect';
|
|
||||||
/* eslint-enable import/no-unresolved */
|
|
||||||
import { ShallowWrapper, ReactWrapper } from 'enzyme';
|
|
||||||
import Adapter15 from 'enzyme-adapter-react-15';
|
|
||||||
import {
|
|
||||||
types,
|
|
||||||
|
|
||||||
updateOutput,
|
|
||||||
checkChallenge,
|
|
||||||
updateTests,
|
|
||||||
|
|
||||||
codeLockedSelector,
|
|
||||||
testsSelector
|
|
||||||
} from '../../common/app/routes/Challenges/redux';
|
|
||||||
|
|
||||||
// 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>
|
|
||||||
`;
|
|
||||||
|
|
||||||
|
|
||||||
function createFrame(document, id = mainId) {
|
|
||||||
const frame = document.createElement('iframe');
|
|
||||||
frame.id = id;
|
|
||||||
frame.className = 'hide-test-frame';
|
|
||||||
document.body.appendChild(frame);
|
|
||||||
return frame;
|
|
||||||
}
|
|
||||||
|
|
||||||
function refreshFrame(frame) {
|
|
||||||
frame.src = 'about:blank';
|
|
||||||
return frame;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFrameDocument(document, id = mainId) {
|
|
||||||
let frame = document.getElementById(id);
|
|
||||||
if (!frame) {
|
|
||||||
frame = createFrame(document, id);
|
|
||||||
}
|
|
||||||
frame.contentWindow.loopProtect = loopProtect;
|
|
||||||
return {
|
|
||||||
frame: frame.contentDocument || frame.contentWindow.document,
|
|
||||||
frameWindow: frame.contentWindow
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildProxyConsole(window, proxyLogger) {
|
|
||||||
const oldLog = window.console.log.bind(console);
|
|
||||||
window.__console = {};
|
|
||||||
window.__console.log = function proxyConsole(...args) {
|
|
||||||
proxyLogger.onNext(args);
|
|
||||||
return oldLog(...args);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function frameMain({ build } = {}, document, proxyLogger) {
|
|
||||||
const { frame: main, frameWindow } = getFrameDocument(document);
|
|
||||||
refreshFrame(main);
|
|
||||||
buildProxyConsole(frameWindow, proxyLogger);
|
|
||||||
main.Rx = Rx;
|
|
||||||
main.open();
|
|
||||||
main.write(createHeader() + build);
|
|
||||||
main.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
function frameTests({ build, sources, checkChallengePayload } = {}, document) {
|
|
||||||
const { frame: tests } = getFrameDocument(document, testId);
|
|
||||||
refreshFrame(tests);
|
|
||||||
tests.Rx = Rx;
|
|
||||||
// add enzyme
|
|
||||||
// TODO: do programatically
|
|
||||||
// TODO: webpack lazyload this
|
|
||||||
tests.Enzyme = {
|
|
||||||
shallow: (node, options) => new ShallowWrapper(node, null, {
|
|
||||||
...options,
|
|
||||||
adapter: new Adapter15()
|
|
||||||
}),
|
|
||||||
mount: (node, options) => new ReactWrapper(node, null, {
|
|
||||||
...options,
|
|
||||||
adapter: new Adapter15()
|
|
||||||
})
|
|
||||||
};
|
|
||||||
// default for classic challenges
|
|
||||||
// should not be used for modern
|
|
||||||
tests.__source = sources['index'] || '';
|
|
||||||
tests.__getUserInput = key => sources[key];
|
|
||||||
tests.__checkChallengePayload = checkChallengePayload;
|
|
||||||
tests.open();
|
|
||||||
tests.write(createHeader(testId) + build);
|
|
||||||
tests.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
// 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(() => !codeLockedSelector(getState()))
|
|
||||||
.map(action => {
|
|
||||||
if (action.type === types.frameMain) {
|
|
||||||
return frameMain(action.payload, document, proxyLogger);
|
|
||||||
}
|
|
||||||
return frameTests(action.payload, document);
|
|
||||||
})
|
|
||||||
.ignoreElements();
|
|
||||||
|
|
||||||
return Observable.merge(
|
|
||||||
proxyLogger.map(updateOutput),
|
|
||||||
frameReady.flatMap(({ checkChallengePayload }) => {
|
|
||||||
const { frame } = getFrameDocument(document, testId);
|
|
||||||
const tests = testsSelector(getState());
|
|
||||||
const postTests = Observable.of(
|
|
||||||
updateOutput('// tests completed'),
|
|
||||||
checkChallenge(checkChallengePayload)
|
|
||||||
).delay(250);
|
|
||||||
// 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
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,8 +1,5 @@
|
|||||||
import analyticsEpic from './analytics-epic.js';
|
import analyticsEpic from './analytics-epic.js';
|
||||||
import codeStorageEpic from './code-storage-epic.js';
|
|
||||||
import errEpic from './err-epic.js';
|
import errEpic from './err-epic.js';
|
||||||
import executeChallengeEpic from './execute-challenge-epic.js';
|
|
||||||
import frameEpic from './frame-epic.js';
|
|
||||||
import hardGoToEpic from './hard-go-to-epic.js';
|
import hardGoToEpic from './hard-go-to-epic.js';
|
||||||
import mouseTrapEpic from './mouse-trap-epic.js';
|
import mouseTrapEpic from './mouse-trap-epic.js';
|
||||||
import nightModeEpic from './night-mode-epic.js';
|
import nightModeEpic from './night-mode-epic.js';
|
||||||
@ -10,10 +7,7 @@ import titleEpic from './title-epic.js';
|
|||||||
|
|
||||||
export default [
|
export default [
|
||||||
analyticsEpic,
|
analyticsEpic,
|
||||||
codeStorageEpic,
|
|
||||||
errEpic,
|
errEpic,
|
||||||
executeChallengeEpic,
|
|
||||||
frameEpic,
|
|
||||||
hardGoToEpic,
|
hardGoToEpic,
|
||||||
mouseTrapEpic,
|
mouseTrapEpic,
|
||||||
nightModeEpic,
|
nightModeEpic,
|
||||||
|
@ -1,16 +1,18 @@
|
|||||||
|
import _ from 'lodash';
|
||||||
import { Observable } from 'rx';
|
import { Observable } from 'rx';
|
||||||
|
import { ofType } from 'redux-epic';
|
||||||
import store from 'store';
|
import store from 'store';
|
||||||
|
|
||||||
import { postJSON$ } from '../../common/utils/ajax-stream';
|
import { themes } from '../../common/utils/themes.js';
|
||||||
|
import { postJSON$ } from '../../common/utils/ajax-stream.js';
|
||||||
import {
|
import {
|
||||||
types,
|
types,
|
||||||
|
|
||||||
addThemeToBody,
|
postThemeComplete,
|
||||||
updateTheme,
|
|
||||||
|
|
||||||
createErrorObservable,
|
createErrorObservable,
|
||||||
|
|
||||||
themeSelector,
|
themeSelector,
|
||||||
|
usernameSelector,
|
||||||
csrfSelector
|
csrfSelector
|
||||||
} from '../../common/app/redux';
|
} from '../../common/app/redux';
|
||||||
|
|
||||||
@ -24,40 +26,34 @@ export default function nightModeSaga(
|
|||||||
{ document: { body } }
|
{ document: { body } }
|
||||||
) {
|
) {
|
||||||
const toggleBodyClass = actions
|
const toggleBodyClass = actions
|
||||||
.filter(({ type }) => types.addThemeToBody === type)
|
::ofType(
|
||||||
.doOnNext(({ payload: theme }) => {
|
types.fetchUser.complete,
|
||||||
if (theme === 'night') {
|
types.toggleNightMode,
|
||||||
body.classList.add('night');
|
types.postThemeComplete
|
||||||
|
)
|
||||||
|
.map(_.flow(getState, themeSelector))
|
||||||
// catch existing night mode users
|
// catch existing night mode users
|
||||||
persistTheme(theme);
|
.do(persistTheme)
|
||||||
|
.do(theme => {
|
||||||
|
if (theme === themes.night) {
|
||||||
|
body.classList.add(themes.night);
|
||||||
} else {
|
} else {
|
||||||
body.classList.remove('night');
|
body.classList.remove(themes.night);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.filter(() => false);
|
.ignoreElements();
|
||||||
|
|
||||||
const toggle = actions
|
const postThemeEpic = actions::ofType(types.toggleNightMode)
|
||||||
.filter(({ type }) => types.toggleNightMode === type);
|
|
||||||
|
|
||||||
const optimistic = toggle
|
|
||||||
.flatMap(() => {
|
|
||||||
const theme = themeSelector(getState());
|
|
||||||
const newTheme = !theme || theme === 'default' ? 'night' : 'default';
|
|
||||||
persistTheme(newTheme);
|
|
||||||
return Observable.of(
|
|
||||||
updateTheme(newTheme),
|
|
||||||
addThemeToBody(newTheme)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const ajax = toggle
|
|
||||||
.debounce(250)
|
.debounce(250)
|
||||||
.flatMapLatest(() => {
|
.flatMapLatest(() => {
|
||||||
const _csrf = csrfSelector(getState());
|
const _csrf = csrfSelector(getState());
|
||||||
const theme = themeSelector(getState());
|
const theme = themeSelector(getState());
|
||||||
|
const username = usernameSelector(getState());
|
||||||
return postJSON$('/update-my-theme', { _csrf, theme })
|
return postJSON$('/update-my-theme', { _csrf, theme })
|
||||||
|
.pluck('updatedTo')
|
||||||
|
.map(theme => postThemeComplete(username, theme))
|
||||||
.catch(createErrorObservable);
|
.catch(createErrorObservable);
|
||||||
});
|
});
|
||||||
|
|
||||||
return Observable.merge(optimistic, toggleBodyClass, ajax);
|
return Observable.merge(toggleBodyClass, postThemeEpic);
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,7 @@
|
|||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
var testTimeout = 5000;
|
var testTimeout = 5000;
|
||||||
var common = parent.__common;
|
|
||||||
var frameId = window.__frameId;
|
|
||||||
var frameReady = common[frameId + 'Ready'] || { onNext() {} };
|
|
||||||
var Rx = document.Rx;
|
var Rx = document.Rx;
|
||||||
|
var frameReady = document.__frameReady;
|
||||||
var helpers = Rx.helpers;
|
var helpers = Rx.helpers;
|
||||||
var chai = parent.chai;
|
var chai = parent.chai;
|
||||||
var source = document.__source;
|
var source = document.__source;
|
||||||
@ -14,8 +12,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.__getJsOutput = function getJsOutput() {
|
document.__getJsOutput = function getJsOutput() {
|
||||||
if (window.__err || !common.shouldRun()) {
|
if (window.__err) {
|
||||||
return window.__err || 'source disabled';
|
return window.__err;
|
||||||
}
|
}
|
||||||
let output;
|
let output;
|
||||||
try {
|
try {
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import _ from 'lodash';
|
||||||
import { ofType } from 'redux-epic';
|
import { ofType } from 'redux-epic';
|
||||||
|
|
||||||
import { types } from './';
|
import { types } from './';
|
||||||
@ -8,23 +9,21 @@ import {
|
|||||||
} from '../../redux';
|
} from '../../redux';
|
||||||
import { onRouteChallenges } from '../../routes/Challenges/redux';
|
import { onRouteChallenges } from '../../routes/Challenges/redux';
|
||||||
import { entitiesSelector } from '../../entities';
|
import { entitiesSelector } from '../../entities';
|
||||||
|
import { langSelector, pathnameSelector } from '../../Router/redux';
|
||||||
|
|
||||||
export default function loadCurrentChallengeEpic(actions, { getState }) {
|
export default function loadCurrentChallengeEpic(actions, { getState }) {
|
||||||
return actions::ofType(types.clickOnLogo, types.clickOnMap)
|
return actions::ofType(types.clickOnLogo, types.clickOnMap)
|
||||||
.debounce(500)
|
.debounce(500)
|
||||||
.map(() => {
|
.map(getState)
|
||||||
|
.map(state => {
|
||||||
let finalChallenge;
|
let finalChallenge;
|
||||||
const state = getState();
|
const lang = langSelector(state);
|
||||||
const { id: currentlyLoadedChallengeId } = challengeSelector(state);
|
const { id: currentlyLoadedChallengeId } = challengeSelector(state);
|
||||||
const {
|
const {
|
||||||
challenge: challengeMap,
|
challenge: challengeMap,
|
||||||
challengeIdToName
|
challengeIdToName
|
||||||
} = entitiesSelector(state);
|
} = entitiesSelector(state);
|
||||||
const {
|
const pathname = pathnameSelector(state);
|
||||||
routing: {
|
|
||||||
locationBeforeTransitions: { pathname } = {}
|
|
||||||
}
|
|
||||||
} = state;
|
|
||||||
const firstChallenge = firstChallengeSelector(state);
|
const firstChallenge = firstChallengeSelector(state);
|
||||||
const { currentChallengeId } = userSelector(state);
|
const { currentChallengeId } = userSelector(state);
|
||||||
const isOnAChallenge = (/^\/[^\/]{2,6}\/challenges/).test(pathname);
|
const isOnAChallenge = (/^\/[^\/]{2,6}\/challenges/).test(pathname);
|
||||||
@ -37,22 +36,23 @@ export default function loadCurrentChallengeEpic(actions, { getState }) {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
finalChallenge,
|
..._.pick(finalChallenge, ['id', 'block', 'dashedName']),
|
||||||
|
lang,
|
||||||
isOnAChallenge,
|
isOnAChallenge,
|
||||||
currentlyLoadedChallengeId
|
currentlyLoadedChallengeId
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter(({
|
.filter(({
|
||||||
finalChallenge,
|
id,
|
||||||
isOnAChallenge,
|
isOnAChallenge,
|
||||||
currentlyLoadedChallengeId
|
currentlyLoadedChallengeId
|
||||||
}) => (
|
}) => (
|
||||||
// data might not be there yet, filter out for now
|
// data might not be there yet, filter out for now
|
||||||
!!finalChallenge &&
|
!!id &&
|
||||||
// are we already on that challenge? if not load challenge
|
// are we already on that challenge? if not load challenge
|
||||||
(!isOnAChallenge || finalChallenge.id !== currentlyLoadedChallengeId)
|
(!isOnAChallenge || id !== currentlyLoadedChallengeId)
|
||||||
// don't reload if the challenge is already loaded.
|
// don't reload if the challenge is already loaded.
|
||||||
// This may change to toast to avoid user confusion
|
// This may change to toast to avoid user confusion
|
||||||
))
|
))
|
||||||
.map(({ finalChallenge }) => onRouteChallenges(finalChallenge));
|
.map(onRouteChallenges);
|
||||||
}
|
}
|
||||||
|
@ -6,3 +6,4 @@ export const locationTypeSelector =
|
|||||||
export const langSelector = state => paramsSelector(state).lang || 'en';
|
export const langSelector = state => paramsSelector(state).lang || 'en';
|
||||||
export const routesMapSelector = state =>
|
export const routesMapSelector = state =>
|
||||||
selectLocationState(state).routesMap || {};
|
selectLocationState(state).routesMap || {};
|
||||||
|
export const pathnameSelector = state => selectLocationState(state).pathname;
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import _ from 'lodash';
|
||||||
import {
|
import {
|
||||||
composeReducers,
|
composeReducers,
|
||||||
createAction,
|
createAction,
|
||||||
@ -5,12 +6,14 @@ import {
|
|||||||
handleActions
|
handleActions
|
||||||
} from 'berkeleys-redux-utils';
|
} from 'berkeleys-redux-utils';
|
||||||
|
|
||||||
import { types as app } from '../routes/Challenges/redux';
|
import { themes } from '../../utils/themes';
|
||||||
|
import { types as challenges } from '../routes/Challenges/redux';
|
||||||
|
|
||||||
export const ns = 'entities';
|
export const ns = 'entities';
|
||||||
export const getNS = state => state[ns];
|
export const getNS = state => state[ns];
|
||||||
export const entitiesSelector = getNS;
|
export const entitiesSelector = getNS;
|
||||||
export const types = createTypes([
|
export const types = createTypes([
|
||||||
|
'updateTheme',
|
||||||
'updateUserFlag',
|
'updateUserFlag',
|
||||||
'updateUserEmail',
|
'updateUserEmail',
|
||||||
'updateUserLang',
|
'updateUserLang',
|
||||||
@ -37,6 +40,17 @@ export const updateUserCurrentChallenge = createAction(
|
|||||||
types.updateUserCurrentChallenge
|
types.updateUserCurrentChallenge
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// entity meta creators
|
||||||
|
const getEntityAction = _.property('meta.entitiesAction');
|
||||||
|
export const updateThemeMetacreator = (username, theme) => ({
|
||||||
|
entitiesAction: {
|
||||||
|
type: types.updateTheme,
|
||||||
|
payload: {
|
||||||
|
username,
|
||||||
|
theme: !theme || theme === themes.default ? themes.default : themes.night
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const defaultState = {
|
const defaultState = {
|
||||||
superBlock: {},
|
superBlock: {},
|
||||||
@ -73,34 +87,59 @@ export default composeReducers(
|
|||||||
}
|
}
|
||||||
return state;
|
return state;
|
||||||
},
|
},
|
||||||
|
function(state = defaultState, action) {
|
||||||
|
if (getEntityAction(action)) {
|
||||||
|
const { payload: { username, theme } } = getEntityAction(action);
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
user: {
|
||||||
|
...state.user,
|
||||||
|
[username]: {
|
||||||
|
...state.user[username],
|
||||||
|
theme
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
},
|
||||||
handleActions(
|
handleActions(
|
||||||
() => ({
|
() => ({
|
||||||
[
|
[
|
||||||
app.submitChallenge.complete
|
challenges.submitChallenge.complete
|
||||||
]: (state, { payload: { username, points, challengeInfo } }) => ({
|
]: (state, { payload: { username, points, challengeInfo } }) => ({
|
||||||
...state,
|
...state,
|
||||||
|
user: {
|
||||||
|
...state.user,
|
||||||
[username]: {
|
[username]: {
|
||||||
...state[username],
|
...state.user[username],
|
||||||
points,
|
points,
|
||||||
challengeMap: {
|
challengeMap: {
|
||||||
...state[username].challengeMap,
|
...state.user[username].challengeMap,
|
||||||
[challengeInfo.id]: challengeInfo
|
[challengeInfo.id]: challengeInfo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
[types.updateUserFlag]: (state, { payload: { username, flag } }) => ({
|
[types.updateUserFlag]: (state, { payload: { username, flag } }) => ({
|
||||||
...state,
|
...state,
|
||||||
|
user: {
|
||||||
|
...state.user,
|
||||||
[username]: {
|
[username]: {
|
||||||
...state[username],
|
...state.user[username],
|
||||||
[flag]: !state[username][flag]
|
[flag]: !state.user[username][flag]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
[types.updateUserEmail]: (state, { payload: { username, email } }) => ({
|
[types.updateUserEmail]: (state, { payload: { username, email } }) => ({
|
||||||
...state,
|
...state,
|
||||||
|
user: {
|
||||||
|
...state.user,
|
||||||
[username]: {
|
[username]: {
|
||||||
...state[username],
|
...state.user[username],
|
||||||
email
|
email
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
[types.updateUserLang]:
|
[types.updateUserLang]:
|
||||||
(
|
(
|
||||||
@ -110,10 +149,13 @@ export default composeReducers(
|
|||||||
}
|
}
|
||||||
) => ({
|
) => ({
|
||||||
...state,
|
...state,
|
||||||
|
user: {
|
||||||
|
...state.user,
|
||||||
[username]: {
|
[username]: {
|
||||||
...state[username],
|
...state.user[username],
|
||||||
languageTag
|
languageTag
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
[types.updateUserCurrentChallenge]:
|
[types.updateUserCurrentChallenge]:
|
||||||
(
|
(
|
||||||
@ -123,10 +165,13 @@ export default composeReducers(
|
|||||||
}
|
}
|
||||||
) => ({
|
) => ({
|
||||||
...state,
|
...state,
|
||||||
|
user: {
|
||||||
|
...state.user,
|
||||||
[username]: {
|
[username]: {
|
||||||
...state[username],
|
...state.user[username],
|
||||||
currentChallengeId
|
currentChallengeId
|
||||||
}
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
defaultState
|
defaultState
|
||||||
|
@ -1,31 +1,24 @@
|
|||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import {
|
import {
|
||||||
combineActions,
|
addNS,
|
||||||
createAction,
|
createTypes
|
||||||
createTypes,
|
|
||||||
handleActions
|
|
||||||
} from 'berkeleys-redux-utils';
|
} from 'berkeleys-redux-utils';
|
||||||
|
|
||||||
import { bonfire, html, js } from '../utils/challengeTypes.js';
|
|
||||||
import { createPoly, setContent } from '../../utils/polyvinyl.js';
|
import { createPoly, setContent } from '../../utils/polyvinyl.js';
|
||||||
import { arrayToString, buildSeed, getPreFile } from '../utils/classic-file.js';
|
|
||||||
import { types as app } from '../redux';
|
|
||||||
import { types as challenges } from '../routes/Challenges/redux';
|
|
||||||
|
|
||||||
const ns = 'files';
|
const ns = 'files';
|
||||||
|
|
||||||
export const types = createTypes([
|
export const types = createTypes([
|
||||||
'updateFile',
|
'updateFile',
|
||||||
'updateFiles',
|
'createFiles'
|
||||||
'savedCodeFound'
|
|
||||||
], ns);
|
], ns);
|
||||||
|
|
||||||
export const updateFile = createAction(types.updateFile);
|
export const updateFileMetaCreator = (key, content)=> ({
|
||||||
export const updateFiles = createAction(types.updateFiles);
|
file: { type: types.updateFile, payload: { key, content } }
|
||||||
export const savedCodeFound = createAction(
|
});
|
||||||
types.savedCodeFound,
|
export const createFilesMetaCreator = payload => ({
|
||||||
(files, challenge) => ({ files, challenge })
|
file: { type: types.createFiles, payload }
|
||||||
);
|
});
|
||||||
|
|
||||||
export const filesSelector = state => state[ns];
|
export const filesSelector = state => state[ns];
|
||||||
export const createFileSelector = keySelector => (state, props) => {
|
export const createFileSelector = keySelector => (state, props) => {
|
||||||
@ -33,76 +26,28 @@ export const createFileSelector = keySelector => (state, props) => {
|
|||||||
return files[keySelector(state, props)] || {};
|
return files[keySelector(state, props)] || {};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default handleActions(
|
const getFileAction = _.property('meta.file.type');
|
||||||
() => ({
|
const getFilePayload = _.property('meta.file.payload');
|
||||||
[types.updateFile]: (state, { payload: { key, content }}) => ({
|
|
||||||
|
export default addNS(
|
||||||
|
ns,
|
||||||
|
function reducer(state = {}, action) {
|
||||||
|
if (getFileAction(action)) {
|
||||||
|
if (getFileAction(action) === types.updateFile) {
|
||||||
|
const { key, content } = getFilePayload(action);
|
||||||
|
return {
|
||||||
...state,
|
...state,
|
||||||
[key]: setContent(content, state[key])
|
[key]: setContent(content, state[key])
|
||||||
}),
|
};
|
||||||
[types.updateFiles]: (state, { payload: files }) => {
|
}
|
||||||
return files
|
if (getFileAction(action) === types.createFiles) {
|
||||||
.reduce((files, file) => {
|
const files = getFilePayload(action);
|
||||||
files[file.key] = file;
|
|
||||||
return files;
|
|
||||||
}, { ...state });
|
|
||||||
},
|
|
||||||
[types.savedCodeFound]: (state, { payload: { files, challenge } }) => {
|
|
||||||
if (challenge.type === 'modern') {
|
|
||||||
// this may need to change to update head/tail
|
|
||||||
return _.reduce(files, (files, file) => {
|
return _.reduce(files, (files, file) => {
|
||||||
files[file.key] = createPoly(file);
|
files[file.key] = createPoly(file);
|
||||||
return files;
|
return files;
|
||||||
}, {});
|
}, { ...state });
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
challenge.challengeType !== html &&
|
|
||||||
challenge.challengeType !== js &&
|
|
||||||
challenge.challengeType !== bonfire
|
|
||||||
) {
|
|
||||||
return {};
|
|
||||||
}
|
}
|
||||||
// classic challenge to modern format
|
return state;
|
||||||
const preFile = getPreFile(challenge);
|
|
||||||
return {
|
|
||||||
[preFile.key]: createPoly({
|
|
||||||
...files[preFile.key],
|
|
||||||
// make sure head/tail are always fresh
|
|
||||||
head: arrayToString(challenge.head),
|
|
||||||
tail: arrayToString(challenge.tail)
|
|
||||||
})
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[
|
|
||||||
combineActions(
|
|
||||||
challenges.challengeUpdated,
|
|
||||||
app.fetchChallenge.complete
|
|
||||||
)
|
|
||||||
]: (state, { payload: { challenge } }) => {
|
|
||||||
if (challenge.type === 'modern') {
|
|
||||||
return _.reduce(challenge.files, (files, file) => {
|
|
||||||
files[file.key] = createPoly(file);
|
|
||||||
return files;
|
|
||||||
}, {});
|
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
challenge.challengeType !== html &&
|
|
||||||
challenge.challengeType !== js &&
|
|
||||||
challenge.challengeType !== bonfire
|
|
||||||
) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
// classic challenge to modern format
|
|
||||||
const preFile = getPreFile(challenge);
|
|
||||||
return {
|
|
||||||
[preFile.key]: createPoly({
|
|
||||||
...preFile,
|
|
||||||
contents: buildSeed(challenge),
|
|
||||||
head: arrayToString(challenge.head),
|
|
||||||
tail: arrayToString(challenge.tail)
|
|
||||||
})
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
{},
|
|
||||||
ns
|
|
||||||
);
|
);
|
||||||
|
@ -1,32 +1,18 @@
|
|||||||
import { Observable } from 'rx';
|
|
||||||
import { ofType } from 'redux-epic';
|
import { ofType } from 'redux-epic';
|
||||||
import {
|
import {
|
||||||
types,
|
types,
|
||||||
|
|
||||||
addUser,
|
fetchUserComplete,
|
||||||
updateThisUser,
|
|
||||||
createErrorObservable,
|
createErrorObservable,
|
||||||
showSignIn,
|
showSignIn
|
||||||
updateTheme,
|
|
||||||
addThemeToBody
|
|
||||||
} from './';
|
} from './';
|
||||||
|
|
||||||
export default function getUserEpic(actions, _, { services }) {
|
export default function getUserEpic(actions, _, { services }) {
|
||||||
return actions::ofType(types.fetchUser)
|
return actions::ofType('' + types.fetchUser)
|
||||||
.flatMap(() => {
|
.flatMap(() => {
|
||||||
return services.readService$({ service: 'user' })
|
return services.readService$({ service: 'user' })
|
||||||
.filter(({ entities, result }) => entities && !!result)
|
.filter(({ entities, result }) => entities && !!result)
|
||||||
.flatMap(({ entities, result })=> {
|
.map(fetchUserComplete)
|
||||||
const user = entities.user[result];
|
|
||||||
const isNightMode = user.theme === 'night';
|
|
||||||
const actions = [
|
|
||||||
addUser(entities),
|
|
||||||
updateThisUser(result),
|
|
||||||
isNightMode ? updateTheme(user.theme) : null,
|
|
||||||
isNightMode ? addThemeToBody(user.theme) : null
|
|
||||||
];
|
|
||||||
return Observable.from(actions).filter(Boolean);
|
|
||||||
})
|
|
||||||
.defaultIfEmpty(showSignIn())
|
.defaultIfEmpty(showSignIn())
|
||||||
.catch(createErrorObservable);
|
.catch(createErrorObservable);
|
||||||
});
|
});
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import _ from 'lodash';
|
||||||
import { Observable } from 'rx';
|
import { Observable } from 'rx';
|
||||||
import {
|
import {
|
||||||
combineActions,
|
combineActions,
|
||||||
@ -7,18 +8,21 @@ import {
|
|||||||
handleActions
|
handleActions
|
||||||
} from 'berkeleys-redux-utils';
|
} from 'berkeleys-redux-utils';
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
import noop from 'lodash/noop';
|
|
||||||
import identity from 'lodash/identity';
|
|
||||||
|
|
||||||
import { entitiesSelector } from '../entities';
|
|
||||||
import fetchUserEpic from './fetch-user-epic.js';
|
import fetchUserEpic from './fetch-user-epic.js';
|
||||||
import updateMyCurrentChallengeEpic from './update-my-challenge-epic.js';
|
import updateMyCurrentChallengeEpic from './update-my-challenge-epic.js';
|
||||||
import fetchChallengesEpic from './fetch-challenges-epic.js';
|
import fetchChallengesEpic from './fetch-challenges-epic.js';
|
||||||
import navSizeEpic from './nav-size-epic.js';
|
import navSizeEpic from './nav-size-epic.js';
|
||||||
|
|
||||||
|
import { createFilesMetaCreator } from '../files';
|
||||||
|
import { updateThemeMetacreator, entitiesSelector } from '../entities';
|
||||||
import { types as challenges } from '../routes/Challenges/redux';
|
import { types as challenges } from '../routes/Challenges/redux';
|
||||||
|
import { challengeToFiles } from '../routes/Challenges/utils';
|
||||||
|
|
||||||
import ns from '../ns.json';
|
import ns from '../ns.json';
|
||||||
|
|
||||||
|
import { themes, invertTheme } from '../../utils/themes.js';
|
||||||
|
|
||||||
export const epics = [
|
export const epics = [
|
||||||
fetchUserEpic,
|
fetchUserEpic,
|
||||||
fetchChallengesEpic,
|
fetchChallengesEpic,
|
||||||
@ -36,9 +40,7 @@ export const types = createTypes([
|
|||||||
createAsyncTypes('fetchChallenge'),
|
createAsyncTypes('fetchChallenge'),
|
||||||
createAsyncTypes('fetchChallenges'),
|
createAsyncTypes('fetchChallenges'),
|
||||||
|
|
||||||
'fetchUser',
|
createAsyncTypes('fetchUser'),
|
||||||
'addUser',
|
|
||||||
'updateThisUser',
|
|
||||||
'showSignIn',
|
'showSignIn',
|
||||||
|
|
||||||
'handleError',
|
'handleError',
|
||||||
@ -48,8 +50,7 @@ export const types = createTypes([
|
|||||||
|
|
||||||
// night mode
|
// night mode
|
||||||
'toggleNightMode',
|
'toggleNightMode',
|
||||||
'updateTheme',
|
'postThemeComplete'
|
||||||
'addThemeToBody'
|
|
||||||
], ns);
|
], ns);
|
||||||
|
|
||||||
const throwIfUndefined = () => {
|
const throwIfUndefined = () => {
|
||||||
@ -95,7 +96,10 @@ export const fetchChallenge = createAction(
|
|||||||
export const fetchChallengeCompleted = createAction(
|
export const fetchChallengeCompleted = createAction(
|
||||||
types.fetchChallenge.complete,
|
types.fetchChallenge.complete,
|
||||||
null,
|
null,
|
||||||
identity
|
meta => ({
|
||||||
|
...meta,
|
||||||
|
..._.flow(challengeToFiles, createFilesMetaCreator)(meta.challenge)
|
||||||
|
})
|
||||||
);
|
);
|
||||||
export const fetchChallenges = createAction('' + types.fetchChallenges);
|
export const fetchChallenges = createAction('' + types.fetchChallenges);
|
||||||
export const fetchChallengesCompleted = createAction(
|
export const fetchChallengesCompleted = createAction(
|
||||||
@ -110,16 +114,12 @@ export const updateTitle = createAction(types.updateTitle);
|
|||||||
// fetchUser() => Action
|
// fetchUser() => Action
|
||||||
// used in combination with fetch-user-epic
|
// used in combination with fetch-user-epic
|
||||||
export const fetchUser = createAction(types.fetchUser);
|
export const fetchUser = createAction(types.fetchUser);
|
||||||
|
export const fetchUserComplete = createAction(
|
||||||
// addUser(
|
types.fetchUser.complete,
|
||||||
// entities: { [userId]: User }
|
({ result }) => result,
|
||||||
// ) => Action
|
_.identity
|
||||||
export const addUser = createAction(
|
|
||||||
types.addUser,
|
|
||||||
noop,
|
|
||||||
entities => ({ entities })
|
|
||||||
);
|
);
|
||||||
export const updateThisUser = createAction(types.updateThisUser);
|
|
||||||
export const showSignIn = createAction(types.showSignIn);
|
export const showSignIn = createAction(types.showSignIn);
|
||||||
|
|
||||||
// used when server needs client to redirect
|
// used when server needs client to redirect
|
||||||
@ -145,21 +145,20 @@ export const doActionOnError = actionCreator => error => Observable.of(
|
|||||||
|
|
||||||
export const toggleNightMode = createAction(
|
export const toggleNightMode = createAction(
|
||||||
types.toggleNightMode,
|
types.toggleNightMode,
|
||||||
// we use this function to avoid hanging onto the eventObject
|
null,
|
||||||
// so that react can recycle it
|
(username, theme) => updateThemeMetacreator(username, invertTheme(theme))
|
||||||
() => null
|
);
|
||||||
|
export const postThemeComplete = createAction(
|
||||||
|
types.postThemeComplete,
|
||||||
|
null,
|
||||||
|
updateThemeMetacreator
|
||||||
);
|
);
|
||||||
// updateTheme(theme: /night|default/) => Action
|
|
||||||
export const updateTheme = createAction(types.updateTheme);
|
|
||||||
// addThemeToBody(theme: /night|default/) => Action
|
|
||||||
export const addThemeToBody = createAction(types.addThemeToBody);
|
|
||||||
|
|
||||||
const initialState = {
|
const defaultState = {
|
||||||
title: 'Learn To Code | freeCodeCamp',
|
title: 'Learn To Code | freeCodeCamp',
|
||||||
isSignInAttempted: false,
|
isSignInAttempted: false,
|
||||||
user: '',
|
user: '',
|
||||||
csrfToken: '',
|
csrfToken: '',
|
||||||
theme: 'default',
|
|
||||||
// eventually this should be only in the user object
|
// eventually this should be only in the user object
|
||||||
currentChallenge: '',
|
currentChallenge: '',
|
||||||
superBlocks: []
|
superBlocks: []
|
||||||
@ -167,28 +166,37 @@ const initialState = {
|
|||||||
|
|
||||||
export const getNS = state => state[ns];
|
export const getNS = state => state[ns];
|
||||||
export const csrfSelector = state => getNS(state).csrfToken;
|
export const csrfSelector = state => getNS(state).csrfToken;
|
||||||
export const themeSelector = state => getNS(state).theme;
|
|
||||||
export const titleSelector = state => getNS(state).title;
|
export const titleSelector = state => getNS(state).title;
|
||||||
|
|
||||||
export const currentChallengeSelector = state => getNS(state).currentChallenge;
|
export const currentChallengeSelector = state => getNS(state).currentChallenge;
|
||||||
export const superBlocksSelector = state => getNS(state).superBlocks;
|
export const superBlocksSelector = state => getNS(state).superBlocks;
|
||||||
export const signInLoadingSelector = state => !getNS(state).isSignInAttempted;
|
export const signInLoadingSelector = state => !getNS(state).isSignInAttempted;
|
||||||
|
|
||||||
|
export const usernameSelector = state => getNS(state).user || '';
|
||||||
export const userSelector = createSelector(
|
export const userSelector = createSelector(
|
||||||
state => getNS(state).user,
|
state => getNS(state).user,
|
||||||
state => entitiesSelector(state).user,
|
state => entitiesSelector(state).user,
|
||||||
(username, userMap) => userMap[username] || {}
|
(username, userMap) => userMap[username] || {}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const themeSelector = _.flow(
|
||||||
|
userSelector,
|
||||||
|
user => user.theme || themes.default
|
||||||
|
);
|
||||||
|
|
||||||
export const isSignedInSelector = state => !!userSelector(state).username;
|
export const isSignedInSelector = state => !!userSelector(state).username;
|
||||||
|
|
||||||
export const challengeSelector = createSelector(
|
export const challengeSelector = state => {
|
||||||
currentChallengeSelector,
|
const challengeName = currentChallengeSelector(state);
|
||||||
state => entitiesSelector(state).challenge,
|
const challengeMap = entitiesSelector(state).challenge || {};
|
||||||
(challengeName, challengeMap = {}) => {
|
|
||||||
return challengeMap[challengeName] || {};
|
return challengeMap[challengeName] || {};
|
||||||
}
|
};
|
||||||
);
|
|
||||||
|
export const previousSolutionSelector = state => {
|
||||||
|
const { id } = challengeSelector(state);
|
||||||
|
const { challengeMap = {} } = userSelector(state);
|
||||||
|
return challengeMap[id];
|
||||||
|
};
|
||||||
|
|
||||||
export const firstChallengeSelector = createSelector(
|
export const firstChallengeSelector = createSelector(
|
||||||
entitiesSelector,
|
entitiesSelector,
|
||||||
@ -231,7 +239,7 @@ export default handleActions(
|
|||||||
title: payload + ' | freeCodeCamp'
|
title: payload + ' | freeCodeCamp'
|
||||||
}),
|
}),
|
||||||
|
|
||||||
[types.updateThisUser]: (state, { payload: user }) => ({
|
[types.fetchUser.complete]: (state, { payload: user }) => ({
|
||||||
...state,
|
...state,
|
||||||
user
|
user
|
||||||
}),
|
}),
|
||||||
@ -246,11 +254,9 @@ export default handleActions(
|
|||||||
...state,
|
...state,
|
||||||
currentChallenge: dashedName
|
currentChallenge: dashedName
|
||||||
}),
|
}),
|
||||||
[types.updateTheme]: (state, { payload = 'default' }) => ({
|
[
|
||||||
...state,
|
combineActions(types.showSignIn, types.fetchUser.complete)
|
||||||
theme: payload
|
]: state => ({
|
||||||
}),
|
|
||||||
[combineActions(types.showSignIn, types.updateThisUser)]: state => ({
|
|
||||||
...state,
|
...state,
|
||||||
isSignInAttempted: true
|
isSignInAttempted: true
|
||||||
}),
|
}),
|
||||||
@ -264,6 +270,6 @@ export default handleActions(
|
|||||||
delayedRedirect: payload
|
delayedRedirect: payload
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
initialState,
|
defaultState,
|
||||||
ns
|
ns
|
||||||
);
|
);
|
||||||
|
46
common/app/routes/Challenges/Preview.jsx
Normal file
46
common/app/routes/Challenges/Preview.jsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import React, { PropTypes, PureComponent } from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
|
import ns from './ns.json';
|
||||||
|
import { isJSEnabledSelector } from './redux';
|
||||||
|
|
||||||
|
const mainId = 'fcc-main-frame';
|
||||||
|
|
||||||
|
const mapStateToProps = state => ({
|
||||||
|
isJSEnabled: isJSEnabledSelector(state)
|
||||||
|
});
|
||||||
|
const mapDispatchToProps = null;
|
||||||
|
const propTypes = {
|
||||||
|
isJSEnabled: PropTypes.bool
|
||||||
|
};
|
||||||
|
|
||||||
|
export class Preview extends PureComponent {
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
isJSEnabled
|
||||||
|
} = this.props;
|
||||||
|
return (
|
||||||
|
<div className={ `${ns}-preview` }>
|
||||||
|
{
|
||||||
|
!isJSEnabled && (
|
||||||
|
<span className={ `${ns}-preview-js-warning` }>
|
||||||
|
JavaScript is disabled. Execute code to enable
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
<iframe
|
||||||
|
className={ `${ns}-preview-frame` }
|
||||||
|
id={ mainId }
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Preview.propTypes = propTypes;
|
||||||
|
Preview.displayName = 'Preview';
|
||||||
|
|
||||||
|
export default connect(
|
||||||
|
mapStateToProps,
|
||||||
|
mapDispatchToProps
|
||||||
|
)(Preview);
|
@ -1,6 +1,13 @@
|
|||||||
// should be the same as the filename and ./ns.json
|
// should be the same as the filename and ./ns.json
|
||||||
@ns: challenges;
|
@ns: challenges;
|
||||||
|
|
||||||
|
// challenge panes are bound to the pane size which in turn is
|
||||||
|
// bound to the total height minus navbar height
|
||||||
|
.max-element-height() {
|
||||||
|
height: 100%;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.@{ns}-title {
|
.@{ns}-title {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
@ -198,4 +205,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.@{ns}-preview {
|
||||||
|
.max-element-height();
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.@{ns}-preview-frame {
|
||||||
|
.max-element-height();
|
||||||
|
border: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
&{ @import "./views/index.less"; }
|
&{ @import "./views/index.less"; }
|
||||||
|
@ -6,15 +6,15 @@ import matchesProperty from 'lodash/matchesProperty';
|
|||||||
import partial from 'lodash/partial';
|
import partial from 'lodash/partial';
|
||||||
import stubTrue from 'lodash/stubTrue';
|
import stubTrue from 'lodash/stubTrue';
|
||||||
|
|
||||||
import {
|
|
||||||
compileHeadTail,
|
|
||||||
setExt,
|
|
||||||
transformContents
|
|
||||||
} from '../../common/utils/polyvinyl';
|
|
||||||
import {
|
import {
|
||||||
fetchScript,
|
fetchScript,
|
||||||
fetchLink
|
fetchLink
|
||||||
} from '../utils/fetch-and-cache.js';
|
} from '../utils/fetch-and-cache.js';
|
||||||
|
import {
|
||||||
|
compileHeadTail,
|
||||||
|
setExt,
|
||||||
|
transformContents
|
||||||
|
} from '../../../../utils/polyvinyl';
|
||||||
|
|
||||||
const htmlCatch = '\n<!--fcc-->\n';
|
const htmlCatch = '\n<!--fcc-->\n';
|
||||||
const jsCatch = '\n;/*fcc*/\n';
|
const jsCatch = '\n;/*fcc*/\n';
|
@ -4,7 +4,7 @@ import identity from 'lodash/identity';
|
|||||||
import stubTrue from 'lodash/stubTrue';
|
import stubTrue from 'lodash/stubTrue';
|
||||||
import conforms from 'lodash/conforms';
|
import conforms from 'lodash/conforms';
|
||||||
|
|
||||||
import castToObservable from '../../common/app/utils/cast-to-observable.js';
|
import castToObservable from '../../../utils/cast-to-observable.js';
|
||||||
|
|
||||||
const HTML$JSReg = /html|js/;
|
const HTML$JSReg = /html|js/;
|
||||||
|
|
@ -4,26 +4,23 @@ import * as babel from 'babel-core';
|
|||||||
import presetEs2015 from 'babel-preset-es2015';
|
import presetEs2015 from 'babel-preset-es2015';
|
||||||
import presetReact from 'babel-preset-react';
|
import presetReact from 'babel-preset-react';
|
||||||
import { Observable } from 'rx';
|
import { Observable } from 'rx';
|
||||||
/* eslint-disable import/no-unresolved */
|
|
||||||
import loopProtect from 'loop-protect';
|
|
||||||
/* eslint-enable import/no-unresolved */
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
transformHeadTailAndContents,
|
transformHeadTailAndContents,
|
||||||
setContent,
|
setContent,
|
||||||
setExt
|
setExt
|
||||||
} from '../../common/utils/polyvinyl.js';
|
} from '../../../../utils/polyvinyl.js';
|
||||||
import castToObservable from '../../common/app/utils/cast-to-observable.js';
|
import castToObservable from '../../../utils/cast-to-observable.js';
|
||||||
|
|
||||||
const babelOptions = { presets: [ presetEs2015, presetReact ] };
|
const babelOptions = { presets: [ presetEs2015, presetReact ] };
|
||||||
loopProtect.hit = function hit(line) {
|
function loopProtectHit(line) {
|
||||||
var err = 'Exiting potential infinite loop at line ' +
|
var err = 'Exiting potential infinite loop at line ' +
|
||||||
line +
|
line +
|
||||||
'. To disable loop protection, write: \n\/\/ noprotect\nas the first ' +
|
'. To disable loop protection, write: \n\/\/ noprotect\nas the first ' +
|
||||||
'line. Beware that if you do have an infinite loop in your code, ' +
|
'line. Beware that if you do have an infinite loop in your code, ' +
|
||||||
'this will crash your browser.';
|
'this will crash your browser.';
|
||||||
throw new Error(err);
|
throw new Error(err);
|
||||||
};
|
}
|
||||||
|
|
||||||
// const sourceReg =
|
// const sourceReg =
|
||||||
// /(<!-- fcc-start-source -->)([\s\S]*?)(?=<!-- fcc-end-source -->)/g;
|
// /(<!-- fcc-start-source -->)([\s\S]*?)(?=<!-- fcc-end-source -->)/g;
|
||||||
@ -57,6 +54,10 @@ export const addLoopProtect = _.cond([
|
|||||||
// No JavaScript in user code, so no need for loopProtect
|
// No JavaScript in user code, so no need for loopProtect
|
||||||
return file;
|
return file;
|
||||||
}
|
}
|
||||||
|
/* eslint-disable import/no-unresolved */
|
||||||
|
const loopProtect = require('loop-protect');
|
||||||
|
/* eslint-enable import/no-unresolved */
|
||||||
|
loopProtect.hit = loopProtectHit;
|
||||||
return setContent(loopProtect(file.contents), file);
|
return setContent(loopProtect(file.contents), file);
|
||||||
}
|
}
|
||||||
],
|
],
|
@ -1,3 +1,4 @@
|
|||||||
|
import _ from 'lodash';
|
||||||
import debug from 'debug';
|
import debug from 'debug';
|
||||||
import { Observable } from 'rx';
|
import { Observable } from 'rx';
|
||||||
import { combineEpics, ofType } from 'redux-epic';
|
import { combineEpics, ofType } from 'redux-epic';
|
||||||
@ -7,8 +8,7 @@ import {
|
|||||||
|
|
||||||
challengeUpdated,
|
challengeUpdated,
|
||||||
onRouteChallenges,
|
onRouteChallenges,
|
||||||
onRouteCurrentChallenge,
|
onRouteCurrentChallenge
|
||||||
updateMain
|
|
||||||
} from './';
|
} from './';
|
||||||
import { getNS as entitiesSelector } from '../../../entities';
|
import { getNS as entitiesSelector } from '../../../entities';
|
||||||
import {
|
import {
|
||||||
@ -38,21 +38,15 @@ export function challengeUpdatedEpic(actions, { getState }) {
|
|||||||
// this will be an empty object
|
// this will be an empty object
|
||||||
// We wait instead for the fetchChallenge.complete to complete the UI state
|
// We wait instead for the fetchChallenge.complete to complete the UI state
|
||||||
.filter(({ dashedName }) => !!dashedName)
|
.filter(({ dashedName }) => !!dashedName)
|
||||||
.flatMap(challenge =>
|
// send the challenge to update UI and trigger main iframe to update
|
||||||
// send the challenge to update UI and update main iframe with inital
|
// use unary to prevent index from being passed to func
|
||||||
// challenge
|
.map(_.unary(challengeUpdated));
|
||||||
Observable.of(challengeUpdated(challenge), updateMain())
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// used to reset users code on request
|
// used to reset users code on request
|
||||||
export function resetChallengeEpic(actions, { getState }) {
|
export function resetChallengeEpic(actions, { getState }) {
|
||||||
return actions::ofType(types.clickOnReset)
|
return actions::ofType(types.clickOnReset)
|
||||||
.flatMap(() =>
|
.map(_.flow(getState, challengeSelector, challengeUpdated));
|
||||||
Observable.of(
|
|
||||||
challengeUpdated(challengeSelector(getState())),
|
|
||||||
updateMain()
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function nextChallengeEpic(actions, { getState }) {
|
export function nextChallengeEpic(actions, { getState }) {
|
||||||
@ -88,7 +82,7 @@ export function nextChallengeEpic(actions, { getState }) {
|
|||||||
{ isDev }
|
{ isDev }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
/* this requires user data not available yet
|
/* // TODO(berks): get this to work
|
||||||
if (isNewSuperBlock || isNewBlock) {
|
if (isNewSuperBlock || isNewBlock) {
|
||||||
const getName = isNewSuperBlock ?
|
const getName = isNewSuperBlock ?
|
||||||
getCurrentSuperBlockName :
|
getCurrentSuperBlockName :
|
||||||
|
@ -2,26 +2,25 @@ import { Observable } from 'rx';
|
|||||||
import { combineEpics, ofType } from 'redux-epic';
|
import { combineEpics, ofType } from 'redux-epic';
|
||||||
import store from 'store';
|
import store from 'store';
|
||||||
|
|
||||||
import { removeCodeUri, getCodeUri } from '../utils/code-uri';
|
|
||||||
|
|
||||||
import { setContent } from '../../common/utils/polyvinyl';
|
|
||||||
|
|
||||||
import {
|
|
||||||
types as app,
|
|
||||||
userSelector,
|
|
||||||
challengeSelector
|
|
||||||
} from '../../common/app/redux';
|
|
||||||
import { makeToast } from '../../common/app/Toasts/redux';
|
|
||||||
import {
|
import {
|
||||||
types,
|
types,
|
||||||
updateMain,
|
storedCodeFound,
|
||||||
lockUntrustedCode,
|
noStoredCodeFound,
|
||||||
|
previousSolutionFound,
|
||||||
|
|
||||||
keySelector,
|
keySelector,
|
||||||
codeLockedSelector
|
codeLockedSelector
|
||||||
} from '../../common/app/routes/Challenges/redux';
|
} from './';
|
||||||
|
import { removeCodeUri, getCodeUri } from '../utils/code-uri.js';
|
||||||
|
|
||||||
import { filesSelector, savedCodeFound } from '../../common/app/files';
|
import {
|
||||||
|
types as app,
|
||||||
|
challengeSelector,
|
||||||
|
previousSolutionSelector
|
||||||
|
} from '../../../redux';
|
||||||
|
import { filesSelector } from '../../../files';
|
||||||
|
import { makeToast } from '../../../Toasts/redux';
|
||||||
|
import { setContent } from '../../../../utils/polyvinyl.js';
|
||||||
|
|
||||||
const legacyPrefixes = [
|
const legacyPrefixes = [
|
||||||
'Bonfire: ',
|
'Bonfire: ',
|
||||||
@ -86,10 +85,11 @@ export function loadCodeEpic(actions, { getState }, { window, location }) {
|
|||||||
actions::ofType(types.onRouteChallenges)
|
actions::ofType(types.onRouteChallenges)
|
||||||
.distinctUntilChanged(({ payload: { dashedName } }) => dashedName)
|
.distinctUntilChanged(({ payload: { dashedName } }) => dashedName)
|
||||||
)
|
)
|
||||||
|
// make sure we are not SSR
|
||||||
|
.filter(() => !!window)
|
||||||
.flatMap(() => {
|
.flatMap(() => {
|
||||||
let finalFiles;
|
let finalFiles;
|
||||||
const state = getState();
|
const state = getState();
|
||||||
const user = userSelector(state);
|
|
||||||
const challenge = challengeSelector(state);
|
const challenge = challengeSelector(state);
|
||||||
const key = keySelector(state);
|
const key = keySelector(state);
|
||||||
const files = filesSelector(state);
|
const files = filesSelector(state);
|
||||||
@ -105,11 +105,10 @@ export function loadCodeEpic(actions, { getState }, { window, location }) {
|
|||||||
finalFiles = legacyToFile(codeUriFound, files, key);
|
finalFiles = legacyToFile(codeUriFound, files, key);
|
||||||
removeCodeUri(location, window.history);
|
removeCodeUri(location, window.history);
|
||||||
return Observable.of(
|
return Observable.of(
|
||||||
lockUntrustedCode(),
|
|
||||||
makeToast({
|
makeToast({
|
||||||
message: 'I found code in the URI. Loading now.'
|
message: 'I found code in the URI. Loading now.'
|
||||||
}),
|
}),
|
||||||
savedCodeFound(finalFiles, challenge)
|
storedCodeFound(challenge, finalFiles)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -128,13 +127,12 @@ export function loadCodeEpic(actions, { getState }, { window, location }) {
|
|||||||
makeToast({
|
makeToast({
|
||||||
message: 'I found some saved work. Loading now.'
|
message: 'I found some saved work. Loading now.'
|
||||||
}),
|
}),
|
||||||
savedCodeFound(finalFiles, challenge),
|
storedCodeFound(challenge, finalFiles)
|
||||||
updateMain()
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.challengeMap && user.challengeMap[id]) {
|
if (previousSolutionSelector(getState())) {
|
||||||
const userChallenge = user.challengeMap[id];
|
const userChallenge = previousSolutionSelector(getState());
|
||||||
if (userChallenge.files) {
|
if (userChallenge.files) {
|
||||||
finalFiles = userChallenge.files;
|
finalFiles = userChallenge.files;
|
||||||
} else if (userChallenge.solution) {
|
} else if (userChallenge.solution) {
|
||||||
@ -145,14 +143,48 @@ export function loadCodeEpic(actions, { getState }, { window, location }) {
|
|||||||
makeToast({
|
makeToast({
|
||||||
message: 'I found a previous solved solution. Loading now.'
|
message: 'I found a previous solved solution. Loading now.'
|
||||||
}),
|
}),
|
||||||
savedCodeFound(finalFiles, challenge),
|
previousSolutionFound(challenge, finalFiles)
|
||||||
updateMain()
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return Observable.of(noStoredCodeFound());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findPreviousSolutionEpic(actions, { getState }) {
|
||||||
|
return Observable.combineLatest(
|
||||||
|
actions::ofType(types.noStoredCodeFound),
|
||||||
|
actions::ofType(app.fetchUser.complete)
|
||||||
|
)
|
||||||
|
.map(() => previousSolutionSelector(getState()))
|
||||||
|
.filter(Boolean)
|
||||||
|
.flatMap(userChallenge => {
|
||||||
|
const challenge = challengeSelector(getState());
|
||||||
|
let finalFiles;
|
||||||
|
if (userChallenge.files) {
|
||||||
|
finalFiles = userChallenge.files;
|
||||||
|
} else if (userChallenge.solution) {
|
||||||
|
const files = filesSelector(getState());
|
||||||
|
const key = keySelector(getState());
|
||||||
|
finalFiles = legacyToFile(userChallenge.solution, files, key);
|
||||||
|
}
|
||||||
|
if (finalFiles) {
|
||||||
|
return Observable.of(
|
||||||
|
makeToast({
|
||||||
|
message: 'I found a previous solved solution. Loading now.'
|
||||||
|
}),
|
||||||
|
previousSolutionFound(challenge, finalFiles)
|
||||||
|
);
|
||||||
|
}
|
||||||
return Observable.empty();
|
return Observable.empty();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default combineEpics(saveCodeEpic, loadCodeEpic, clearCodeEpic);
|
|
||||||
|
export default combineEpics(
|
||||||
|
saveCodeEpic,
|
||||||
|
loadCodeEpic,
|
||||||
|
clearCodeEpic,
|
||||||
|
findPreviousSolutionEpic
|
||||||
|
);
|
@ -1,25 +0,0 @@
|
|||||||
import { ofType, combineEpics } from 'redux-epic';
|
|
||||||
|
|
||||||
import {
|
|
||||||
types,
|
|
||||||
keySelector
|
|
||||||
} from './';
|
|
||||||
|
|
||||||
import { updateFile } from '../../../files';
|
|
||||||
|
|
||||||
export function classicEditorEpic(actions, { getState }) {
|
|
||||||
return actions::ofType(types.classicEditorUpdated)
|
|
||||||
.pluck('payload')
|
|
||||||
.map(content => updateFile({
|
|
||||||
content,
|
|
||||||
key: keySelector(getState())
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function modernEditorEpic(actions) {
|
|
||||||
return actions::ofType(types.modernEditorUpdated)
|
|
||||||
.pluck('payload')
|
|
||||||
.map(updateFile);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default combineEpics(classicEditorEpic, modernEditorEpic);
|
|
132
common/app/routes/Challenges/redux/execute-challenge-epic.js
Normal file
132
common/app/routes/Challenges/redux/execute-challenge-epic.js
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
import _ from 'lodash';
|
||||||
|
import { Observable, Subject } from 'rx';
|
||||||
|
import { combineEpics, ofType } from 'redux-epic';
|
||||||
|
|
||||||
|
import {
|
||||||
|
types,
|
||||||
|
|
||||||
|
initOutput,
|
||||||
|
updateOutput,
|
||||||
|
updateTests,
|
||||||
|
checkChallenge,
|
||||||
|
|
||||||
|
codeLockedSelector,
|
||||||
|
showPreviewSelector,
|
||||||
|
testsSelector
|
||||||
|
} from './';
|
||||||
|
import {
|
||||||
|
buildFromFiles,
|
||||||
|
buildBackendChallenge
|
||||||
|
} from '../utils/build.js';
|
||||||
|
import {
|
||||||
|
runTestsInTestFrame,
|
||||||
|
createTestFramer,
|
||||||
|
createMainFramer
|
||||||
|
} from '../utils/frame.js';
|
||||||
|
import {
|
||||||
|
createErrorObservable,
|
||||||
|
|
||||||
|
challengeSelector
|
||||||
|
} from '../../../redux';
|
||||||
|
|
||||||
|
import { filesSelector } from '../../../files';
|
||||||
|
|
||||||
|
const executeDebounceTimeout = 750;
|
||||||
|
export function updateMainEpic(actions, { getState }, { document }) {
|
||||||
|
return Observable.of(document)
|
||||||
|
// if document is not defined then none of this epic will run
|
||||||
|
// this prevents issues during SSR
|
||||||
|
.filter(Boolean)
|
||||||
|
.flatMapLatest(() => {
|
||||||
|
const proxyLogger = new Subject();
|
||||||
|
const frameMain = createMainFramer(document, getState, proxyLogger);
|
||||||
|
const buildAndFrameMain = actions::ofType(
|
||||||
|
types.unlockUntrustedCode,
|
||||||
|
types.modernEditorUpdated,
|
||||||
|
types.classicEditorUpdated,
|
||||||
|
types.executeChallenge,
|
||||||
|
types.challengeUpdated
|
||||||
|
)
|
||||||
|
.debounce(executeDebounceTimeout)
|
||||||
|
// if isCodeLocked do not run challenges
|
||||||
|
.filter(() => (
|
||||||
|
!codeLockedSelector(getState()) &&
|
||||||
|
showPreviewSelector(getState())
|
||||||
|
))
|
||||||
|
.map(getState)
|
||||||
|
.flatMapLatest(state => {
|
||||||
|
const files = filesSelector(state);
|
||||||
|
const { required = [] } = challengeSelector(state);
|
||||||
|
return buildFromFiles(files, required, true)
|
||||||
|
.map(frameMain)
|
||||||
|
.ignoreElements()
|
||||||
|
.catch(createErrorObservable);
|
||||||
|
});
|
||||||
|
return Observable.merge(buildAndFrameMain, proxyLogger.map(updateOutput));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function executeChallengeEpic(actions, { getState }, { document }) {
|
||||||
|
return Observable.of(document)
|
||||||
|
// if document is not defined then none of this epic will run
|
||||||
|
// this prevents issues during SSR
|
||||||
|
.filter(Boolean)
|
||||||
|
.flatMapLatest(() => {
|
||||||
|
const frameReady = new Subject();
|
||||||
|
const frameTests = createTestFramer(document, getState, frameReady);
|
||||||
|
const challengeResults = frameReady
|
||||||
|
.pluck('checkChallengePayload')
|
||||||
|
.map(checkChallengePayload => ({
|
||||||
|
checkChallengePayload,
|
||||||
|
tests: testsSelector(getState())
|
||||||
|
}))
|
||||||
|
.flatMap(({ checkChallengePayload, tests }) => {
|
||||||
|
const postTests = Observable.of(
|
||||||
|
updateOutput('// tests completed'),
|
||||||
|
checkChallenge(checkChallengePayload)
|
||||||
|
).delay(250);
|
||||||
|
// run the tests within the test iframe
|
||||||
|
return runTestsInTestFrame(document, tests)
|
||||||
|
.flatMap(tests => {
|
||||||
|
return Observable.from(tests)
|
||||||
|
.map(({ message }) => message)
|
||||||
|
// make sure that the test message is a non empty string
|
||||||
|
.filter(_.overEvery(_.isString, Boolean))
|
||||||
|
.map(updateOutput)
|
||||||
|
.concat(Observable.of(updateTests(tests)));
|
||||||
|
})
|
||||||
|
.concat(postTests);
|
||||||
|
});
|
||||||
|
const buildAndFrameChallenge = actions::ofType(types.executeChallenge)
|
||||||
|
.debounce(executeDebounceTimeout)
|
||||||
|
// if isCodeLocked do not run challenges
|
||||||
|
.filter(() => !codeLockedSelector(getState()))
|
||||||
|
.flatMapLatest(() => {
|
||||||
|
const state = getState();
|
||||||
|
const files = filesSelector(state);
|
||||||
|
const {
|
||||||
|
required = [],
|
||||||
|
type: challengeType
|
||||||
|
} = challengeSelector(state);
|
||||||
|
if (challengeType === 'backend') {
|
||||||
|
return buildBackendChallenge(state)
|
||||||
|
.do(frameTests)
|
||||||
|
.ignoreElements()
|
||||||
|
.startWith(initOutput('// running test'))
|
||||||
|
.catch(createErrorObservable);
|
||||||
|
}
|
||||||
|
return buildFromFiles(files, required, false)
|
||||||
|
.do(frameTests)
|
||||||
|
.ignoreElements()
|
||||||
|
.startWith(initOutput('// running test'))
|
||||||
|
.catch(createErrorObservable);
|
||||||
|
});
|
||||||
|
return Observable.merge(
|
||||||
|
buildAndFrameChallenge,
|
||||||
|
challengeResults
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default combineEpics(executeChallengeEpic, updateMainEpic);
|
@ -1,3 +1,4 @@
|
|||||||
|
import _ from 'lodash';
|
||||||
import {
|
import {
|
||||||
combineActions,
|
combineActions,
|
||||||
combineReducers,
|
combineReducers,
|
||||||
@ -12,14 +13,21 @@ import noop from 'lodash/noop';
|
|||||||
import bugEpic from './bug-epic';
|
import bugEpic from './bug-epic';
|
||||||
import completionEpic from './completion-epic.js';
|
import completionEpic from './completion-epic.js';
|
||||||
import challengeEpic from './challenge-epic.js';
|
import challengeEpic from './challenge-epic.js';
|
||||||
import editorEpic from './editor-epic.js';
|
import executeChallengeEpic from './execute-challenge-epic.js';
|
||||||
|
import codeStorageEpic from './code-storage-epic.js';
|
||||||
|
|
||||||
import ns from '../ns.json';
|
import ns from '../ns.json';
|
||||||
|
import stepReducer, { epics as stepEpics } from '../views/step/redux';
|
||||||
|
import quizReducer from '../views/quiz/redux';
|
||||||
|
import projectReducer from '../views/project/redux';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createTests,
|
createTests,
|
||||||
loggerToStr,
|
loggerToStr,
|
||||||
submitTypes,
|
submitTypes,
|
||||||
viewTypes
|
viewTypes,
|
||||||
|
getFileKey,
|
||||||
|
challengeToFiles
|
||||||
} from '../utils';
|
} from '../utils';
|
||||||
import {
|
import {
|
||||||
types as app,
|
types as app,
|
||||||
@ -27,19 +35,20 @@ import {
|
|||||||
} from '../../../redux';
|
} from '../../../redux';
|
||||||
import { html } from '../../../utils/challengeTypes.js';
|
import { html } from '../../../utils/challengeTypes.js';
|
||||||
import blockNameify from '../../../utils/blockNameify.js';
|
import blockNameify from '../../../utils/blockNameify.js';
|
||||||
import { getFileKey } from '../../../utils/classic-file.js';
|
import { updateFileMetaCreator, createFilesMetaCreator } from '../../../files';
|
||||||
import stepReducer, { epics as stepEpics } from '../views/step/redux';
|
|
||||||
import quizReducer from '../views/quiz/redux';
|
|
||||||
import projectReducer from '../views/project/redux';
|
|
||||||
|
|
||||||
// this is not great but is ok until we move to a different form type
|
// this is not great but is ok until we move to a different form type
|
||||||
export projectNormalizer from '../views/project/redux';
|
export projectNormalizer from '../views/project/redux';
|
||||||
|
|
||||||
|
const challengeToFilesMetaCreator =
|
||||||
|
_.flow(challengeToFiles, createFilesMetaCreator);
|
||||||
|
|
||||||
export const epics = [
|
export const epics = [
|
||||||
bugEpic,
|
bugEpic,
|
||||||
completionEpic,
|
|
||||||
challengeEpic,
|
challengeEpic,
|
||||||
editorEpic,
|
codeStorageEpic,
|
||||||
|
completionEpic,
|
||||||
|
executeChallengeEpic,
|
||||||
...stepEpics
|
...stepEpics
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -53,7 +62,6 @@ export const types = createTypes([
|
|||||||
'challengeUpdated',
|
'challengeUpdated',
|
||||||
'clickOnReset',
|
'clickOnReset',
|
||||||
'updateHint',
|
'updateHint',
|
||||||
'lockUntrustedCode',
|
|
||||||
'unlockUntrustedCode',
|
'unlockUntrustedCode',
|
||||||
'closeChallengeModal',
|
'closeChallengeModal',
|
||||||
'updateSuccessMessage',
|
'updateSuccessMessage',
|
||||||
@ -62,10 +70,6 @@ export const types = createTypes([
|
|||||||
|
|
||||||
// rechallenge
|
// rechallenge
|
||||||
'executeChallenge',
|
'executeChallenge',
|
||||||
'updateMain',
|
|
||||||
'runTests',
|
|
||||||
'frameMain',
|
|
||||||
'frameTests',
|
|
||||||
'updateOutput',
|
'updateOutput',
|
||||||
'initOutput',
|
'initOutput',
|
||||||
'updateTests',
|
'updateTests',
|
||||||
@ -86,7 +90,12 @@ export const types = createTypes([
|
|||||||
'togglePreview',
|
'togglePreview',
|
||||||
'toggleSidePanel',
|
'toggleSidePanel',
|
||||||
'toggleStep',
|
'toggleStep',
|
||||||
'toggleModernEditor'
|
'toggleModernEditor',
|
||||||
|
|
||||||
|
// code storage
|
||||||
|
'storedCodeFound',
|
||||||
|
'noStoredCodeFound',
|
||||||
|
'previousSolutionFound'
|
||||||
], ns);
|
], ns);
|
||||||
|
|
||||||
// routes
|
// routes
|
||||||
@ -95,24 +104,29 @@ export const onRouteCurrentChallenge =
|
|||||||
createAction(types.onRouteCurrentChallenge);
|
createAction(types.onRouteCurrentChallenge);
|
||||||
|
|
||||||
// classic
|
// classic
|
||||||
export const classicEditorUpdated = createAction(types.classicEditorUpdated);
|
export const classicEditorUpdated = createAction(
|
||||||
|
types.classicEditorUpdated,
|
||||||
|
null,
|
||||||
|
updateFileMetaCreator
|
||||||
|
);
|
||||||
// modern
|
// modern
|
||||||
export const modernEditorUpdated = createAction(
|
export const modernEditorUpdated = createAction(
|
||||||
types.modernEditorUpdated,
|
types.modernEditorUpdated,
|
||||||
(key, content) => ({ key, content })
|
null,
|
||||||
|
createFilesMetaCreator
|
||||||
);
|
);
|
||||||
// challenges
|
// challenges
|
||||||
export const closeChallengeModal = createAction(types.closeChallengeModal);
|
export const closeChallengeModal = createAction(types.closeChallengeModal);
|
||||||
export const updateHint = createAction(types.updateHint);
|
export const updateHint = createAction(types.updateHint);
|
||||||
export const lockUntrustedCode = createAction(types.lockUntrustedCode);
|
|
||||||
export const unlockUntrustedCode = createAction(
|
export const unlockUntrustedCode = createAction(
|
||||||
types.unlockUntrustedCode,
|
types.unlockUntrustedCode,
|
||||||
() => null
|
_.noop
|
||||||
);
|
);
|
||||||
export const updateSuccessMessage = createAction(types.updateSuccessMessage);
|
export const updateSuccessMessage = createAction(types.updateSuccessMessage);
|
||||||
export const challengeUpdated = createAction(
|
export const challengeUpdated = createAction(
|
||||||
types.challengeUpdated,
|
types.challengeUpdated,
|
||||||
challenge => ({ challenge })
|
challenge => ({ challenge }),
|
||||||
|
challengeToFilesMetaCreator
|
||||||
);
|
);
|
||||||
export const clickOnReset = createAction(types.clickOnReset);
|
export const clickOnReset = createAction(types.clickOnReset);
|
||||||
|
|
||||||
@ -122,11 +136,6 @@ export const executeChallenge = createAction(
|
|||||||
noop,
|
noop,
|
||||||
);
|
);
|
||||||
|
|
||||||
export const updateMain = createAction(types.updateMain);
|
|
||||||
export const frameMain = createAction(types.frameMain);
|
|
||||||
export const frameTests = createAction(types.frameTests);
|
|
||||||
|
|
||||||
export const runTests = createAction(types.runTests);
|
|
||||||
export const updateTests = createAction(types.updateTests);
|
export const updateTests = createAction(types.updateTests);
|
||||||
|
|
||||||
export const initOutput = createAction(types.initOutput, loggerToStr);
|
export const initOutput = createAction(types.initOutput, loggerToStr);
|
||||||
@ -148,6 +157,19 @@ export const closeBugModal = createAction(types.closeBugModal);
|
|||||||
export const openIssueSearch = createAction(types.openIssueSearch);
|
export const openIssueSearch = createAction(types.openIssueSearch);
|
||||||
export const createIssue = createAction(types.createIssue);
|
export const createIssue = createAction(types.createIssue);
|
||||||
|
|
||||||
|
// code storage
|
||||||
|
export const storedCodeFound = createAction(
|
||||||
|
types.storedCodeFound,
|
||||||
|
null,
|
||||||
|
challengeToFilesMetaCreator,
|
||||||
|
);
|
||||||
|
export const noStoredCodeFound = createAction(types.noStoredCodeFound);
|
||||||
|
export const previousSolutionFound = createAction(
|
||||||
|
types.previousSolutionFound,
|
||||||
|
null,
|
||||||
|
challengeToFilesMetaCreator
|
||||||
|
);
|
||||||
|
|
||||||
const initialUiState = {
|
const initialUiState = {
|
||||||
output: null,
|
output: null,
|
||||||
isChallengeModalOpen: false,
|
isChallengeModalOpen: false,
|
||||||
@ -157,6 +179,7 @@ const initialUiState = {
|
|||||||
|
|
||||||
const initialState = {
|
const initialState = {
|
||||||
isCodeLocked: false,
|
isCodeLocked: false,
|
||||||
|
isJSEnabled: true,
|
||||||
id: '',
|
id: '',
|
||||||
challenge: '',
|
challenge: '',
|
||||||
helpChatRoom: 'Help',
|
helpChatRoom: 'Help',
|
||||||
@ -176,6 +199,8 @@ export const outputSelector = state => getNS(state).output;
|
|||||||
export const successMessageSelector = state => getNS(state).successMessage;
|
export const successMessageSelector = state => getNS(state).successMessage;
|
||||||
export const hintIndexSelector = state => getNS(state).hintIndex;
|
export const hintIndexSelector = state => getNS(state).hintIndex;
|
||||||
export const codeLockedSelector = state => getNS(state).isCodeLocked;
|
export const codeLockedSelector = state => getNS(state).isCodeLocked;
|
||||||
|
export const isCodeLockedSelector = state => getNS(state).isCodeLocked;
|
||||||
|
export const isJSEnabledSelector = state => getNS(state).isJSEnabled;
|
||||||
export const chatRoomSelector = state => getNS(state).helpChatRoom;
|
export const chatRoomSelector = state => getNS(state).helpChatRoom;
|
||||||
export const challengeModalSelector =
|
export const challengeModalSelector =
|
||||||
state => getNS(state).isChallengeModalOpen;
|
state => getNS(state).isChallengeModalOpen;
|
||||||
@ -204,7 +229,10 @@ export const challengeMetaSelector = createSelector(
|
|||||||
submitTypes[challengeType] ||
|
submitTypes[challengeType] ||
|
||||||
submitTypes[challenge && challenge.type] ||
|
submitTypes[challenge && challenge.type] ||
|
||||||
'tests',
|
'tests',
|
||||||
showPreview: challengeType === html,
|
showPreview: (
|
||||||
|
challengeType === html ||
|
||||||
|
type === 'modern'
|
||||||
|
),
|
||||||
mode: challenge && challengeType === html ?
|
mode: challenge && challengeType === html ?
|
||||||
'text/html' :
|
'text/html' :
|
||||||
'javascript'
|
'javascript'
|
||||||
@ -250,8 +278,9 @@ export default combineReducers(
|
|||||||
...state,
|
...state,
|
||||||
successMessage: payload
|
successMessage: payload
|
||||||
}),
|
}),
|
||||||
[types.lockUntrustedCode]: state => ({
|
[types.storedCodeFound]: state => ({
|
||||||
...state,
|
...state,
|
||||||
|
isJSEnabled: false,
|
||||||
isCodeLocked: true
|
isCodeLocked: true
|
||||||
}),
|
}),
|
||||||
[types.unlockUntrustedCode]: state => ({
|
[types.unlockUntrustedCode]: state => ({
|
||||||
@ -260,8 +289,18 @@ export default combineReducers(
|
|||||||
}),
|
}),
|
||||||
[types.executeChallenge]: state => ({
|
[types.executeChallenge]: state => ({
|
||||||
...state,
|
...state,
|
||||||
|
isJSEnabled: true,
|
||||||
tests: state.tests.map(test => ({ ...test, err: false, pass: false }))
|
tests: state.tests.map(test => ({ ...test, err: false, pass: false }))
|
||||||
}),
|
}),
|
||||||
|
[
|
||||||
|
combineActions(
|
||||||
|
types.classicEditorUpdated,
|
||||||
|
types.modernEditorUpdated
|
||||||
|
)
|
||||||
|
]: state => ({
|
||||||
|
...state,
|
||||||
|
isJSEnabled: false
|
||||||
|
}),
|
||||||
|
|
||||||
// classic/modern
|
// classic/modern
|
||||||
[types.initOutput]: (state, { payload: output }) => ({
|
[types.initOutput]: (state, { payload: output }) => ({
|
||||||
|
@ -2,7 +2,7 @@ import { Observable } from 'rx';
|
|||||||
import { getValues } from 'redux-form';
|
import { getValues } from 'redux-form';
|
||||||
import identity from 'lodash/identity';
|
import identity from 'lodash/identity';
|
||||||
|
|
||||||
import { fetchScript } from '../utils/fetch-and-cache.js';
|
import { fetchScript } from './fetch-and-cache.js';
|
||||||
import throwers from '../rechallenge/throwers';
|
import throwers from '../rechallenge/throwers';
|
||||||
import {
|
import {
|
||||||
applyTransformers,
|
applyTransformers,
|
||||||
@ -16,7 +16,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
createFileStream,
|
createFileStream,
|
||||||
pipe
|
pipe
|
||||||
} from '../../common/utils/polyvinyl.js';
|
} from '../../../../utils/polyvinyl.js';
|
||||||
|
|
||||||
|
|
||||||
const jQuery = {
|
const jQuery = {
|
@ -1,5 +1,5 @@
|
|||||||
import flow from 'lodash/flow';
|
import _ from 'lodash';
|
||||||
import { decodeFcc } from '../../common/utils/encode-decode';
|
import { decodeFcc } from '../../../../utils/encode-decode';
|
||||||
|
|
||||||
const queryRegex = /^(\?|#\?)/;
|
const queryRegex = /^(\?|#\?)/;
|
||||||
export function legacyIsInQuery(query, decode) {
|
export function legacyIsInQuery(query, decode) {
|
||||||
@ -42,7 +42,7 @@ export function getKeyInQuery(query, keyToFind = '') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getLegacySolutionFromQuery(query = '', decode) {
|
export function getLegacySolutionFromQuery(query = '', decode) {
|
||||||
return flow(
|
return _.flow(
|
||||||
getKeyInQuery,
|
getKeyInQuery,
|
||||||
decode,
|
decode,
|
||||||
decodeFcc
|
decodeFcc
|
@ -1,5 +1,5 @@
|
|||||||
import { Observable } from 'rx';
|
import { Observable } from 'rx';
|
||||||
import { ajax$ } from '../../common/utils/ajax-stream';
|
import { ajax$ } from '../../../../utils/ajax-stream';
|
||||||
|
|
||||||
// value used to break browser ajax caching
|
// value used to break browser ajax caching
|
||||||
const cacheBreakerValue = Math.random();
|
const cacheBreakerValue = Math.random();
|
136
common/app/routes/Challenges/utils/frame.js
Normal file
136
common/app/routes/Challenges/utils/frame.js
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
import _ from 'lodash';
|
||||||
|
import Rx, { Observable } from 'rx';
|
||||||
|
import { ShallowWrapper, ReactWrapper } from 'enzyme';
|
||||||
|
import Adapter15 from 'enzyme-adapter-react-15';
|
||||||
|
import { isJSEnabledSelector } from '../redux';
|
||||||
|
|
||||||
|
// 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>
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const runTestsInTestFrame = (document, tests) => Observable.defer(() => {
|
||||||
|
const { contentDocument: frame } = document.getElementById(testId);
|
||||||
|
return frame.__runTests(tests);
|
||||||
|
});
|
||||||
|
|
||||||
|
const createFrame = (document, getState, id) => ctx => {
|
||||||
|
const isJSEnabled = isJSEnabledSelector(getState());
|
||||||
|
const frame = document.createElement('iframe');
|
||||||
|
frame.id = id;
|
||||||
|
if (!isJSEnabled) {
|
||||||
|
frame.sandbox = 'allow-same-origin';
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...ctx,
|
||||||
|
element: frame
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const hiddenFrameClassname = 'hide-test-frame';
|
||||||
|
const mountFrame = document => ({ element, ...rest })=> {
|
||||||
|
const oldFrame = document.getElementById(element.id);
|
||||||
|
if (oldFrame) {
|
||||||
|
element.className = oldFrame.className || hiddenFrameClassname;
|
||||||
|
oldFrame.parentNode.replaceChild(element, oldFrame);
|
||||||
|
} else {
|
||||||
|
element.className = hiddenFrameClassname;
|
||||||
|
document.body.appendChild(element);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
element,
|
||||||
|
document: element.contentDocument,
|
||||||
|
window: element.contentWindow
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const addDepsToDocument = ctx => {
|
||||||
|
ctx.document.Rx = Rx;
|
||||||
|
|
||||||
|
// using require here prevents nodejs issues as loop-protect
|
||||||
|
// is added to the window object by webpack and not available to
|
||||||
|
// us server side.
|
||||||
|
/* eslint-disable import/no-unresolved */
|
||||||
|
ctx.document.loopProtect = require('loop-protect');
|
||||||
|
/* eslint-enable import/no-unresolved */
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildProxyConsole = proxyLogger => ctx => {
|
||||||
|
const oldLog = ctx.window.console.log.bind(ctx.window.console);
|
||||||
|
ctx.window.__console = {};
|
||||||
|
ctx.window.__console.log = function proxyConsole(...args) {
|
||||||
|
proxyLogger.onNext(args);
|
||||||
|
return oldLog(...args);
|
||||||
|
};
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
|
|
||||||
|
const writeTestDepsToDocument = frameReady => ctx => {
|
||||||
|
const {
|
||||||
|
document: tests,
|
||||||
|
sources,
|
||||||
|
checkChallengePayload
|
||||||
|
} = ctx;
|
||||||
|
// add enzyme
|
||||||
|
// TODO: do programatically
|
||||||
|
// TODO: webpack lazyload this
|
||||||
|
tests.Enzyme = {
|
||||||
|
shallow: (node, options) => new ShallowWrapper(node, null, {
|
||||||
|
...options,
|
||||||
|
adapter: new Adapter15()
|
||||||
|
}),
|
||||||
|
mount: (node, options) => new ReactWrapper(node, null, {
|
||||||
|
...options,
|
||||||
|
adapter: new Adapter15()
|
||||||
|
})
|
||||||
|
};
|
||||||
|
// default for classic challenges
|
||||||
|
// should not be used for modern
|
||||||
|
tests.__source = sources['index'] || '';
|
||||||
|
tests.__getUserInput = key => sources[key];
|
||||||
|
tests.__checkChallengePayload = checkChallengePayload;
|
||||||
|
tests.__frameReady = frameReady;
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
|
|
||||||
|
function writeToFrame(content, frame) {
|
||||||
|
frame.open();
|
||||||
|
frame.write(content);
|
||||||
|
frame.close();
|
||||||
|
return frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
const writeContentToFrame = ctx => {
|
||||||
|
writeToFrame(createHeader(ctx.element.id) + ctx.build, ctx.document);
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createMainFramer = (document, getState, proxyLogger) => _.flow(
|
||||||
|
createFrame(document, getState, mainId),
|
||||||
|
mountFrame(document),
|
||||||
|
addDepsToDocument,
|
||||||
|
buildProxyConsole(proxyLogger),
|
||||||
|
writeContentToFrame,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const createTestFramer = (document, getState, frameReady) => _.flow(
|
||||||
|
createFrame(document, getState, testId),
|
||||||
|
mountFrame(document),
|
||||||
|
addDepsToDocument,
|
||||||
|
writeTestDepsToDocument(frameReady),
|
||||||
|
writeContentToFrame,
|
||||||
|
);
|
@ -1,5 +1,15 @@
|
|||||||
import * as challengeTypes from '../../utils/challengeTypes';
|
import _ from 'lodash';
|
||||||
|
|
||||||
|
import * as challengeTypes from '../../../utils/challengeTypes.js';
|
||||||
|
import { createPoly } from '../../../../utils/polyvinyl.js';
|
||||||
|
import { decodeScriptTags } from '../../../../utils/encode-decode.js';
|
||||||
|
|
||||||
|
// turn challengeType to file ext
|
||||||
|
const pathsMap = {
|
||||||
|
[ challengeTypes.html ]: 'html',
|
||||||
|
[ challengeTypes.js ]: 'js',
|
||||||
|
[ challengeTypes.bonfire ]: 'js'
|
||||||
|
};
|
||||||
// determine the component to view for each challenge
|
// determine the component to view for each challenge
|
||||||
export const viewTypes = {
|
export const viewTypes = {
|
||||||
[ challengeTypes.html ]: 'classic',
|
[ challengeTypes.html ]: 'classic',
|
||||||
@ -43,6 +53,66 @@ export const submitTypes = {
|
|||||||
// has html that should be rendered
|
// has html that should be rendered
|
||||||
export const descriptionRegex = /\<blockquote|\<ol|\<h4|\<table/;
|
export const descriptionRegex = /\<blockquote|\<ol|\<h4|\<table/;
|
||||||
|
|
||||||
|
export function arrayToString(seedData = ['']) {
|
||||||
|
seedData = Array.isArray(seedData) ? seedData : [seedData];
|
||||||
|
return seedData.reduce((seed, line) => '' + seed + line + '\n', '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSeed({ challengeSeed = [] } = {}) {
|
||||||
|
return _.flow(
|
||||||
|
arrayToString,
|
||||||
|
decodeScriptTags
|
||||||
|
)(challengeSeed);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFileKey({ challengeType }) {
|
||||||
|
return 'index' + (pathsMap[challengeType] || 'html');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPreFile({ challengeType }) {
|
||||||
|
return {
|
||||||
|
name: 'index',
|
||||||
|
ext: pathsMap[challengeType] || 'html',
|
||||||
|
key: getFileKey({ challengeType })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function challengeToFiles(challenge, files) {
|
||||||
|
const previousWork = !!files;
|
||||||
|
files = files || challenge.files || {};
|
||||||
|
if (challenge.type === 'modern') {
|
||||||
|
return _.reduce(files, (files, file) => {
|
||||||
|
// TODO(berks): need to make sure head/tail are fresh from fCC
|
||||||
|
files[file.key] = createPoly(file);
|
||||||
|
return files;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
challenge.challengeType !== challengeTypes.html &&
|
||||||
|
challenge.challengeType !== challengeTypes.js &&
|
||||||
|
challenge.challengeType !== challengeTypes.bonfire
|
||||||
|
) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
// classic challenge to modern format
|
||||||
|
const preFile = getPreFile(challenge);
|
||||||
|
const contents = previousWork ?
|
||||||
|
// get previous contents
|
||||||
|
_.property([ preFile.key, 'contents' ])(files) :
|
||||||
|
// otherwise start fresh
|
||||||
|
buildSeed(challenge);
|
||||||
|
return {
|
||||||
|
[preFile.key]: createPoly({
|
||||||
|
...files[preFile.key],
|
||||||
|
...preFile,
|
||||||
|
contents,
|
||||||
|
// make sure head/tail are always fresh from fCC
|
||||||
|
head: arrayToString(challenge.head),
|
||||||
|
tail: arrayToString(challenge.tail)
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function createTests({ tests = [] }) {
|
export function createTests({ tests = [] }) {
|
||||||
return tests
|
return tests
|
||||||
.map(test => {
|
.map(test => {
|
@ -3,7 +3,7 @@ import {
|
|||||||
getNextChallenge,
|
getNextChallenge,
|
||||||
getFirstChallengeOfNextBlock,
|
getFirstChallengeOfNextBlock,
|
||||||
getFirstChallengeOfNextSuperBlock
|
getFirstChallengeOfNextSuperBlock
|
||||||
} from './utils.js';
|
} from './';
|
||||||
|
|
||||||
test('getNextChallenge', t => {
|
test('getNextChallenge', t => {
|
||||||
t.plan(7);
|
t.plan(7);
|
@ -9,8 +9,9 @@ import ns from './ns.json';
|
|||||||
import Editor from './Editor.jsx';
|
import Editor from './Editor.jsx';
|
||||||
import { showPreviewSelector, types } from '../../redux';
|
import { showPreviewSelector, types } from '../../redux';
|
||||||
import SidePanel from '../../Side-Panel.jsx';
|
import SidePanel from '../../Side-Panel.jsx';
|
||||||
import Panes from '../../../../Panes';
|
import Preview from '../../Preview.jsx';
|
||||||
import _Map from '../../../../Map';
|
import _Map from '../../../../Map';
|
||||||
|
import Panes from '../../../../Panes';
|
||||||
import ChildContainer from '../../../../Child-Container.jsx';
|
import ChildContainer from '../../../../Child-Container.jsx';
|
||||||
import { filesSelector } from '../../../../files';
|
import { filesSelector } from '../../../../files';
|
||||||
|
|
||||||
@ -62,7 +63,8 @@ export const mapStateToPanes = addNS(
|
|||||||
|
|
||||||
const nameToComponent = {
|
const nameToComponent = {
|
||||||
Map: _Map,
|
Map: _Map,
|
||||||
'Side Panel': SidePanel
|
'Side Panel': SidePanel,
|
||||||
|
Preview: Preview
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ShowModern({ nameToFileKey }) {
|
export function ShowModern({ nameToFileKey }) {
|
||||||
|
@ -19,7 +19,7 @@ import {
|
|||||||
testsSelector,
|
testsSelector,
|
||||||
outputSelector
|
outputSelector
|
||||||
} from '../../redux';
|
} from '../../redux';
|
||||||
import { descriptionRegex } from '../../utils.js';
|
import { descriptionRegex } from '../../utils';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createFormValidator,
|
createFormValidator,
|
||||||
|
@ -45,6 +45,7 @@ const mapStateToProps = createSelector(
|
|||||||
) => ({
|
) => ({
|
||||||
content: files[key] && files[key].contents || '// Happy Coding!',
|
content: files[key] && files[key].contents || '// Happy Coding!',
|
||||||
file: files[key],
|
file: files[key],
|
||||||
|
fileKey: key,
|
||||||
mode
|
mode
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@ -58,6 +59,7 @@ const propTypes = {
|
|||||||
classicEditorUpdated: PropTypes.func.isRequired,
|
classicEditorUpdated: PropTypes.func.isRequired,
|
||||||
content: PropTypes.string,
|
content: PropTypes.string,
|
||||||
executeChallenge: PropTypes.func.isRequired,
|
executeChallenge: PropTypes.func.isRequired,
|
||||||
|
fileKey: PropTypes.string.isRequired,
|
||||||
mode: PropTypes.string
|
mode: PropTypes.string
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -114,6 +116,7 @@ export class Editor extends PureComponent {
|
|||||||
const {
|
const {
|
||||||
content,
|
content,
|
||||||
executeChallenge,
|
executeChallenge,
|
||||||
|
fileKey,
|
||||||
classicEditorUpdated,
|
classicEditorUpdated,
|
||||||
mode
|
mode
|
||||||
} = this.props;
|
} = this.props;
|
||||||
@ -124,7 +127,7 @@ export class Editor extends PureComponent {
|
|||||||
>
|
>
|
||||||
<NoSSR onSSR={ <CodeMirrorSkeleton content={ content } /> }>
|
<NoSSR onSSR={ <CodeMirrorSkeleton content={ content } /> }>
|
||||||
<Codemirror
|
<Codemirror
|
||||||
onChange={ classicEditorUpdated }
|
onChange={ change => classicEditorUpdated(fileKey, change) }
|
||||||
options={ this.createOptions({ executeChallenge, mode }) }
|
options={ this.createOptions({ executeChallenge, mode }) }
|
||||||
ref='editor'
|
ref='editor'
|
||||||
value={ content }
|
value={ content }
|
||||||
|
@ -1,20 +0,0 @@
|
|||||||
import React, { PureComponent } from 'react';
|
|
||||||
|
|
||||||
import ns from './ns.json';
|
|
||||||
|
|
||||||
const mainId = 'fcc-main-frame';
|
|
||||||
|
|
||||||
export default class Preview extends PureComponent {
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<div className={ `${ns}-preview` }>
|
|
||||||
<iframe
|
|
||||||
className={ `${ns}-preview-frame` }
|
|
||||||
id={ mainId }
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Preview.displayName = 'Preview';
|
|
@ -2,8 +2,8 @@ import React from 'react';
|
|||||||
import { addNS } from 'berkeleys-redux-utils';
|
import { addNS } from 'berkeleys-redux-utils';
|
||||||
|
|
||||||
import Editor from './Editor.jsx';
|
import Editor from './Editor.jsx';
|
||||||
import Preview from './Preview.jsx';
|
|
||||||
import { types, showPreviewSelector } from '../../redux';
|
import { types, showPreviewSelector } from '../../redux';
|
||||||
|
import Preview from '../../Preview.jsx';
|
||||||
import SidePanel from '../../Side-Panel.jsx';
|
import SidePanel from '../../Side-Panel.jsx';
|
||||||
import Panes from '../../../../Panes';
|
import Panes from '../../../../Panes';
|
||||||
import _Map from '../../../../Map';
|
import _Map from '../../../../Map';
|
||||||
|
@ -40,14 +40,3 @@
|
|||||||
.max-element-height();
|
.max-element-height();
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.@{ns}-preview {
|
|
||||||
.max-element-height();
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.@{ns}-preview-frame {
|
|
||||||
.max-element-height();
|
|
||||||
border: none;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
@ -20,12 +20,14 @@ import {
|
|||||||
updateTitle,
|
updateTitle,
|
||||||
|
|
||||||
signInLoadingSelector,
|
signInLoadingSelector,
|
||||||
userSelector
|
userSelector,
|
||||||
|
themeSelector
|
||||||
} from '../../redux';
|
} from '../../redux';
|
||||||
import ChildContainer from '../../Child-Container.jsx';
|
import ChildContainer from '../../Child-Container.jsx';
|
||||||
|
|
||||||
const mapStateToProps = createSelector(
|
const mapStateToProps = createSelector(
|
||||||
userSelector,
|
userSelector,
|
||||||
|
themeSelector,
|
||||||
signInLoadingSelector,
|
signInLoadingSelector,
|
||||||
showUpdateEmailViewSelector,
|
showUpdateEmailViewSelector,
|
||||||
(
|
(
|
||||||
@ -41,9 +43,11 @@ const mapStateToProps = createSelector(
|
|||||||
sendNotificationEmail,
|
sendNotificationEmail,
|
||||||
sendQuincyEmail
|
sendQuincyEmail
|
||||||
},
|
},
|
||||||
|
theme,
|
||||||
showLoading,
|
showLoading,
|
||||||
showUpdateEmailView
|
showUpdateEmailView
|
||||||
) => ({
|
) => ({
|
||||||
|
currentTheme: theme,
|
||||||
email,
|
email,
|
||||||
isAvailableForHire,
|
isAvailableForHire,
|
||||||
isGithubCool,
|
isGithubCool,
|
||||||
@ -71,6 +75,7 @@ const mapDispatchToProps = {
|
|||||||
|
|
||||||
const propTypes = {
|
const propTypes = {
|
||||||
children: PropTypes.element,
|
children: PropTypes.element,
|
||||||
|
currentTheme: PropTypes.string,
|
||||||
email: PropTypes.string,
|
email: PropTypes.string,
|
||||||
initialLang: PropTypes.string,
|
initialLang: PropTypes.string,
|
||||||
isAvailableForHire: PropTypes.bool,
|
isAvailableForHire: PropTypes.bool,
|
||||||
@ -113,6 +118,7 @@ export class Settings extends React.Component {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
const {
|
const {
|
||||||
|
currentTheme,
|
||||||
email,
|
email,
|
||||||
isAvailableForHire,
|
isAvailableForHire,
|
||||||
isGithubCool,
|
isGithubCool,
|
||||||
@ -182,7 +188,7 @@ export class Settings extends React.Component {
|
|||||||
bsSize='lg'
|
bsSize='lg'
|
||||||
bsStyle='primary'
|
bsStyle='primary'
|
||||||
className='btn-link-social'
|
className='btn-link-social'
|
||||||
onClick={ toggleNightMode }
|
onClick={ () => toggleNightMode(username, currentTheme) }
|
||||||
>
|
>
|
||||||
Toggle Night Mode
|
Toggle Night Mode
|
||||||
</Button>
|
</Button>
|
||||||
|
@ -42,7 +42,8 @@ export function updateUserEmailEpic(actions, { getState }) {
|
|||||||
.catch(doActionOnError(() => oldEmail ?
|
.catch(doActionOnError(() => oldEmail ?
|
||||||
updateUserEmail(username, oldEmail) :
|
updateUserEmail(username, oldEmail) :
|
||||||
null
|
null
|
||||||
));
|
))
|
||||||
|
.filter(Boolean);
|
||||||
return Observable.merge(optimisticUpdate, ajaxUpdate);
|
return Observable.merge(optimisticUpdate, ajaxUpdate);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -109,6 +110,7 @@ export function updateUserFlagEpic(actions, { getState }) {
|
|||||||
}
|
}
|
||||||
return updateUserFlag(username, flag);
|
return updateUserFlag(username, flag);
|
||||||
})
|
})
|
||||||
|
.filter(Boolean)
|
||||||
.catch(doActionOnError(() => {
|
.catch(doActionOnError(() => {
|
||||||
return updateUserFlag(username, currentValue);
|
return updateUserFlag(username, currentValue);
|
||||||
}));
|
}));
|
||||||
|
@ -1,33 +0,0 @@
|
|||||||
import flow from 'lodash/flow';
|
|
||||||
import { decodeScriptTags } from '../../utils/encode-decode.js';
|
|
||||||
import * as challengeTypes from './challengeTypes.js';
|
|
||||||
|
|
||||||
export function arrayToString(seedData = ['']) {
|
|
||||||
seedData = Array.isArray(seedData) ? seedData : [seedData];
|
|
||||||
return seedData.reduce((seed, line) => '' + seed + line + '\n', '\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildSeed({ challengeSeed = [] } = {}) {
|
|
||||||
return flow(
|
|
||||||
arrayToString,
|
|
||||||
decodeScriptTags
|
|
||||||
)(challengeSeed);
|
|
||||||
}
|
|
||||||
|
|
||||||
const pathsMap = {
|
|
||||||
[ challengeTypes.html ]: 'html',
|
|
||||||
[ challengeTypes.js ]: 'js',
|
|
||||||
[ challengeTypes.bonfire ]: 'js'
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getPreFile({ challengeType }) {
|
|
||||||
return {
|
|
||||||
name: 'index',
|
|
||||||
ext: pathsMap[challengeType] || 'html',
|
|
||||||
key: getFileKey({ challengeType })
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getFileKey({ challengeType }) {
|
|
||||||
return 'index' + (pathsMap[challengeType] || 'html');
|
|
||||||
}
|
|
@ -7,6 +7,7 @@ import { isEmail } from 'validator';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import loopback from 'loopback';
|
import loopback from 'loopback';
|
||||||
|
|
||||||
|
import { themes } from '../utils/themes';
|
||||||
import { saveUser, observeMethod } from '../../server/utils/rx.js';
|
import { saveUser, observeMethod } from '../../server/utils/rx.js';
|
||||||
import { blacklistedUsernames } from '../../server/utils/constants.js';
|
import { blacklistedUsernames } from '../../server/utils/constants.js';
|
||||||
import { wrapHandledError } from '../../server/utils/create-handled-error.js';
|
import { wrapHandledError } from '../../server/utils/create-handled-error.js';
|
||||||
@ -762,10 +763,7 @@ module.exports = function(User) {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
User.themes = {
|
User.themes = themes;
|
||||||
night: true,
|
|
||||||
default: true
|
|
||||||
};
|
|
||||||
|
|
||||||
User.prototype.updateTheme = function updateTheme(theme) {
|
User.prototype.updateTheme = function updateTheme(theme) {
|
||||||
if (!this.constructor.themes[theme]) {
|
if (!this.constructor.themes[theme]) {
|
||||||
|
10
common/utils/themes.js
Normal file
10
common/utils/themes.js
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
export const themes = {
|
||||||
|
night: 'night',
|
||||||
|
default: 'default'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const invertTheme = currentTheme => (
|
||||||
|
!currentTheme || currentTheme === themes.default ?
|
||||||
|
themes.night :
|
||||||
|
themes.default
|
||||||
|
);
|
6
server/boot/react.js
vendored
6
server/boot/react.js
vendored
@ -5,7 +5,6 @@ import { NOT_FOUND } from 'redux-first-router';
|
|||||||
import devtoolsEnhancer from 'remote-redux-devtools';
|
import devtoolsEnhancer from 'remote-redux-devtools';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
loggerMiddleware,
|
|
||||||
errorThrowerMiddleware
|
errorThrowerMiddleware
|
||||||
} from '../utils/react.js';
|
} from '../utils/react.js';
|
||||||
import { createApp, provideStore, App } from '../../common/app';
|
import { createApp, provideStore, App } from '../../common/app';
|
||||||
@ -27,10 +26,7 @@ const routes = [
|
|||||||
|
|
||||||
const devRoutes = [];
|
const devRoutes = [];
|
||||||
|
|
||||||
const middlewares = [
|
const middlewares = isDev ? [errorThrowerMiddleware] : [];
|
||||||
isDev ? loggerMiddleware : null,
|
|
||||||
isDev ? errorThrowerMiddleware : null
|
|
||||||
].filter(Boolean);
|
|
||||||
export default function reactSubRouter(app) {
|
export default function reactSubRouter(app) {
|
||||||
var router = app.loopback.Router();
|
var router = app.loopback.Router();
|
||||||
|
|
||||||
|
9
server/utils/react.js
vendored
9
server/utils/react.js
vendored
@ -1,15 +1,6 @@
|
|||||||
import debug from 'debug';
|
|
||||||
|
|
||||||
const log = debug('fcc:server:react:utils');
|
|
||||||
|
|
||||||
export const errorThrowerMiddleware = () => next => action => {
|
export const errorThrowerMiddleware = () => next => action => {
|
||||||
if (action.error) {
|
if (action.error) {
|
||||||
throw action.payload;
|
throw action.payload;
|
||||||
}
|
}
|
||||||
return next(action);
|
return next(action);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loggerMiddleware = () => next => action => {
|
|
||||||
log('action: \n', action);
|
|
||||||
return next(action);
|
|
||||||
};
|
|
||||||
|
Reference in New Issue
Block a user