From 2e410330f1e7a0395a599d0c290c7bbb1782f69e Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Thu, 7 Dec 2017 16:13:19 -0800 Subject: [PATCH] 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 --- client/epics/execute-challenge-epic.js | 62 ------- client/epics/frame-epic.js | 151 ------------------ client/epics/index.js | 6 - client/epics/night-mode-epic.js | 52 +++--- client/frame-runner.js | 8 +- .../Nav/redux/load-current-challenge-epic.js | 24 +-- common/app/Router/redux/index.js | 1 + common/app/entities/index.js | 85 +++++++--- common/app/files/index.js | 111 ++++--------- common/app/redux/fetch-user-epic.js | 22 +-- common/app/redux/index.js | 90 ++++++----- common/app/routes/Challenges/Preview.jsx | 46 ++++++ common/app/routes/Challenges/challenges.less | 18 +++ .../Challenges}/rechallenge/builders.js | 10 +- .../Challenges}/rechallenge/throwers.js | 2 +- .../Challenges}/rechallenge/transformers.js | 15 +- .../routes/Challenges/redux/challenge-epic.js | 20 +-- .../Challenges/redux}/code-storage-epic.js | 80 +++++++--- .../routes/Challenges/redux/editor-epic.js | 25 --- .../redux/execute-challenge-epic.js | 132 +++++++++++++++ common/app/routes/Challenges/redux/index.js | 91 ++++++++--- .../app/routes/Challenges}/utils/build.js | 4 +- .../app/routes/Challenges}/utils/code-uri.js | 6 +- .../Challenges}/utils/fetch-and-cache.js | 2 +- common/app/routes/Challenges/utils/frame.js | 136 ++++++++++++++++ .../Challenges/{utils.js => utils/index.js} | 72 ++++++++- .../{utils.test.js => utils/index.test.js} | 2 +- .../routes/Challenges/views/Modern/Show.jsx | 6 +- .../Challenges/views/backend/Back-End.jsx | 2 +- .../Challenges/views/classic/Editor.jsx | 5 +- .../Challenges/views/classic/Preview.jsx | 20 --- .../routes/Challenges/views/classic/Show.jsx | 2 +- .../Challenges/views/classic/classic.less | 11 -- common/app/routes/Settings/Settings.jsx | 10 +- .../routes/Settings/redux/update-user-epic.js | 4 +- common/app/utils/classic-file.js | 33 ---- common/models/user.js | 6 +- common/utils/themes.js | 10 ++ server/boot/react.js | 6 +- server/utils/react.js | 9 -- 40 files changed, 771 insertions(+), 626 deletions(-) delete mode 100644 client/epics/execute-challenge-epic.js delete mode 100644 client/epics/frame-epic.js create mode 100644 common/app/routes/Challenges/Preview.jsx rename {client => common/app/routes/Challenges}/rechallenge/builders.js (98%) rename {client => common/app/routes/Challenges}/rechallenge/throwers.js (97%) rename {client => common/app/routes/Challenges}/rechallenge/transformers.js (89%) rename {client/epics => common/app/routes/Challenges/redux}/code-storage-epic.js (64%) delete mode 100644 common/app/routes/Challenges/redux/editor-epic.js create mode 100644 common/app/routes/Challenges/redux/execute-challenge-epic.js rename {client => common/app/routes/Challenges}/utils/build.js (93%) rename {client => common/app/routes/Challenges}/utils/code-uri.js (93%) rename {client => common/app/routes/Challenges}/utils/fetch-and-cache.js (96%) create mode 100644 common/app/routes/Challenges/utils/frame.js rename common/app/routes/Challenges/{utils.js => utils/index.js} (77%) rename common/app/routes/Challenges/{utils.test.js => utils/index.test.js} (99%) delete mode 100644 common/app/routes/Challenges/views/classic/Preview.jsx delete mode 100644 common/app/utils/classic-file.js create mode 100644 common/utils/themes.js diff --git a/client/epics/execute-challenge-epic.js b/client/epics/execute-challenge-epic.js deleted file mode 100644 index c78d9dade3..0000000000 --- a/client/epics/execute-challenge-epic.js +++ /dev/null @@ -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); - }); -} diff --git a/client/epics/frame-epic.js b/client/epics/frame-epic.js deleted file mode 100644 index 7ed663b51b..0000000000 --- a/client/epics/frame-epic.js +++ /dev/null @@ -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) => ` - -`; - - -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 - ); -} diff --git a/client/epics/index.js b/client/epics/index.js index 328050755b..d17b5a70e5 100644 --- a/client/epics/index.js +++ b/client/epics/index.js @@ -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, diff --git a/client/epics/night-mode-epic.js b/client/epics/night-mode-epic.js index ae27c26575..58b4b01354 100644 --- a/client/epics/night-mode-epic.js +++ b/client/epics/night-mode-epic.js @@ -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'); - // catch existing night mode users - persistTheme(theme); + ::ofType( + types.fetchUser.complete, + types.toggleNightMode, + types.postThemeComplete + ) + .map(_.flow(getState, themeSelector)) + // catch existing night mode users + .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); } diff --git a/client/frame-runner.js b/client/frame-runner.js index 8e49c3d5f1..19da925dff 100644 --- a/client/frame-runner.js +++ b/client/frame-runner.js @@ -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 { diff --git a/common/app/Nav/redux/load-current-challenge-epic.js b/common/app/Nav/redux/load-current-challenge-epic.js index 066bf5986f..456d5f401f 100644 --- a/common/app/Nav/redux/load-current-challenge-epic.js +++ b/common/app/Nav/redux/load-current-challenge-epic.js @@ -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); } diff --git a/common/app/Router/redux/index.js b/common/app/Router/redux/index.js index 8b2690c987..be8b951eb5 100644 --- a/common/app/Router/redux/index.js +++ b/common/app/Router/redux/index.js @@ -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; diff --git a/common/app/entities/index.js b/common/app/entities/index.js index 2b80834acd..0223edb0d8 100644 --- a/common/app/entities/index.js +++ b/common/app/entities/index.js @@ -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,33 +87,58 @@ 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, - [username]: { - ...state[username], - points, - challengeMap: { - ...state[username].challengeMap, - [challengeInfo.id]: challengeInfo + user: { + ...state.user, + [username]: { + ...state.user[username], + points, + challengeMap: { + ...state.user[username].challengeMap, + [challengeInfo.id]: challengeInfo + } } } }), [types.updateUserFlag]: (state, { payload: { username, flag } }) => ({ ...state, - [username]: { - ...state[username], - [flag]: !state[username][flag] + user: { + ...state.user, + [username]: { + ...state.user[username], + [flag]: !state.user[username][flag] + } } }), [types.updateUserEmail]: (state, { payload: { username, email } }) => ({ ...state, - [username]: { - ...state[username], - email + user: { + ...state.user, + [username]: { + ...state.user[username], + email + } } }), [types.updateUserLang]: @@ -110,9 +149,12 @@ export default composeReducers( } ) => ({ ...state, - [username]: { - ...state[username], - languageTag + user: { + ...state.user, + [username]: { + ...state.user[username], + languageTag + } } }), [types.updateUserCurrentChallenge]: @@ -123,9 +165,12 @@ export default composeReducers( } ) => ({ ...state, - [username]: { - ...state[username], - currentChallengeId + user: { + ...state.user, + [username]: { + ...state.user[username], + currentChallengeId + } } }) }), diff --git a/common/app/files/index.js b/common/app/files/index.js index 3b0a0ca4b6..d6a65493ad 100644 --- a/common/app/files/index.js +++ b/common/app/files/index.js @@ -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 }}) => ({ - ...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 +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]) + }; + } + 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; - }, {}); - } - 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 + return state; + } ); diff --git a/common/app/redux/fetch-user-epic.js b/common/app/redux/fetch-user-epic.js index 32eb86429e..447e8366f2 100644 --- a/common/app/redux/fetch-user-epic.js +++ b/common/app/redux/fetch-user-epic.js @@ -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); }); diff --git a/common/app/redux/index.js b/common/app/redux/index.js index e802af869c..4523794bf5 100644 --- a/common/app/redux/index.js +++ b/common/app/redux/index.js @@ -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 = {}) => { - return challengeMap[challengeName] || {}; - } -); +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 ); diff --git a/common/app/routes/Challenges/Preview.jsx b/common/app/routes/Challenges/Preview.jsx new file mode 100644 index 0000000000..1e1faf8632 --- /dev/null +++ b/common/app/routes/Challenges/Preview.jsx @@ -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 ( +
+ { + !isJSEnabled && ( + + JavaScript is disabled. Execute code to enable + + ) + } +