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 codeStorageEpic from './code-storage-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 mouseTrapEpic from './mouse-trap-epic.js';
|
||||
import nightModeEpic from './night-mode-epic.js';
|
||||
@ -10,10 +7,7 @@ import titleEpic from './title-epic.js';
|
||||
|
||||
export default [
|
||||
analyticsEpic,
|
||||
codeStorageEpic,
|
||||
errEpic,
|
||||
executeChallengeEpic,
|
||||
frameEpic,
|
||||
hardGoToEpic,
|
||||
mouseTrapEpic,
|
||||
nightModeEpic,
|
||||
|
@ -1,16 +1,18 @@
|
||||
import _ from 'lodash';
|
||||
import { Observable } from 'rx';
|
||||
import { ofType } from 'redux-epic';
|
||||
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 {
|
||||
types,
|
||||
|
||||
addThemeToBody,
|
||||
updateTheme,
|
||||
|
||||
postThemeComplete,
|
||||
createErrorObservable,
|
||||
|
||||
themeSelector,
|
||||
usernameSelector,
|
||||
csrfSelector
|
||||
} from '../../common/app/redux';
|
||||
|
||||
@ -24,40 +26,34 @@ export default function nightModeSaga(
|
||||
{ document: { body } }
|
||||
) {
|
||||
const toggleBodyClass = actions
|
||||
.filter(({ type }) => types.addThemeToBody === type)
|
||||
.doOnNext(({ payload: theme }) => {
|
||||
if (theme === 'night') {
|
||||
body.classList.add('night');
|
||||
::ofType(
|
||||
types.fetchUser.complete,
|
||||
types.toggleNightMode,
|
||||
types.postThemeComplete
|
||||
)
|
||||
.map(_.flow(getState, themeSelector))
|
||||
// catch existing night mode users
|
||||
persistTheme(theme);
|
||||
.do(persistTheme)
|
||||
.do(theme => {
|
||||
if (theme === themes.night) {
|
||||
body.classList.add(themes.night);
|
||||
} else {
|
||||
body.classList.remove('night');
|
||||
body.classList.remove(themes.night);
|
||||
}
|
||||
})
|
||||
.filter(() => false);
|
||||
.ignoreElements();
|
||||
|
||||
const toggle = actions
|
||||
.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
|
||||
const postThemeEpic = actions::ofType(types.toggleNightMode)
|
||||
.debounce(250)
|
||||
.flatMapLatest(() => {
|
||||
const _csrf = csrfSelector(getState());
|
||||
const theme = themeSelector(getState());
|
||||
const username = usernameSelector(getState());
|
||||
return postJSON$('/update-my-theme', { _csrf, theme })
|
||||
.pluck('updatedTo')
|
||||
.map(theme => postThemeComplete(username, theme))
|
||||
.catch(createErrorObservable);
|
||||
});
|
||||
|
||||
return Observable.merge(optimistic, toggleBodyClass, ajax);
|
||||
return Observable.merge(toggleBodyClass, postThemeEpic);
|
||||
}
|
||||
|
@ -1,9 +1,7 @@
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var testTimeout = 5000;
|
||||
var common = parent.__common;
|
||||
var frameId = window.__frameId;
|
||||
var frameReady = common[frameId + 'Ready'] || { onNext() {} };
|
||||
var Rx = document.Rx;
|
||||
var frameReady = document.__frameReady;
|
||||
var helpers = Rx.helpers;
|
||||
var chai = parent.chai;
|
||||
var source = document.__source;
|
||||
@ -14,8 +12,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
|
||||
document.__getJsOutput = function getJsOutput() {
|
||||
if (window.__err || !common.shouldRun()) {
|
||||
return window.__err || 'source disabled';
|
||||
if (window.__err) {
|
||||
return window.__err;
|
||||
}
|
||||
let output;
|
||||
try {
|
||||
|
@ -1,3 +1,4 @@
|
||||
import _ from 'lodash';
|
||||
import { ofType } from 'redux-epic';
|
||||
|
||||
import { types } from './';
|
||||
@ -8,23 +9,21 @@ import {
|
||||
} from '../../redux';
|
||||
import { onRouteChallenges } from '../../routes/Challenges/redux';
|
||||
import { entitiesSelector } from '../../entities';
|
||||
import { langSelector, pathnameSelector } from '../../Router/redux';
|
||||
|
||||
export default function loadCurrentChallengeEpic(actions, { getState }) {
|
||||
return actions::ofType(types.clickOnLogo, types.clickOnMap)
|
||||
.debounce(500)
|
||||
.map(() => {
|
||||
.map(getState)
|
||||
.map(state => {
|
||||
let finalChallenge;
|
||||
const state = getState();
|
||||
const lang = langSelector(state);
|
||||
const { id: currentlyLoadedChallengeId } = challengeSelector(state);
|
||||
const {
|
||||
challenge: challengeMap,
|
||||
challengeIdToName
|
||||
} = entitiesSelector(state);
|
||||
const {
|
||||
routing: {
|
||||
locationBeforeTransitions: { pathname } = {}
|
||||
}
|
||||
} = state;
|
||||
const pathname = pathnameSelector(state);
|
||||
const firstChallenge = firstChallengeSelector(state);
|
||||
const { currentChallengeId } = userSelector(state);
|
||||
const isOnAChallenge = (/^\/[^\/]{2,6}\/challenges/).test(pathname);
|
||||
@ -37,22 +36,23 @@ export default function loadCurrentChallengeEpic(actions, { getState }) {
|
||||
];
|
||||
}
|
||||
return {
|
||||
finalChallenge,
|
||||
..._.pick(finalChallenge, ['id', 'block', 'dashedName']),
|
||||
lang,
|
||||
isOnAChallenge,
|
||||
currentlyLoadedChallengeId
|
||||
};
|
||||
})
|
||||
.filter(({
|
||||
finalChallenge,
|
||||
id,
|
||||
isOnAChallenge,
|
||||
currentlyLoadedChallengeId
|
||||
}) => (
|
||||
// data might not be there yet, filter out for now
|
||||
!!finalChallenge &&
|
||||
!!id &&
|
||||
// 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.
|
||||
// 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 routesMapSelector = state =>
|
||||
selectLocationState(state).routesMap || {};
|
||||
export const pathnameSelector = state => selectLocationState(state).pathname;
|
||||
|
@ -1,3 +1,4 @@
|
||||
import _ from 'lodash';
|
||||
import {
|
||||
composeReducers,
|
||||
createAction,
|
||||
@ -5,12 +6,14 @@ import {
|
||||
handleActions
|
||||
} 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 getNS = state => state[ns];
|
||||
export const entitiesSelector = getNS;
|
||||
export const types = createTypes([
|
||||
'updateTheme',
|
||||
'updateUserFlag',
|
||||
'updateUserEmail',
|
||||
'updateUserLang',
|
||||
@ -37,6 +40,17 @@ export const updateUserCurrentChallenge = createAction(
|
||||
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 = {
|
||||
superBlock: {},
|
||||
@ -73,34 +87,59 @@ export default composeReducers(
|
||||
}
|
||||
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(
|
||||
() => ({
|
||||
[
|
||||
app.submitChallenge.complete
|
||||
challenges.submitChallenge.complete
|
||||
]: (state, { payload: { username, points, challengeInfo } }) => ({
|
||||
...state,
|
||||
user: {
|
||||
...state.user,
|
||||
[username]: {
|
||||
...state[username],
|
||||
...state.user[username],
|
||||
points,
|
||||
challengeMap: {
|
||||
...state[username].challengeMap,
|
||||
...state.user[username].challengeMap,
|
||||
[challengeInfo.id]: challengeInfo
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
[types.updateUserFlag]: (state, { payload: { username, flag } }) => ({
|
||||
...state,
|
||||
user: {
|
||||
...state.user,
|
||||
[username]: {
|
||||
...state[username],
|
||||
[flag]: !state[username][flag]
|
||||
...state.user[username],
|
||||
[flag]: !state.user[username][flag]
|
||||
}
|
||||
}
|
||||
}),
|
||||
[types.updateUserEmail]: (state, { payload: { username, email } }) => ({
|
||||
...state,
|
||||
user: {
|
||||
...state.user,
|
||||
[username]: {
|
||||
...state[username],
|
||||
...state.user[username],
|
||||
email
|
||||
}
|
||||
}
|
||||
}),
|
||||
[types.updateUserLang]:
|
||||
(
|
||||
@ -110,10 +149,13 @@ export default composeReducers(
|
||||
}
|
||||
) => ({
|
||||
...state,
|
||||
user: {
|
||||
...state.user,
|
||||
[username]: {
|
||||
...state[username],
|
||||
...state.user[username],
|
||||
languageTag
|
||||
}
|
||||
}
|
||||
}),
|
||||
[types.updateUserCurrentChallenge]:
|
||||
(
|
||||
@ -123,10 +165,13 @@ export default composeReducers(
|
||||
}
|
||||
) => ({
|
||||
...state,
|
||||
user: {
|
||||
...state.user,
|
||||
[username]: {
|
||||
...state[username],
|
||||
...state.user[username],
|
||||
currentChallengeId
|
||||
}
|
||||
}
|
||||
})
|
||||
}),
|
||||
defaultState
|
||||
|
@ -1,31 +1,24 @@
|
||||
import _ from 'lodash';
|
||||
import {
|
||||
combineActions,
|
||||
createAction,
|
||||
createTypes,
|
||||
handleActions
|
||||
addNS,
|
||||
createTypes
|
||||
} from 'berkeleys-redux-utils';
|
||||
|
||||
import { bonfire, html, js } from '../utils/challengeTypes.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';
|
||||
|
||||
export const types = createTypes([
|
||||
'updateFile',
|
||||
'updateFiles',
|
||||
'savedCodeFound'
|
||||
'createFiles'
|
||||
], ns);
|
||||
|
||||
export const updateFile = createAction(types.updateFile);
|
||||
export const updateFiles = createAction(types.updateFiles);
|
||||
export const savedCodeFound = createAction(
|
||||
types.savedCodeFound,
|
||||
(files, challenge) => ({ files, challenge })
|
||||
);
|
||||
export const updateFileMetaCreator = (key, content)=> ({
|
||||
file: { type: types.updateFile, payload: { key, content } }
|
||||
});
|
||||
export const createFilesMetaCreator = payload => ({
|
||||
file: { type: types.createFiles, payload }
|
||||
});
|
||||
|
||||
export const filesSelector = state => state[ns];
|
||||
export const createFileSelector = keySelector => (state, props) => {
|
||||
@ -33,76 +26,28 @@ export const createFileSelector = keySelector => (state, props) => {
|
||||
return files[keySelector(state, props)] || {};
|
||||
};
|
||||
|
||||
export default handleActions(
|
||||
() => ({
|
||||
[types.updateFile]: (state, { payload: { key, content }}) => ({
|
||||
const getFileAction = _.property('meta.file.type');
|
||||
const getFilePayload = _.property('meta.file.payload');
|
||||
|
||||
export default addNS(
|
||||
ns,
|
||||
function reducer(state = {}, action) {
|
||||
if (getFileAction(action)) {
|
||||
if (getFileAction(action) === types.updateFile) {
|
||||
const { key, content } = getFilePayload(action);
|
||||
return {
|
||||
...state,
|
||||
[key]: setContent(content, state[key])
|
||||
}),
|
||||
[types.updateFiles]: (state, { payload: files }) => {
|
||||
return files
|
||||
.reduce((files, file) => {
|
||||
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
|
||||
};
|
||||
}
|
||||
if (getFileAction(action) === types.createFiles) {
|
||||
const files = getFilePayload(action);
|
||||
return _.reduce(files, (files, file) => {
|
||||
files[file.key] = createPoly(file);
|
||||
return files;
|
||||
}, {});
|
||||
}, { ...state });
|
||||
}
|
||||
if (
|
||||
challenge.challengeType !== html &&
|
||||
challenge.challengeType !== js &&
|
||||
challenge.challengeType !== bonfire
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
// classic challenge to modern format
|
||||
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;
|
||||
}, {});
|
||||
return state;
|
||||
}
|
||||
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 {
|
||||
types,
|
||||
|
||||
addUser,
|
||||
updateThisUser,
|
||||
fetchUserComplete,
|
||||
createErrorObservable,
|
||||
showSignIn,
|
||||
updateTheme,
|
||||
addThemeToBody
|
||||
showSignIn
|
||||
} from './';
|
||||
|
||||
export default function getUserEpic(actions, _, { services }) {
|
||||
return actions::ofType(types.fetchUser)
|
||||
return actions::ofType('' + types.fetchUser)
|
||||
.flatMap(() => {
|
||||
return services.readService$({ service: 'user' })
|
||||
.filter(({ entities, result }) => entities && !!result)
|
||||
.flatMap(({ entities, result })=> {
|
||||
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);
|
||||
})
|
||||
.map(fetchUserComplete)
|
||||
.defaultIfEmpty(showSignIn())
|
||||
.catch(createErrorObservable);
|
||||
});
|
||||
|
@ -1,3 +1,4 @@
|
||||
import _ from 'lodash';
|
||||
import { Observable } from 'rx';
|
||||
import {
|
||||
combineActions,
|
||||
@ -7,18 +8,21 @@ import {
|
||||
handleActions
|
||||
} from 'berkeleys-redux-utils';
|
||||
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 updateMyCurrentChallengeEpic from './update-my-challenge-epic.js';
|
||||
import fetchChallengesEpic from './fetch-challenges-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 { challengeToFiles } from '../routes/Challenges/utils';
|
||||
|
||||
import ns from '../ns.json';
|
||||
|
||||
import { themes, invertTheme } from '../../utils/themes.js';
|
||||
|
||||
export const epics = [
|
||||
fetchUserEpic,
|
||||
fetchChallengesEpic,
|
||||
@ -36,9 +40,7 @@ export const types = createTypes([
|
||||
createAsyncTypes('fetchChallenge'),
|
||||
createAsyncTypes('fetchChallenges'),
|
||||
|
||||
'fetchUser',
|
||||
'addUser',
|
||||
'updateThisUser',
|
||||
createAsyncTypes('fetchUser'),
|
||||
'showSignIn',
|
||||
|
||||
'handleError',
|
||||
@ -48,8 +50,7 @@ export const types = createTypes([
|
||||
|
||||
// night mode
|
||||
'toggleNightMode',
|
||||
'updateTheme',
|
||||
'addThemeToBody'
|
||||
'postThemeComplete'
|
||||
], ns);
|
||||
|
||||
const throwIfUndefined = () => {
|
||||
@ -95,7 +96,10 @@ export const fetchChallenge = createAction(
|
||||
export const fetchChallengeCompleted = createAction(
|
||||
types.fetchChallenge.complete,
|
||||
null,
|
||||
identity
|
||||
meta => ({
|
||||
...meta,
|
||||
..._.flow(challengeToFiles, createFilesMetaCreator)(meta.challenge)
|
||||
})
|
||||
);
|
||||
export const fetchChallenges = createAction('' + types.fetchChallenges);
|
||||
export const fetchChallengesCompleted = createAction(
|
||||
@ -110,16 +114,12 @@ export const updateTitle = createAction(types.updateTitle);
|
||||
// fetchUser() => Action
|
||||
// used in combination with fetch-user-epic
|
||||
export const fetchUser = createAction(types.fetchUser);
|
||||
|
||||
// addUser(
|
||||
// entities: { [userId]: User }
|
||||
// ) => Action
|
||||
export const addUser = createAction(
|
||||
types.addUser,
|
||||
noop,
|
||||
entities => ({ entities })
|
||||
export const fetchUserComplete = createAction(
|
||||
types.fetchUser.complete,
|
||||
({ result }) => result,
|
||||
_.identity
|
||||
);
|
||||
export const updateThisUser = createAction(types.updateThisUser);
|
||||
|
||||
export const showSignIn = createAction(types.showSignIn);
|
||||
|
||||
// used when server needs client to redirect
|
||||
@ -145,21 +145,20 @@ export const doActionOnError = actionCreator => error => Observable.of(
|
||||
|
||||
export const toggleNightMode = createAction(
|
||||
types.toggleNightMode,
|
||||
// we use this function to avoid hanging onto the eventObject
|
||||
// so that react can recycle it
|
||||
() => null
|
||||
null,
|
||||
(username, theme) => updateThemeMetacreator(username, invertTheme(theme))
|
||||
);
|
||||
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',
|
||||
isSignInAttempted: false,
|
||||
user: '',
|
||||
csrfToken: '',
|
||||
theme: 'default',
|
||||
// eventually this should be only in the user object
|
||||
currentChallenge: '',
|
||||
superBlocks: []
|
||||
@ -167,28 +166,37 @@ const initialState = {
|
||||
|
||||
export const getNS = state => state[ns];
|
||||
export const csrfSelector = state => getNS(state).csrfToken;
|
||||
export const themeSelector = state => getNS(state).theme;
|
||||
export const titleSelector = state => getNS(state).title;
|
||||
|
||||
export const currentChallengeSelector = state => getNS(state).currentChallenge;
|
||||
export const superBlocksSelector = state => getNS(state).superBlocks;
|
||||
export const signInLoadingSelector = state => !getNS(state).isSignInAttempted;
|
||||
|
||||
export const usernameSelector = state => getNS(state).user || '';
|
||||
export const userSelector = createSelector(
|
||||
state => getNS(state).user,
|
||||
state => entitiesSelector(state).user,
|
||||
(username, userMap) => userMap[username] || {}
|
||||
);
|
||||
|
||||
export const themeSelector = _.flow(
|
||||
userSelector,
|
||||
user => user.theme || themes.default
|
||||
);
|
||||
|
||||
export const isSignedInSelector = state => !!userSelector(state).username;
|
||||
|
||||
export const challengeSelector = createSelector(
|
||||
currentChallengeSelector,
|
||||
state => entitiesSelector(state).challenge,
|
||||
(challengeName, challengeMap = {}) => {
|
||||
export const challengeSelector = state => {
|
||||
const challengeName = currentChallengeSelector(state);
|
||||
const challengeMap = entitiesSelector(state).challenge || {};
|
||||
return challengeMap[challengeName] || {};
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const previousSolutionSelector = state => {
|
||||
const { id } = challengeSelector(state);
|
||||
const { challengeMap = {} } = userSelector(state);
|
||||
return challengeMap[id];
|
||||
};
|
||||
|
||||
export const firstChallengeSelector = createSelector(
|
||||
entitiesSelector,
|
||||
@ -231,7 +239,7 @@ export default handleActions(
|
||||
title: payload + ' | freeCodeCamp'
|
||||
}),
|
||||
|
||||
[types.updateThisUser]: (state, { payload: user }) => ({
|
||||
[types.fetchUser.complete]: (state, { payload: user }) => ({
|
||||
...state,
|
||||
user
|
||||
}),
|
||||
@ -246,11 +254,9 @@ export default handleActions(
|
||||
...state,
|
||||
currentChallenge: dashedName
|
||||
}),
|
||||
[types.updateTheme]: (state, { payload = 'default' }) => ({
|
||||
...state,
|
||||
theme: payload
|
||||
}),
|
||||
[combineActions(types.showSignIn, types.updateThisUser)]: state => ({
|
||||
[
|
||||
combineActions(types.showSignIn, types.fetchUser.complete)
|
||||
]: state => ({
|
||||
...state,
|
||||
isSignInAttempted: true
|
||||
}),
|
||||
@ -264,6 +270,6 @@ export default handleActions(
|
||||
delayedRedirect: payload
|
||||
})
|
||||
}),
|
||||
initialState,
|
||||
defaultState,
|
||||
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
|
||||
@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 {
|
||||
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"; }
|
||||
|
@ -6,15 +6,15 @@ import matchesProperty from 'lodash/matchesProperty';
|
||||
import partial from 'lodash/partial';
|
||||
import stubTrue from 'lodash/stubTrue';
|
||||
|
||||
import {
|
||||
compileHeadTail,
|
||||
setExt,
|
||||
transformContents
|
||||
} from '../../common/utils/polyvinyl';
|
||||
import {
|
||||
fetchScript,
|
||||
fetchLink
|
||||
} from '../utils/fetch-and-cache.js';
|
||||
import {
|
||||
compileHeadTail,
|
||||
setExt,
|
||||
transformContents
|
||||
} from '../../../../utils/polyvinyl';
|
||||
|
||||
const htmlCatch = '\n<!--fcc-->\n';
|
||||
const jsCatch = '\n;/*fcc*/\n';
|
@ -4,7 +4,7 @@ import identity from 'lodash/identity';
|
||||
import stubTrue from 'lodash/stubTrue';
|
||||
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/;
|
||||
|
@ -4,26 +4,23 @@ import * as babel from 'babel-core';
|
||||
import presetEs2015 from 'babel-preset-es2015';
|
||||
import presetReact from 'babel-preset-react';
|
||||
import { Observable } from 'rx';
|
||||
/* eslint-disable import/no-unresolved */
|
||||
import loopProtect from 'loop-protect';
|
||||
/* eslint-enable import/no-unresolved */
|
||||
|
||||
import {
|
||||
transformHeadTailAndContents,
|
||||
setContent,
|
||||
setExt
|
||||
} from '../../common/utils/polyvinyl.js';
|
||||
import castToObservable from '../../common/app/utils/cast-to-observable.js';
|
||||
} from '../../../../utils/polyvinyl.js';
|
||||
import castToObservable from '../../../utils/cast-to-observable.js';
|
||||
|
||||
const babelOptions = { presets: [ presetEs2015, presetReact ] };
|
||||
loopProtect.hit = function hit(line) {
|
||||
function loopProtectHit(line) {
|
||||
var err = 'Exiting potential infinite loop at line ' +
|
||||
line +
|
||||
'. To disable loop protection, write: \n\/\/ noprotect\nas the first ' +
|
||||
'line. Beware that if you do have an infinite loop in your code, ' +
|
||||
'this will crash your browser.';
|
||||
throw new Error(err);
|
||||
};
|
||||
}
|
||||
|
||||
// const sourceReg =
|
||||
// /(<!-- 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
|
||||
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);
|
||||
}
|
||||
],
|
@ -1,3 +1,4 @@
|
||||
import _ from 'lodash';
|
||||
import debug from 'debug';
|
||||
import { Observable } from 'rx';
|
||||
import { combineEpics, ofType } from 'redux-epic';
|
||||
@ -7,8 +8,7 @@ import {
|
||||
|
||||
challengeUpdated,
|
||||
onRouteChallenges,
|
||||
onRouteCurrentChallenge,
|
||||
updateMain
|
||||
onRouteCurrentChallenge
|
||||
} from './';
|
||||
import { getNS as entitiesSelector } from '../../../entities';
|
||||
import {
|
||||
@ -38,21 +38,15 @@ export function challengeUpdatedEpic(actions, { getState }) {
|
||||
// this will be an empty object
|
||||
// We wait instead for the fetchChallenge.complete to complete the UI state
|
||||
.filter(({ dashedName }) => !!dashedName)
|
||||
.flatMap(challenge =>
|
||||
// send the challenge to update UI and update main iframe with inital
|
||||
// challenge
|
||||
Observable.of(challengeUpdated(challenge), updateMain())
|
||||
);
|
||||
// send the challenge to update UI and trigger main iframe to update
|
||||
// use unary to prevent index from being passed to func
|
||||
.map(_.unary(challengeUpdated));
|
||||
}
|
||||
|
||||
// used to reset users code on request
|
||||
export function resetChallengeEpic(actions, { getState }) {
|
||||
return actions::ofType(types.clickOnReset)
|
||||
.flatMap(() =>
|
||||
Observable.of(
|
||||
challengeUpdated(challengeSelector(getState())),
|
||||
updateMain()
|
||||
));
|
||||
.map(_.flow(getState, challengeSelector, challengeUpdated));
|
||||
}
|
||||
|
||||
export function nextChallengeEpic(actions, { getState }) {
|
||||
@ -88,7 +82,7 @@ export function nextChallengeEpic(actions, { getState }) {
|
||||
{ isDev }
|
||||
);
|
||||
}
|
||||
/* this requires user data not available yet
|
||||
/* // TODO(berks): get this to work
|
||||
if (isNewSuperBlock || isNewBlock) {
|
||||
const getName = isNewSuperBlock ?
|
||||
getCurrentSuperBlockName :
|
||||
|
@ -2,26 +2,25 @@ import { Observable } from 'rx';
|
||||
import { combineEpics, ofType } from 'redux-epic';
|
||||
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 {
|
||||
types,
|
||||
updateMain,
|
||||
lockUntrustedCode,
|
||||
storedCodeFound,
|
||||
noStoredCodeFound,
|
||||
previousSolutionFound,
|
||||
|
||||
keySelector,
|
||||
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 = [
|
||||
'Bonfire: ',
|
||||
@ -86,10 +85,11 @@ export function loadCodeEpic(actions, { getState }, { window, location }) {
|
||||
actions::ofType(types.onRouteChallenges)
|
||||
.distinctUntilChanged(({ payload: { dashedName } }) => dashedName)
|
||||
)
|
||||
// make sure we are not SSR
|
||||
.filter(() => !!window)
|
||||
.flatMap(() => {
|
||||
let finalFiles;
|
||||
const state = getState();
|
||||
const user = userSelector(state);
|
||||
const challenge = challengeSelector(state);
|
||||
const key = keySelector(state);
|
||||
const files = filesSelector(state);
|
||||
@ -105,11 +105,10 @@ export function loadCodeEpic(actions, { getState }, { window, location }) {
|
||||
finalFiles = legacyToFile(codeUriFound, files, key);
|
||||
removeCodeUri(location, window.history);
|
||||
return Observable.of(
|
||||
lockUntrustedCode(),
|
||||
makeToast({
|
||||
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({
|
||||
message: 'I found some saved work. Loading now.'
|
||||
}),
|
||||
savedCodeFound(finalFiles, challenge),
|
||||
updateMain()
|
||||
storedCodeFound(challenge, finalFiles)
|
||||
);
|
||||
}
|
||||
|
||||
if (user.challengeMap && user.challengeMap[id]) {
|
||||
const userChallenge = user.challengeMap[id];
|
||||
if (previousSolutionSelector(getState())) {
|
||||
const userChallenge = previousSolutionSelector(getState());
|
||||
if (userChallenge.files) {
|
||||
finalFiles = userChallenge.files;
|
||||
} else if (userChallenge.solution) {
|
||||
@ -145,14 +143,48 @@ export function loadCodeEpic(actions, { getState }, { window, location }) {
|
||||
makeToast({
|
||||
message: 'I found a previous solved solution. Loading now.'
|
||||
}),
|
||||
savedCodeFound(finalFiles, challenge),
|
||||
updateMain()
|
||||
previousSolutionFound(challenge, finalFiles)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
combineActions,
|
||||
combineReducers,
|
||||
@ -12,14 +13,21 @@ import noop from 'lodash/noop';
|
||||
import bugEpic from './bug-epic';
|
||||
import completionEpic from './completion-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 stepReducer, { epics as stepEpics } from '../views/step/redux';
|
||||
import quizReducer from '../views/quiz/redux';
|
||||
import projectReducer from '../views/project/redux';
|
||||
|
||||
import {
|
||||
createTests,
|
||||
loggerToStr,
|
||||
submitTypes,
|
||||
viewTypes
|
||||
viewTypes,
|
||||
getFileKey,
|
||||
challengeToFiles
|
||||
} from '../utils';
|
||||
import {
|
||||
types as app,
|
||||
@ -27,19 +35,20 @@ import {
|
||||
} from '../../../redux';
|
||||
import { html } from '../../../utils/challengeTypes.js';
|
||||
import blockNameify from '../../../utils/blockNameify.js';
|
||||
import { getFileKey } from '../../../utils/classic-file.js';
|
||||
import stepReducer, { epics as stepEpics } from '../views/step/redux';
|
||||
import quizReducer from '../views/quiz/redux';
|
||||
import projectReducer from '../views/project/redux';
|
||||
import { updateFileMetaCreator, createFilesMetaCreator } from '../../../files';
|
||||
|
||||
// this is not great but is ok until we move to a different form type
|
||||
export projectNormalizer from '../views/project/redux';
|
||||
|
||||
const challengeToFilesMetaCreator =
|
||||
_.flow(challengeToFiles, createFilesMetaCreator);
|
||||
|
||||
export const epics = [
|
||||
bugEpic,
|
||||
completionEpic,
|
||||
challengeEpic,
|
||||
editorEpic,
|
||||
codeStorageEpic,
|
||||
completionEpic,
|
||||
executeChallengeEpic,
|
||||
...stepEpics
|
||||
];
|
||||
|
||||
@ -53,7 +62,6 @@ export const types = createTypes([
|
||||
'challengeUpdated',
|
||||
'clickOnReset',
|
||||
'updateHint',
|
||||
'lockUntrustedCode',
|
||||
'unlockUntrustedCode',
|
||||
'closeChallengeModal',
|
||||
'updateSuccessMessage',
|
||||
@ -62,10 +70,6 @@ export const types = createTypes([
|
||||
|
||||
// rechallenge
|
||||
'executeChallenge',
|
||||
'updateMain',
|
||||
'runTests',
|
||||
'frameMain',
|
||||
'frameTests',
|
||||
'updateOutput',
|
||||
'initOutput',
|
||||
'updateTests',
|
||||
@ -86,7 +90,12 @@ export const types = createTypes([
|
||||
'togglePreview',
|
||||
'toggleSidePanel',
|
||||
'toggleStep',
|
||||
'toggleModernEditor'
|
||||
'toggleModernEditor',
|
||||
|
||||
// code storage
|
||||
'storedCodeFound',
|
||||
'noStoredCodeFound',
|
||||
'previousSolutionFound'
|
||||
], ns);
|
||||
|
||||
// routes
|
||||
@ -95,24 +104,29 @@ export const onRouteCurrentChallenge =
|
||||
createAction(types.onRouteCurrentChallenge);
|
||||
|
||||
// classic
|
||||
export const classicEditorUpdated = createAction(types.classicEditorUpdated);
|
||||
export const classicEditorUpdated = createAction(
|
||||
types.classicEditorUpdated,
|
||||
null,
|
||||
updateFileMetaCreator
|
||||
);
|
||||
// modern
|
||||
export const modernEditorUpdated = createAction(
|
||||
types.modernEditorUpdated,
|
||||
(key, content) => ({ key, content })
|
||||
null,
|
||||
createFilesMetaCreator
|
||||
);
|
||||
// challenges
|
||||
export const closeChallengeModal = createAction(types.closeChallengeModal);
|
||||
export const updateHint = createAction(types.updateHint);
|
||||
export const lockUntrustedCode = createAction(types.lockUntrustedCode);
|
||||
export const unlockUntrustedCode = createAction(
|
||||
types.unlockUntrustedCode,
|
||||
() => null
|
||||
_.noop
|
||||
);
|
||||
export const updateSuccessMessage = createAction(types.updateSuccessMessage);
|
||||
export const challengeUpdated = createAction(
|
||||
types.challengeUpdated,
|
||||
challenge => ({ challenge })
|
||||
challenge => ({ challenge }),
|
||||
challengeToFilesMetaCreator
|
||||
);
|
||||
export const clickOnReset = createAction(types.clickOnReset);
|
||||
|
||||
@ -122,11 +136,6 @@ export const executeChallenge = createAction(
|
||||
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 initOutput = createAction(types.initOutput, loggerToStr);
|
||||
@ -148,6 +157,19 @@ export const closeBugModal = createAction(types.closeBugModal);
|
||||
export const openIssueSearch = createAction(types.openIssueSearch);
|
||||
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 = {
|
||||
output: null,
|
||||
isChallengeModalOpen: false,
|
||||
@ -157,6 +179,7 @@ const initialUiState = {
|
||||
|
||||
const initialState = {
|
||||
isCodeLocked: false,
|
||||
isJSEnabled: true,
|
||||
id: '',
|
||||
challenge: '',
|
||||
helpChatRoom: 'Help',
|
||||
@ -176,6 +199,8 @@ export const outputSelector = state => getNS(state).output;
|
||||
export const successMessageSelector = state => getNS(state).successMessage;
|
||||
export const hintIndexSelector = state => getNS(state).hintIndex;
|
||||
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 challengeModalSelector =
|
||||
state => getNS(state).isChallengeModalOpen;
|
||||
@ -204,7 +229,10 @@ export const challengeMetaSelector = createSelector(
|
||||
submitTypes[challengeType] ||
|
||||
submitTypes[challenge && challenge.type] ||
|
||||
'tests',
|
||||
showPreview: challengeType === html,
|
||||
showPreview: (
|
||||
challengeType === html ||
|
||||
type === 'modern'
|
||||
),
|
||||
mode: challenge && challengeType === html ?
|
||||
'text/html' :
|
||||
'javascript'
|
||||
@ -250,8 +278,9 @@ export default combineReducers(
|
||||
...state,
|
||||
successMessage: payload
|
||||
}),
|
||||
[types.lockUntrustedCode]: state => ({
|
||||
[types.storedCodeFound]: state => ({
|
||||
...state,
|
||||
isJSEnabled: false,
|
||||
isCodeLocked: true
|
||||
}),
|
||||
[types.unlockUntrustedCode]: state => ({
|
||||
@ -260,8 +289,18 @@ export default combineReducers(
|
||||
}),
|
||||
[types.executeChallenge]: state => ({
|
||||
...state,
|
||||
isJSEnabled: true,
|
||||
tests: state.tests.map(test => ({ ...test, err: false, pass: false }))
|
||||
}),
|
||||
[
|
||||
combineActions(
|
||||
types.classicEditorUpdated,
|
||||
types.modernEditorUpdated
|
||||
)
|
||||
]: state => ({
|
||||
...state,
|
||||
isJSEnabled: false
|
||||
}),
|
||||
|
||||
// classic/modern
|
||||
[types.initOutput]: (state, { payload: output }) => ({
|
||||
|
@ -2,7 +2,7 @@ import { Observable } from 'rx';
|
||||
import { getValues } from 'redux-form';
|
||||
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 {
|
||||
applyTransformers,
|
||||
@ -16,7 +16,7 @@ import {
|
||||
import {
|
||||
createFileStream,
|
||||
pipe
|
||||
} from '../../common/utils/polyvinyl.js';
|
||||
} from '../../../../utils/polyvinyl.js';
|
||||
|
||||
|
||||
const jQuery = {
|
@ -1,5 +1,5 @@
|
||||
import flow from 'lodash/flow';
|
||||
import { decodeFcc } from '../../common/utils/encode-decode';
|
||||
import _ from 'lodash';
|
||||
import { decodeFcc } from '../../../../utils/encode-decode';
|
||||
|
||||
const queryRegex = /^(\?|#\?)/;
|
||||
export function legacyIsInQuery(query, decode) {
|
||||
@ -42,7 +42,7 @@ export function getKeyInQuery(query, keyToFind = '') {
|
||||
}
|
||||
|
||||
export function getLegacySolutionFromQuery(query = '', decode) {
|
||||
return flow(
|
||||
return _.flow(
|
||||
getKeyInQuery,
|
||||
decode,
|
||||
decodeFcc
|
@ -1,5 +1,5 @@
|
||||
import { Observable } from 'rx';
|
||||
import { ajax$ } from '../../common/utils/ajax-stream';
|
||||
import { ajax$ } from '../../../../utils/ajax-stream';
|
||||
|
||||
// value used to break browser ajax caching
|
||||
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
|
||||
export const viewTypes = {
|
||||
[ challengeTypes.html ]: 'classic',
|
||||
@ -43,6 +53,66 @@ export const submitTypes = {
|
||||
// has html that should be rendered
|
||||
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 = [] }) {
|
||||
return tests
|
||||
.map(test => {
|
@ -3,7 +3,7 @@ import {
|
||||
getNextChallenge,
|
||||
getFirstChallengeOfNextBlock,
|
||||
getFirstChallengeOfNextSuperBlock
|
||||
} from './utils.js';
|
||||
} from './';
|
||||
|
||||
test('getNextChallenge', t => {
|
||||
t.plan(7);
|
@ -9,8 +9,9 @@ import ns from './ns.json';
|
||||
import Editor from './Editor.jsx';
|
||||
import { showPreviewSelector, types } from '../../redux';
|
||||
import SidePanel from '../../Side-Panel.jsx';
|
||||
import Panes from '../../../../Panes';
|
||||
import Preview from '../../Preview.jsx';
|
||||
import _Map from '../../../../Map';
|
||||
import Panes from '../../../../Panes';
|
||||
import ChildContainer from '../../../../Child-Container.jsx';
|
||||
import { filesSelector } from '../../../../files';
|
||||
|
||||
@ -62,7 +63,8 @@ export const mapStateToPanes = addNS(
|
||||
|
||||
const nameToComponent = {
|
||||
Map: _Map,
|
||||
'Side Panel': SidePanel
|
||||
'Side Panel': SidePanel,
|
||||
Preview: Preview
|
||||
};
|
||||
|
||||
export function ShowModern({ nameToFileKey }) {
|
||||
|
@ -19,7 +19,7 @@ import {
|
||||
testsSelector,
|
||||
outputSelector
|
||||
} from '../../redux';
|
||||
import { descriptionRegex } from '../../utils.js';
|
||||
import { descriptionRegex } from '../../utils';
|
||||
|
||||
import {
|
||||
createFormValidator,
|
||||
|
@ -45,6 +45,7 @@ const mapStateToProps = createSelector(
|
||||
) => ({
|
||||
content: files[key] && files[key].contents || '// Happy Coding!',
|
||||
file: files[key],
|
||||
fileKey: key,
|
||||
mode
|
||||
})
|
||||
);
|
||||
@ -58,6 +59,7 @@ const propTypes = {
|
||||
classicEditorUpdated: PropTypes.func.isRequired,
|
||||
content: PropTypes.string,
|
||||
executeChallenge: PropTypes.func.isRequired,
|
||||
fileKey: PropTypes.string.isRequired,
|
||||
mode: PropTypes.string
|
||||
};
|
||||
|
||||
@ -114,6 +116,7 @@ export class Editor extends PureComponent {
|
||||
const {
|
||||
content,
|
||||
executeChallenge,
|
||||
fileKey,
|
||||
classicEditorUpdated,
|
||||
mode
|
||||
} = this.props;
|
||||
@ -124,7 +127,7 @@ export class Editor extends PureComponent {
|
||||
>
|
||||
<NoSSR onSSR={ <CodeMirrorSkeleton content={ content } /> }>
|
||||
<Codemirror
|
||||
onChange={ classicEditorUpdated }
|
||||
onChange={ change => classicEditorUpdated(fileKey, change) }
|
||||
options={ this.createOptions({ executeChallenge, mode }) }
|
||||
ref='editor'
|
||||
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 Editor from './Editor.jsx';
|
||||
import Preview from './Preview.jsx';
|
||||
import { types, showPreviewSelector } from '../../redux';
|
||||
import Preview from '../../Preview.jsx';
|
||||
import SidePanel from '../../Side-Panel.jsx';
|
||||
import Panes from '../../../../Panes';
|
||||
import _Map from '../../../../Map';
|
||||
|
@ -40,14 +40,3 @@
|
||||
.max-element-height();
|
||||
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,
|
||||
|
||||
signInLoadingSelector,
|
||||
userSelector
|
||||
userSelector,
|
||||
themeSelector
|
||||
} from '../../redux';
|
||||
import ChildContainer from '../../Child-Container.jsx';
|
||||
|
||||
const mapStateToProps = createSelector(
|
||||
userSelector,
|
||||
themeSelector,
|
||||
signInLoadingSelector,
|
||||
showUpdateEmailViewSelector,
|
||||
(
|
||||
@ -41,9 +43,11 @@ const mapStateToProps = createSelector(
|
||||
sendNotificationEmail,
|
||||
sendQuincyEmail
|
||||
},
|
||||
theme,
|
||||
showLoading,
|
||||
showUpdateEmailView
|
||||
) => ({
|
||||
currentTheme: theme,
|
||||
email,
|
||||
isAvailableForHire,
|
||||
isGithubCool,
|
||||
@ -71,6 +75,7 @@ const mapDispatchToProps = {
|
||||
|
||||
const propTypes = {
|
||||
children: PropTypes.element,
|
||||
currentTheme: PropTypes.string,
|
||||
email: PropTypes.string,
|
||||
initialLang: PropTypes.string,
|
||||
isAvailableForHire: PropTypes.bool,
|
||||
@ -113,6 +118,7 @@ export class Settings extends React.Component {
|
||||
|
||||
render() {
|
||||
const {
|
||||
currentTheme,
|
||||
email,
|
||||
isAvailableForHire,
|
||||
isGithubCool,
|
||||
@ -182,7 +188,7 @@ export class Settings extends React.Component {
|
||||
bsSize='lg'
|
||||
bsStyle='primary'
|
||||
className='btn-link-social'
|
||||
onClick={ toggleNightMode }
|
||||
onClick={ () => toggleNightMode(username, currentTheme) }
|
||||
>
|
||||
Toggle Night Mode
|
||||
</Button>
|
||||
|
@ -42,7 +42,8 @@ export function updateUserEmailEpic(actions, { getState }) {
|
||||
.catch(doActionOnError(() => oldEmail ?
|
||||
updateUserEmail(username, oldEmail) :
|
||||
null
|
||||
));
|
||||
))
|
||||
.filter(Boolean);
|
||||
return Observable.merge(optimisticUpdate, ajaxUpdate);
|
||||
});
|
||||
}
|
||||
@ -109,6 +110,7 @@ export function updateUserFlagEpic(actions, { getState }) {
|
||||
}
|
||||
return updateUserFlag(username, flag);
|
||||
})
|
||||
.filter(Boolean)
|
||||
.catch(doActionOnError(() => {
|
||||
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 loopback from 'loopback';
|
||||
|
||||
import { themes } from '../utils/themes';
|
||||
import { saveUser, observeMethod } from '../../server/utils/rx.js';
|
||||
import { blacklistedUsernames } from '../../server/utils/constants.js';
|
||||
import { wrapHandledError } from '../../server/utils/create-handled-error.js';
|
||||
@ -762,10 +763,7 @@ module.exports = function(User) {
|
||||
}
|
||||
);
|
||||
|
||||
User.themes = {
|
||||
night: true,
|
||||
default: true
|
||||
};
|
||||
User.themes = themes;
|
||||
|
||||
User.prototype.updateTheme = function updateTheme(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 {
|
||||
loggerMiddleware,
|
||||
errorThrowerMiddleware
|
||||
} from '../utils/react.js';
|
||||
import { createApp, provideStore, App } from '../../common/app';
|
||||
@ -27,10 +26,7 @@ const routes = [
|
||||
|
||||
const devRoutes = [];
|
||||
|
||||
const middlewares = [
|
||||
isDev ? loggerMiddleware : null,
|
||||
isDev ? errorThrowerMiddleware : null
|
||||
].filter(Boolean);
|
||||
const middlewares = isDev ? [errorThrowerMiddleware] : [];
|
||||
export default function reactSubRouter(app) {
|
||||
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 => {
|
||||
if (action.error) {
|
||||
throw action.payload;
|
||||
}
|
||||
return next(action);
|
||||
};
|
||||
|
||||
export const loggerMiddleware = () => next => action => {
|
||||
log('action: \n', action);
|
||||
return next(action);
|
||||
};
|
||||
|
Reference in New Issue
Block a user