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 ( +
'' + 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 => { diff --git a/common/app/routes/Challenges/utils.test.js b/common/app/routes/Challenges/utils/index.test.js similarity index 99% rename from common/app/routes/Challenges/utils.test.js rename to common/app/routes/Challenges/utils/index.test.js index 06956afcea..7190f411c8 100644 --- a/common/app/routes/Challenges/utils.test.js +++ b/common/app/routes/Challenges/utils/index.test.js @@ -3,7 +3,7 @@ import { getNextChallenge, getFirstChallengeOfNextBlock, getFirstChallengeOfNextSuperBlock -} from './utils.js'; +} from './'; test('getNextChallenge', t => { t.plan(7); diff --git a/common/app/routes/Challenges/views/Modern/Show.jsx b/common/app/routes/Challenges/views/Modern/Show.jsx index cb6d2a1bd2..05b57bfffe 100644 --- a/common/app/routes/Challenges/views/Modern/Show.jsx +++ b/common/app/routes/Challenges/views/Modern/Show.jsx @@ -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 }) { diff --git a/common/app/routes/Challenges/views/backend/Back-End.jsx b/common/app/routes/Challenges/views/backend/Back-End.jsx index 47338139a9..47fe519480 100644 --- a/common/app/routes/Challenges/views/backend/Back-End.jsx +++ b/common/app/routes/Challenges/views/backend/Back-End.jsx @@ -19,7 +19,7 @@ import { testsSelector, outputSelector } from '../../redux'; -import { descriptionRegex } from '../../utils.js'; +import { descriptionRegex } from '../../utils'; import { createFormValidator, diff --git a/common/app/routes/Challenges/views/classic/Editor.jsx b/common/app/routes/Challenges/views/classic/Editor.jsx index 94cbe65b74..8ab61b2f93 100644 --- a/common/app/routes/Challenges/views/classic/Editor.jsx +++ b/common/app/routes/Challenges/views/classic/Editor.jsx @@ -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 { >}> classicEditorUpdated(fileKey, change) } options={ this.createOptions({ executeChallenge, mode }) } ref='editor' value={ content } diff --git a/common/app/routes/Challenges/views/classic/Preview.jsx b/common/app/routes/Challenges/views/classic/Preview.jsx deleted file mode 100644 index ee0a0d2406..0000000000 --- a/common/app/routes/Challenges/views/classic/Preview.jsx +++ /dev/null @@ -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 ( - - -- ); - } -} - -Preview.displayName = 'Preview'; diff --git a/common/app/routes/Challenges/views/classic/Show.jsx b/common/app/routes/Challenges/views/classic/Show.jsx index e6807028b1..713e837533 100644 --- a/common/app/routes/Challenges/views/classic/Show.jsx +++ b/common/app/routes/Challenges/views/classic/Show.jsx @@ -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'; diff --git a/common/app/routes/Challenges/views/classic/classic.less b/common/app/routes/Challenges/views/classic/classic.less index e7cdb69903..03c5ca1e48 100644 --- a/common/app/routes/Challenges/views/classic/classic.less +++ b/common/app/routes/Challenges/views/classic/classic.less @@ -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%; -} diff --git a/common/app/routes/Settings/Settings.jsx b/common/app/routes/Settings/Settings.jsx index 6d84386346..8d4c94c24d 100644 --- a/common/app/routes/Settings/Settings.jsx +++ b/common/app/routes/Settings/Settings.jsx @@ -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 diff --git a/common/app/routes/Settings/redux/update-user-epic.js b/common/app/routes/Settings/redux/update-user-epic.js index bf5d2d66f9..41cece6c40 100644 --- a/common/app/routes/Settings/redux/update-user-epic.js +++ b/common/app/routes/Settings/redux/update-user-epic.js @@ -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); })); diff --git a/common/app/utils/classic-file.js b/common/app/utils/classic-file.js deleted file mode 100644 index 465ea65b35..0000000000 --- a/common/app/utils/classic-file.js +++ /dev/null @@ -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'); -} diff --git a/common/models/user.js b/common/models/user.js index 504f08c688..a800345e92 100644 --- a/common/models/user.js +++ b/common/models/user.js @@ -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]) { diff --git a/common/utils/themes.js b/common/utils/themes.js new file mode 100644 index 0000000000..0f96929894 --- /dev/null +++ b/common/utils/themes.js @@ -0,0 +1,10 @@ +export const themes = { + night: 'night', + default: 'default' +}; + +export const invertTheme = currentTheme => ( + !currentTheme || currentTheme === themes.default ? + themes.night : + themes.default +); diff --git a/server/boot/react.js b/server/boot/react.js index 83b41692cf..02332feae9 100644 --- a/server/boot/react.js +++ b/server/boot/react.js @@ -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(); diff --git a/server/utils/react.js b/server/utils/react.js index 3c6d3299d8..83492c9ddb 100644 --- a/server/utils/react.js +++ b/server/utils/react.js @@ -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); -};