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:
Berkeley Martinez
2017-12-07 16:13:19 -08:00
committed by Quincy Larson
parent 9051faee79
commit 2e410330f1
40 changed files with 771 additions and 626 deletions

View File

@ -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);
});
}

View File

@ -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
);
}

View File

@ -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,

View File

@ -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);
}

View File

@ -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 {

View File

@ -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);
}

View File

@ -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;

View File

@ -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

View File

@ -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
);

View File

@ -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);
});

View File

@ -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
);

View 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);

View File

@ -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"; }

View File

@ -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';

View File

@ -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/;

View File

@ -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);
}
],

View 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 :

View File

@ -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
);

View File

@ -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);

View 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);

View File

@ -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 }) => ({

View File

@ -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 = {

View File

@ -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

View File

@ -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();

View 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,
);

View File

@ -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 => {

View File

@ -3,7 +3,7 @@ import {
getNextChallenge,
getFirstChallengeOfNextBlock,
getFirstChallengeOfNextSuperBlock
} from './utils.js';
} from './';
test('getNextChallenge', t => {
t.plan(7);

View File

@ -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 }) {

View File

@ -19,7 +19,7 @@ import {
testsSelector,
outputSelector
} from '../../redux';
import { descriptionRegex } from '../../utils.js';
import { descriptionRegex } from '../../utils';
import {
createFormValidator,

View File

@ -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 }

View File

@ -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';

View File

@ -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';

View File

@ -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%;
}

View File

@ -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>

View File

@ -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);
}));

View File

@ -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');
}

View File

@ -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
View File

@ -0,0 +1,10 @@
export const themes = {
night: 'night',
default: 'default'
};
export const invertTheme = currentTheme => (
!currentTheme || currentTheme === themes.default ?
themes.night :
themes.default
);

View File

@ -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();

View File

@ -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);
};