diff --git a/client/sagas/README.md b/client/epics/README.md similarity index 100% rename from client/sagas/README.md rename to client/epics/README.md diff --git a/client/sagas/analytics-saga.js b/client/epics/analytics-epic.js similarity index 87% rename from client/sagas/analytics-saga.js rename to client/epics/analytics-epic.js index 9df6fcbf99..de4bfda5aa 100644 --- a/client/sagas/analytics-saga.js +++ b/client/epics/analytics-epic.js @@ -1,5 +1,5 @@ import { Observable } from 'rx'; -import { createErrorObservable } from '../../common/app/redux/actions'; +import { createErrorObservable } from '../../common/app/redux'; import capitalize from 'lodash/capitalize'; // analytics types @@ -27,7 +27,7 @@ function formatFields({ type, ...fields }) { }, { type }); } -export default function analyticsSaga(actions, getState, { window }) { +export default function analyticsSaga(actions, { getState }, { window }) { const { ga } = window; if (typeof ga !== 'function') { console.log('GA not found'); @@ -39,5 +39,6 @@ export default function analyticsSaga(actions, getState, { window }) { .filter(Boolean) // ga always returns undefined .map(({ type, ...fields }) => ga('send', type, fields)) + .ignoreElements() .catch(createErrorObservable); } diff --git a/client/sagas/code-storage-saga.js b/client/epics/code-storage-epic.js similarity index 67% rename from client/sagas/code-storage-saga.js rename to client/epics/code-storage-epic.js index f83b6b2c9f..e48d8ef48a 100644 --- a/client/sagas/code-storage-saga.js +++ b/client/epics/code-storage-epic.js @@ -1,22 +1,26 @@ import { Observable } from 'rx'; +import { combineEpics, ofType } from 'redux-epic'; import store from 'store'; import { removeCodeUri, getCodeUri } from '../utils/code-uri'; -import { ofType } from '../../common/utils/get-actions-of-type'; -import { setContent } from '../../common/utils/polyvinyl'; -import combineSagas from '../../common/utils/combine-sagas'; -import { userSelector } from '../../common/app/redux/selectors'; -import { makeToast } from '../../common/app/toasts/redux/actions'; -import types from '../../common/app/routes/challenges/redux/types'; +import { setContent } from '../../common/utils/polyvinyl'; + import { + userSelector, + challengeSelector +} from '../../common/app/redux'; +import { makeToast } from '../../common/app/Toasts/redux'; +import { + types, savedCodeFound, updateMain, - lockUntrustedCode -} from '../../common/app/routes/challenges/redux/actions'; -import { - challengeSelector -} from '../../common/app/routes/challenges/redux/selectors'; + lockUntrustedCode, + + keySelector, + filesSelector, + codeLockedSelector +} from '../../common/app/routes/challenges/redux'; const legacyPrefixes = [ 'Bonfire: ', @@ -54,43 +58,39 @@ function legacyToFile(code, files, key) { return { [key]: setContent(code, files[key]) }; } -export function clearCodeSaga(actions, getState) { - return actions - ::ofType(types.clearSavedCode) +export function clearCodeEpic(actions, { getState }) { + return actions::ofType(types.clearSavedCode) .map(() => { - const { challengesApp: { id = '' } } = getState(); + const { id } = challengeSelector(getState()); store.remove(id); - return null; - }); + }) + .ignoreElements(); } -export function saveCodeSaga(actions, getState) { - return actions - ::ofType(types.saveCode) +export function saveCodeEpic(actions, { getState }) { + return actions::ofType(types.saveCode) // do not save challenge if code is locked - .filter(() => !getState().challengesApp.isCodeLocked) + .filter(() => !codeLockedSelector(getState())) .map(() => { - const { challengesApp: { id = '', files = {} } } = getState(); + const { id } = challengeSelector(getState()); + const files = filesSelector(getState()); store.set(id, files); - return null; - }); + }) + .ignoreElements(); } -export function loadCodeSaga(actions, getState, { window, location }) { - return actions - ::ofType(types.loadCode) +export function loadCodeEpic(actions, { getState }, { window, location }) { + return actions::ofType(types.loadCode) .flatMap(() => { let finalFiles; const state = getState(); - const { user } = userSelector(state); - const { challenge } = challengeSelector(state); + const user = userSelector(state); + const challenge = challengeSelector(state); + const key = keySelector(state); + const files = filesSelector(state); const { - challengesApp: { - id = '', - files = {}, - legacyKey = '', - key - } - } = state; + id, + name: legacyKey + } = challenge; const codeUriFound = getCodeUri( location, window.decodeURIComponent @@ -149,4 +149,4 @@ export function loadCodeSaga(actions, getState, { window, location }) { }); } -export default combineSagas(saveCodeSaga, loadCodeSaga, clearCodeSaga); +export default combineEpics(saveCodeEpic, loadCodeEpic, clearCodeEpic); diff --git a/client/sagas/err-saga.js b/client/epics/err-epic.js similarity index 51% rename from client/sagas/err-saga.js rename to client/epics/err-epic.js index 511d056f5c..12c0689843 100644 --- a/client/sagas/err-saga.js +++ b/client/epics/err-epic.js @@ -1,8 +1,7 @@ -import { makeToast } from '../../common/app/toasts/redux/actions'; +import { makeToast } from '../../common/app/Toasts/redux'; -export default function errorSaga(action$) { - return action$ - .filter(({ error }) => !!error) +export default function errorSaga(actions) { + return actions.filter(({ error }) => !!error) .map(({ error }) => error) .doOnNext(error => console.error(error)) .map(() => makeToast({ diff --git a/client/sagas/build-challenge-epic.js b/client/epics/execute-challenge-epic.js similarity index 63% rename from client/sagas/build-challenge-epic.js rename to client/epics/execute-challenge-epic.js index 29d404ccdf..0fbf3a6886 100644 --- a/client/sagas/build-challenge-epic.js +++ b/client/epics/execute-challenge-epic.js @@ -1,37 +1,40 @@ import { Scheduler, Observable } from 'rx'; +import { ofType } from 'redux-epic'; + import { buildClassic, buildBackendChallenge } from '../utils/build.js'; -import { ofType } from '../../common/utils/get-actions-of-type.js'; import { + createErrorObservable, + challengeSelector -} from '../../common/app/routes/challenges/redux/selectors'; -import types from '../../common/app/routes/challenges/redux/types'; -import { createErrorObservable } from '../../common/app/redux/actions'; +} from '../../common/app/redux'; import { + types, + frameMain, frameTests, initOutput, - saveCode -} from '../../common/app/routes/challenges/redux/actions'; + saveCode, -export default function buildChallengeEpic(actions, getState) { - return actions - ::ofType(types.executeChallenge, types.updateMain) + filesSelector, + codeLockedSelector +} from '../../common/app/routes/challenges/redux'; + +export default function executeChallengeEpic(actions, { getState }) { + return actions::ofType(types.executeChallenge, types.updateMain) // if isCodeLocked do not run challenges - .filter(() => !getState().challengesApp.isCodeLocked) + .filter(() => !codeLockedSelector(getState())) .debounce(750) .flatMapLatest(({ type }) => { const shouldProxyConsole = type === types.updateMain; const state = getState(); - const { files } = state.challengesApp; + const files = filesSelector(state); const { - challenge: { - required = [], - type: challengeType - } + required = [], + type: challengeType } = challengeSelector(state); if (challengeType === 'backend') { return buildBackendChallenge(state) @@ -53,6 +56,7 @@ export default function buildChallengeEpic(actions, getState) { initOutput('// running test') : null )) + .filter(Boolean) .catch(createErrorObservable); }); } diff --git a/client/sagas/frame-epic.js b/client/epics/frame-epic.js similarity index 88% rename from client/sagas/frame-epic.js rename to client/epics/frame-epic.js index 2a69c31b48..f8dd19b0d9 100644 --- a/client/sagas/frame-epic.js +++ b/client/epics/frame-epic.js @@ -1,14 +1,18 @@ 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 { ofType } from '../../common/utils/get-actions-of-type'; -import types from '../../common/app/routes/challenges/redux/types'; import { + types, + updateOutput, checkChallenge, - updateTests -} from '../../common/app/routes/challenges/redux/actions'; + 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 @@ -86,7 +90,7 @@ function frameTests({ build, sources, checkChallengePayload } = {}, document) { tests.close(); } -export default function frameEpic(actions, getState, { window, document }) { +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 = {}; @@ -95,10 +99,9 @@ export default function frameEpic(actions, getState, { window, document }) { 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) + const result = actions::ofType(types.frameMain, types.frameTests) // if isCodeLocked is true do not frame user code - .filter(() => !getState().challengesApp.isCodeLocked) + .filter(() => !codeLockedSelector(getState())) .map(action => { if (action.type === types.frameMain) { return frameMain(action.payload, document, proxyLogger); @@ -111,7 +114,7 @@ export default function frameEpic(actions, getState, { window, document }) { proxyLogger.map(updateOutput), frameReady.flatMap(({ checkChallengePayload }) => { const { frame } = getFrameDocument(document, testId); - const { tests } = getState().challengesApp; + const tests = testsSelector(getState()); const postTests = Observable.of( updateOutput('// tests completed'), checkChallenge(checkChallengePayload) diff --git a/client/epics/hard-go-to-epic.js b/client/epics/hard-go-to-epic.js new file mode 100644 index 0000000000..b7850edfad --- /dev/null +++ b/client/epics/hard-go-to-epic.js @@ -0,0 +1,10 @@ +import { types } from '../../common/app/redux'; +import { ofType } from 'redux-epic'; + +export default function hardGoToSaga(actions, { getState }, { history }) { + return actions::ofType(types.hardGoTo) + .map(({ payload = '/settings' }) => { + history.pushState(history.state, null, payload); + return null; + }); +} diff --git a/client/epics/index.js b/client/epics/index.js new file mode 100644 index 0000000000..328050755b --- /dev/null +++ b/client/epics/index.js @@ -0,0 +1,21 @@ +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'; +import titleEpic from './title-epic.js'; + +export default [ + analyticsEpic, + codeStorageEpic, + errEpic, + executeChallengeEpic, + frameEpic, + hardGoToEpic, + mouseTrapEpic, + nightModeEpic, + titleEpic +]; diff --git a/client/sagas/mouse-trap-saga.js b/client/epics/mouse-trap-epic.js similarity index 83% rename from client/sagas/mouse-trap-saga.js rename to client/epics/mouse-trap-epic.js index 985117c852..ae79f113ab 100644 --- a/client/sagas/mouse-trap-saga.js +++ b/client/epics/mouse-trap-epic.js @@ -4,7 +4,7 @@ import { push } from 'react-router-redux'; import { toggleNightMode, hardGoTo -} from '../../common/app/redux/actions'; +} from '../../common/app/redux'; function bindKey(key, actionCreator) { return Observable.fromEventPattern( @@ -21,8 +21,8 @@ const softRedirects = { 'g n o': '/settings' }; -export default function mouseTrapSaga(actions$) { - const traps$ = [ +export default function mouseTrapSaga(actions) { + const traps = [ ...Object.keys(softRedirects) .map(key => bindKey(key, () => push(softRedirects[key]))), bindKey( @@ -39,5 +39,5 @@ export default function mouseTrapSaga(actions$) { ), bindKey('g t n', toggleNightMode) ]; - return Observable.merge(traps$).takeUntil(actions$.last()); + return Observable.merge(traps).takeUntil(actions.last()); } diff --git a/client/sagas/night-mode-saga.js b/client/epics/night-mode-epic.js similarity index 82% rename from client/sagas/night-mode-saga.js rename to client/epics/night-mode-epic.js index 1ebec7c60b..b6e84b16bf 100644 --- a/client/sagas/night-mode-saga.js +++ b/client/epics/night-mode-epic.js @@ -2,12 +2,17 @@ import { Observable } from 'rx'; import store from 'store'; import { postJSON$ } from '../../common/utils/ajax-stream'; -import types from '../../common/app/redux/types'; import { + types, + addThemeToBody, updateTheme, - createErrorObservable -} from '../../common/app/redux/actions'; + + createErrorObservable, + + themeSelector, + csrfSelector +} from '../../common/app/redux'; function persistTheme(theme) { store.set('fcc-theme', theme); @@ -15,7 +20,7 @@ function persistTheme(theme) { export default function nightModeSaga( actions, - getState, + { getState }, { document: { body } } ) { const toggleBodyClass = actions @@ -35,7 +40,7 @@ export default function nightModeSaga( const optimistic = toggle .flatMap(() => { - const { app: { theme } } = getState(); + const { theme } = themeSelector(getState()); const newTheme = !theme || theme === 'default' ? 'night' : 'default'; persistTheme(newTheme); return Observable.of( @@ -47,7 +52,8 @@ export default function nightModeSaga( const ajax = toggle .debounce(250) .flatMapLatest(() => { - const { app: { theme, csrfToken: _csrf } } = getState(); + const _csrf = csrfSelector(getState()); + const theme = themeSelector(getState()); return postJSON$('/update-my-theme', { _csrf, theme }) .catch(createErrorObservable); }); diff --git a/client/epics/title-epic.js b/client/epics/title-epic.js new file mode 100644 index 0000000000..4a9656c4f8 --- /dev/null +++ b/client/epics/title-epic.js @@ -0,0 +1,10 @@ +import { ofType } from 'redux-epic'; +import { types, titleSelector } from '../../common/app/redux'; + +export default function titleSage(actions, { getState }, { document }) { + return actions::ofType(types.updateTitle) + .do(() => { + document.title = titleSelector(getState()); + }) + .ignoreElements(); +} diff --git a/client/index.js b/client/index.js index 90a17e5e76..9bc7fbf57c 100644 --- a/client/index.js +++ b/client/index.js @@ -18,8 +18,8 @@ import createApp from '../common/app'; import provideStore from '../common/app/provide-store'; import { getLangFromPath } from '../common/app/utils/lang'; -// client specific sagas -import sagas from './sagas'; +// client specific epics +import epics from './epics'; import { isColdStored, @@ -51,7 +51,7 @@ sendPageAnalytics(history, window.ga); const devTools = window.devToolsExtension ? window.devToolsExtension() : f => f; const adjustUrlOnReplay = !!window.devToolsExtension; -const sagaOptions = { +const epicOptions = { isDev, window, document: window.document, @@ -66,8 +66,8 @@ createApp({ serviceOptions, initialState, middlewares: [ routerMiddleware(history) ], - sagas: [...sagas ], - sagaOptions, + epics, + epicOptions, reducers: { routing }, enhancers: [ devTools ] }) diff --git a/client/less/code-mirror.less b/client/less/code-mirror.less index a2535218c1..69430c2b3d 100644 --- a/client/less/code-mirror.less +++ b/client/less/code-mirror.less @@ -1,3 +1,12 @@ +.ReactCodeMirror { + height: 100%; +} + +.CodeMirror { + height: 100%; + line-height: 1 !important; +} + .CodeMirror span { font-size: 18px; font-family: "Ubuntu Mono"; @@ -6,12 +15,9 @@ height: 100%; } -.CodeMirror { - border-radius: 5px; - height: auto; - line-height: 1 !important; +.CodeMirror-gutters { + background-color: @body-bg; } - .CodeMirror-linenumber { font-size: 18px; font-family: "Ubuntu Mono"; diff --git a/client/less/lib/bootstrap/modals.less b/client/less/lib/bootstrap/modals.less index 39f5621fa5..3671ff0715 100755 --- a/client/less/lib/bootstrap/modals.less +++ b/client/less/lib/bootstrap/modals.less @@ -90,7 +90,6 @@ .modal-title { margin: 0; line-height: @modal-title-line-height; - color: @gray-lighter; } // Modal body diff --git a/client/less/lib/bootstrap/navbar.less b/client/less/lib/bootstrap/navbar.less index c0ffe7f998..21f02d52f7 100755 --- a/client/less/lib/bootstrap/navbar.less +++ b/client/less/lib/bootstrap/navbar.less @@ -11,7 +11,6 @@ .navbar { position: relative; min-height: @navbar-height; // Ensure a navbar always shows (e.g., without a .navbar-brand in collapsed mode) - margin-bottom: @navbar-margin-bottom; border: 1px solid transparent; // Prevent floats from breaking the navbar @@ -191,12 +190,13 @@ // Custom button for toggling the `.navbar-collapse`, powered by the collapse // JavaScript plugin. +@navbar-toggle-height: 30px; .navbar-toggle { position: relative; float: right; margin-right: @navbar-padding-horizontal; - padding: 9px 10px; - .navbar-vertical-align(34px); + padding: 6px 10px; + .navbar-vertical-align(@navbar-toggle-height); background-color: transparent; background-image: none; // Reset unusual Firefox-on-Android default style; see https://github.com/necolas/normalize.css/issues/214 border: 1px solid transparent; diff --git a/client/less/lib/bootstrap/variables.less b/client/less/lib/bootstrap/variables.less index d9e5a316fe..a43c21f8da 100755 --- a/client/less/lib/bootstrap/variables.less +++ b/client/less/lib/bootstrap/variables.less @@ -358,8 +358,9 @@ //## // Basics of a navbar -@navbar-height: 50px; +@navbar-height: 36px; @navbar-margin-bottom: @line-height-computed+10px; +@navbar-total-height: @navbar-height + @navbar-margin-bottom; @navbar-border-radius: @border-radius-base; @navbar-padding-horizontal: floor((@grid-gutter-width / 2)); @navbar-padding-vertical: ((@navbar-height - @line-height-computed) / 2); diff --git a/client/less/main.less b/client/less/main.less index 2ddb7ad613..043d001eb2 100644 --- a/client/less/main.less +++ b/client/less/main.less @@ -112,10 +112,6 @@ h1, h2, h3, h4, h5, h6, p, li { box-shadow: 2px 4px 1px rgba(0, 0, 0, 0.3); } -.btn-nav { - margin-top: 10px; -} - .large-li > li { list-style: none; } @@ -130,29 +126,6 @@ h1, h2, h3, h4, h5, h6, p, li { margin: 2px; } -.navbar-brand { - font-size: 26px; -} - -.navbar > .container { - width: auto; - padding-right: 0px; - - @media (max-width: 767px) { - // container default padding size - padding-left: 15px; - padding-right: 15px; - } -} - -// defined in bootstrap -@navbar-total-height: @navbar-height + @navbar-margin-bottom; -.nav-height { - border: none; - height: @navbar-height; - width: 100%; -} - .landing-icon { height: 200px; width: 200px; @@ -268,46 +241,6 @@ h1, h2, h3, h4, h5, h6, p, li { display: none; } -.nav-logo { - height: 40px; - margin-top: -10px; - - @media (max-width: 397px) { - height: 30px; - margin-top: -5px; - } - @media (max-width: 335px) { - height: 25px; - margin-top: -2px; - } -} - -.navbar-right { - @media (min-width: 767px) { - margin-right:0; - } - @media (max-width: 991px) and (min-width: 767px) { - position: absolute; - left: 0; - right: 0; - margin-right: 0px; - white-space: nowrap; - } - background-color: @brand-primary; - text-align: center; -} -.navbar { - white-space: nowrap; - border: none; - line-height: 1; - @media (min-width: 767px) { - padding-left: 15px; - padding-right: 30px; - } -} - -li.avatar-points, li.avatar-points > a { padding:0; margin:0 } - .thin-progress-bar { height: 8px; margin-top: 3px; @@ -336,10 +269,6 @@ li.avatar-points, li.avatar-points > a { padding:0; margin:0 } margin-bottom: 10px; } -.navbar { - background-color: @brand-primary; -} - a { font-weight: bold; } @@ -352,13 +281,6 @@ p { line-height: 1.8; } -.navbar-nav > li > a { - color: @body-bg; - &:hover { - color: @brand-primary; - } -} - .hug-top { margin-top: -35px; margin-bottom: -10px; @@ -456,58 +378,6 @@ thead { margin-bottom: 50px; } -.profile-picture { - height: 50px; - width: 50px; -} - -.brownie-points-nav { - @media (min-width: 991px) and (max-width: 1030px) { - float: none !important; - padding-right: 10px; - display: inline-block; - } - @media (min-width: 991px) { - float: left; - padding: 15px; - } -} - -.navbar-nav a { - color: @body-bg; - font-size: 20px; - margin-top: -5px; - margin-bottom: -5px; -} - -.navbar-toggle { - color: @body-bg; - - &:hover, - &:focus { - color: #4a2b0f; - } -} - - -.signup-btn-nav { - margin-top: -2px !important; - padding-top: 10px !important; - padding-bottom: 10px !important; - margin-right: -12px; - @media (min-width: 991px) and (max-width: 1010px) { - margin-left: -10px; - margin-right: -5px; - } -} - -a[href="/email-signup"], a[href="/email-signin"] { - &.btn-social.btn-lg > :first-child { - line-height:43px; - font-size:26px; - } -} - form.update-email .btn{ margin:0; width:40%; @@ -517,19 +387,6 @@ form.update-email .btn{ } } -.public-profile-img { - height: 200px; - width: 200px; - border-radius: 5px; -} - -.ng-invalid.ng-dirty { - border-color: #FA787E; -} -.ng-valid.ng-dirty { - border-color: #78FA89; -} - .flat-top { margin-top: -5px; } @@ -596,7 +453,6 @@ form.update-email .btn{ color: #009900 } - .default-border-radius { border-radius: 5px; } @@ -635,10 +491,6 @@ form.update-email .btn{ } } -.navbar-collapse { - border-top: 0; -} - .challenge-list-header { background-color: @brand-primary; color: @gray-lighter; @@ -655,53 +507,10 @@ form.update-email .btn{ text-align: right; } -.fcc-footer { - width: 100%; - height: 50px; - text-align: center; - background-color: @brand-primary; - padding: 12px; - bottom: 0; - left: 0; - position: absolute; - a { - font-size: 20px; - color: @gray-lighter; - margin-left: 0px; - margin-right: 0px; - padding-left: 10px; - padding-right: 10px; - padding-top: 14px; - padding-bottom: 12px; - &:hover { - color: @brand-primary; - background-color: @gray-lighter; - text-decoration: none; - } - } -} - -.embed-responsive-twitch-chat { - padding-bottom: 117%; -} - .graph-rect { fill: #ddd !important } -.scroll-locker { - overflow-x: hidden; - overflow-y: auto; -} - -.test-vertical-center { - margin-top: 8px; -} - -.courseware-height { - min-height: 650px; -} - .btn { font-weight: 400; white-space: normal; @@ -739,65 +548,6 @@ form.update-email .btn{ } } -@media (max-width: 991px) { - .navbar-header { - float: none; - } - - .navbar-toggle { - display: block; - } - - .navbar-collapse.collapse { - display: none !important; - } - - .navbar-nav { - margin-top: 0; - } - - .navbar-nav > li { - float: none; - } - - .navbar-nav > li > a { - padding-top: 10px; - padding-bottom: 10px; - } - - .navbar-text { - float: none; - margin: 15px 0; - } - - /* since 3.1.0 */ - .navbar-collapse.collapse.in { - display: block !important; - } - - .collapsing { - overflow: hidden !important; - position: absolute; - left: 0; - right: 0; - } -} - -.navbar-toggle { - width: 80px; - padding-left: 0; - padding-right: 8px; - margin: 7px 2px 7px 0; - text-align: left; - font-size: 10px; -} - -.hamburger-text { - line-height: 0.75em; - margin-top: 10px; - font-size: 18px; -} - .story-list { padding-bottom: 30px; margin-bottom: 30px; @@ -888,8 +638,8 @@ hr { } .checklist-element { - margin-left: -60px; - margin-right: -20px; + margin-left: -60px; + margin-right: -20px; } .profile-social-icons { @@ -1048,33 +798,6 @@ code { hr { background-image: linear-gradient(to right, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0)); } - .navbar-default { - .navbar-nav { - & > li > a { - color: #CCC; - } - .dropdown-menu { - background-color: @gray; - a { - color: @night-text-color !important; - } - } - a:focus, - a:hover, - .open #nav-Community-dropdown { - background-color: #666 !important; - color: @link-hover-color !important; - } - } - } - .navbar-toggle { - &:hover, - &:focus { - background-color: #666; - color: @link-hover-color; - border-color: #666; - } - } .modal-dialog { .modal-content { background-color: @gray; @@ -1228,9 +951,5 @@ and (max-width : 400px) { // creates locally scoped imports // and prevents vaiables from overwriting each other &{ @import "./code-mirror.less"; } -&{ @import "./challenge.less"; } &{ @import "./toastr.less"; } -&{ @import "./map.less"; } -&{ @import "./sk-wave.less"; } -&{ @import "./skeleton-shimmer.less"; } &{ @import "../../common/index.less"; } diff --git a/client/less/map.less b/client/less/map.less deleted file mode 100644 index 58bf701ae0..0000000000 --- a/client/less/map.less +++ /dev/null @@ -1,238 +0,0 @@ -/* - * based off of https://github.com/gitterHQ/sidecar - * license: MIT - */ -.map-buttons { - margin-top: -10px; - & button, - & .input-group{ - width:300px; - } - .input-group{ - margin-top: 15px; - margin-left: auto; - margin-right: auto; - } -} - -.map-filter { - background:#fff; - border-color: darkgreen; -} - -.map-filter + .input-group-addon { - min-width: inherit; - width: 40px; - color: darkgreen; - background-color: #fff; - border-color: darkgreen; - .fa { - position:absolute; - top:50%; - -webkit-transform: translateY(-50%); - transform: translateY(-50%); - right:10px; - } -} - -.map-filter.filled + span.input-group-addon { - background: darkgreen; - border-color: #000d00; - color: #fff; - cursor: pointer; - padding: 0; - span { - display: inline-block; - min-height: 30px; - width: 100%; - } -} - -.map-wrapper { - display: block; - height: 100%; - width: 100%; -} - -.map-accordion { - max-width: 700px; - overflow-y: auto; - width: 100%; - - .map-accordion-panel-title { - padding-bottom: 0px; - } - - .map-accordion-panel-collapse { - transition: height 0.001s; - } - - .map-accordion-panel-nested-collapse { - transition: height 0.001s; - } - - .map-accordion-panel-nested { - margin: 0 20px; - } - - .map-accordion-panel-nested-body { - padding-left: 36px; - } - - .map-accordion-panel-nested-title > a { - cursor: pointer; - } - - @media (max-width: 400px) { - .map-accordion-panel-nested { - margin: 0; - } - .map-accordion-panel-nested-title { - padding-left: 9px; - } - .map-accordion-panel-nested-body { - padding-left: 18px; - } - } - - a:focus { - text-decoration: none; - color:darkgreen; - } - a:focus:hover { - text-decoration: underline; - color:#001800; - } - - h2 > a { - width:100%; - display:block; - background:#efefef; - padding:10px 0; - padding-left:50px; - padding-right:20px; - cursor: pointer; - } - - a > h3 { - padding-left: 40px; - padding-bottom: 10px; - display: block; - } - - .map-accordion-block { - :before { - margin-right: 15px; - } - - p { - text-indent: -15px; - margin-left: 60px; - padding-right: 20px; - @media (max-width: 400px) { - margin-left:30px; - } - } - } - - @media (max-width: 720px) { - left: 0; - right: 0; - width: 100%; - top: 195px; - bottom: 0; - // position:absolute; - overflow-x: hidden; - overflow-y: auto; - -webkit-overflow-scrolling: touch; - h2 { - margin:15px 0; - padding:0; - &:first-of-type { - margin-top:0; - } - > a { - padding: 10px 0; - padding-left: 50px; - padding-right: 20px; - font-size: 20px; - } - } - a { - margin: 10px 0; - padding: 0; - > h3 { - clear:both; - font-size:20px; - } - } - } - - .challenge-block-description { - margin:0; - margin-top:-10px; - padding:0 15px 23px 30px; - } - - span.no-link-underline { - margin-left:-30px; - color: #666; - } - div > div:last-child { - margin-bottom:30px - } -} - -.challenge-block-time { - font-size: 18px; - color: #BBBBBB; - margin-bottom: 20px; - @media (min-width: 721px) { - // margin-right: 20px; - // margin-top: -30px; - float: right; - } -} - -#noneFound { - display:none; - margin:60px 30px 0; - font-size:30px; - text-align: center; - color:darkgreen; - .fa { - display:block; - font-size:300px; - } -} - - -.night { - .map-fixed-header { - background-color: @night-body-bg; - } - #map-filter, .input-group-addon { - border-color: #292929; - background-color: #666; - color:#ABABAB; - } - .map-accordion span.no-link-underline { - color: @brand-primary; - } - .map-accordion h2 > a { - background:#666; - } - .map-accordion a:focus, #noneFound { - color: #ABABAB; - } - .input-group-addon { - &.filled{ - background: @gray; - border-color: #292929; - color: white; - } - } - .challenge-title { - color: @night-text-color; - } -} diff --git a/client/less/sk-wave.less b/client/less/sk-wave.less deleted file mode 100644 index 2427c92cb8..0000000000 --- a/client/less/sk-wave.less +++ /dev/null @@ -1,36 +0,0 @@ -// original source: -// https://github.com/tobiasahlin/SpinKit -@duration: 3s; -@delayRange: 1s; - -.create-wave-child(@numOfCol, @iter: 2) when (@iter <= @numOfCol) { - div:nth-child(@{iter}) { - animation-delay: -(@duration - (@delayRange / (@numOfCol - 1)) * (@iter - 1)); - } - .create-wave-child(@numOfCol, (@iter + 1)); -} - -.sk-wave { - height: 100px; - margin: 100px auto; - text-align: center; - width: 50px; - > div { - animation: sk-stretchdelay @duration infinite ease-in-out; - background-color: @brand-primary; - display: inline-block; - height: 100%; - margin-right: 2px; - width: 6px; - } - .create-wave-child(5) -} - -@keyframes sk-stretchdelay { - 0%, 40%, 100% { - transform: scaleY(0.4); - } - 20% { - transform: scaleY(1.0); - } -} diff --git a/client/less/skeleton-shimmer.less b/client/less/skeleton-shimmer.less deleted file mode 100644 index 2a0257691d..0000000000 --- a/client/less/skeleton-shimmer.less +++ /dev/null @@ -1,41 +0,0 @@ -@keyframes skeletonShimmer{ - 0% { - transform: translateX(-48px); - } - 100% { - transform: translateX(1000px); - } -} - -.shimmer { - position: relative; - min-height: 18px; - - .row { - height: 18px; - - .col-xs-12 { - padding-right: 12px; - height: 17px; - } - } - - .sprite-wrapper { - background-color: #333; - height: 17px; - width: 75%; - } - - .sprite { - animation-name: skeletonShimmer; - animation-duration: 2.5s; - animation-timing-function: linear; - animation-iteration-count: infinite; - animation-direction: normal; - background: white; - box-shadow: 0 0 3px 2px; - height: 17px; - width: 2px; - z-index: 5; - } -} diff --git a/client/sagas/hard-go-to-saga.js b/client/sagas/hard-go-to-saga.js deleted file mode 100644 index 1d967a265d..0000000000 --- a/client/sagas/hard-go-to-saga.js +++ /dev/null @@ -1,11 +0,0 @@ -import types from '../../common/app/redux/types'; - -const { hardGoTo } = types; -export default function hardGoToSaga(action$, getState, { history }) { - return action$ - .filter(({ type }) => type === hardGoTo) - .map(({ payload = '/settings' }) => { - history.pushState(history.state, null, payload); - return null; - }); -} diff --git a/client/sagas/index.js b/client/sagas/index.js deleted file mode 100644 index 004e8f75f0..0000000000 --- a/client/sagas/index.js +++ /dev/null @@ -1,21 +0,0 @@ -import analyticsSaga from './analytics-saga.js'; -import codeStorageSaga from './code-storage-saga.js'; -import errSaga from './err-saga.js'; -import executeChallengeSaga from './build-challenge-epic.js'; -import frameEpic from './frame-epic.js'; -import hardGoToSaga from './hard-go-to-saga.js'; -import mouseTrapSaga from './mouse-trap-saga.js'; -import nightModeSaga from './night-mode-saga.js'; -import titleSaga from './title-saga.js'; - -export default [ - analyticsSaga, - codeStorageSaga, - errSaga, - executeChallengeSaga, - frameEpic, - hardGoToSaga, - mouseTrapSaga, - nightModeSaga, - titleSaga -]; diff --git a/client/sagas/title-saga.js b/client/sagas/title-saga.js deleted file mode 100644 index 9ee326f7f7..0000000000 --- a/client/sagas/title-saga.js +++ /dev/null @@ -1,10 +0,0 @@ -export default function titleSage(action$, getState, { document }) { - return action$ - .filter(action => action.type === 'app.updateTitle') - .map(() => { - const state = getState(); - const newTitle = state.app.title; - document.title = newTitle; - return null; - }); -} diff --git a/client/utils/flash-to-toast.js b/client/utils/flash-to-toast.js index b7529f7056..895c9781c7 100644 --- a/client/utils/flash-to-toast.js +++ b/client/utils/flash-to-toast.js @@ -1,4 +1,4 @@ -import { makeToast } from '../../common/app/toasts/redux/actions'; +import { makeToast } from '../../common/app/Toasts/redux'; export default function flashToToast(flash) { return Object.keys(flash) diff --git a/common/app/App.jsx b/common/app/App.jsx index bb488ad925..893d038f81 100644 --- a/common/app/App.jsx +++ b/common/app/App.jsx @@ -1,73 +1,40 @@ import React, { PropTypes } from 'react'; -import { Button } from 'react-bootstrap'; import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; import ns from './ns.json'; import { + appMounted, fetchUser, updateAppLang, - trackEvent, - loadCurrentChallenge, - openDropdown, - closeDropdown -} from './redux/actions'; -import { submitChallenge } from './routes/challenges/redux/actions'; + userSelector +} from './redux'; -import Nav from './components/Nav'; -import Toasts from './toasts/Toasts.jsx'; -import { userSelector } from './redux/selectors'; +import Nav from './Nav'; +import Toasts from './Toasts'; const mapDispatchToProps = { - closeDropdown, + appMounted, fetchUser, - loadCurrentChallenge, - openDropdown, - submitChallenge, - trackEvent, updateAppLang }; -const mapStateToProps = createSelector( - userSelector, - state => state.app.isNavDropdownOpen, - state => state.app.isSignInAttempted, - state => state.app.toast, - state => state.challengesApp.toast, - ( - { user: { username, points, picture } }, - isNavDropdownOpen, - isSignInAttempted, - toast, - ) => ({ - username, - points, - picture, - toast, - isNavDropdownOpen, - showLoading: !isSignInAttempted, +const mapStateToProps = state => { + const { username } = userSelector(state); + return { + toast: state.app.toast, isSignedIn: !!username - }) -); + }; +}; const propTypes = { + appMounted: PropTypes.func.isRequired, children: PropTypes.node, - closeDropdown: PropTypes.func.isRequired, fetchUser: PropTypes.func, - isNavDropdownOpen: PropTypes.bool, isSignedIn: PropTypes.bool, - loadCurrentChallenge: PropTypes.func.isRequired, - openDropdown: PropTypes.func.isRequired, params: PropTypes.object, - picture: PropTypes.string, - points: PropTypes.number, - showLoading: PropTypes.bool, - submitChallenge: PropTypes.func, toast: PropTypes.object, - trackEvent: PropTypes.func.isRequired, - updateAppLang: PropTypes.func.isRequired, - username: PropTypes.string + updateAppLang: PropTypes.func.isRequired }; // export plain class for testing @@ -79,54 +46,21 @@ export class FreeCodeCamp extends React.Component { } componentDidMount() { + this.props.appMounted(); if (!this.props.isSignedIn) { this.props.fetchUser(); } } - renderChallengeComplete() { - const { submitChallenge } = this.props; - return ( - - ); - } - render() { - const { - username, - points, - picture, - trackEvent, - loadCurrentChallenge, - openDropdown, - closeDropdown, - isNavDropdownOpen - } = this.props; - const navProps = { - closeDropdown, - isNavDropdownOpen, - loadCurrentChallenge, - openDropdown, - picture, - points, - trackEvent, - username - }; - + // we render nav after the content + // to allow the panes to update + // redux store, which will update the bin + // buttons in the nav return (
-
); diff --git a/common/app/Child-Container.jsx b/common/app/Child-Container.jsx new file mode 100644 index 0000000000..5cbb95c85e --- /dev/null +++ b/common/app/Child-Container.jsx @@ -0,0 +1,24 @@ +import React, { PropTypes } from 'react'; +import classnames from 'classnames'; + +import ns from './ns.json'; + +const propTypes = { + children: PropTypes.node, + isFullWidth: PropTypes.bool +}; + +export default function ChildContainer({ children, isFullWidth }) { + const contentClassname = classnames({ + [`${ns}-content`]: true, + [`${ns}-centered`]: !isFullWidth + }); + return ( +
+ { children } +
+ ); +} + +ChildContainer.displayName = 'ChildContainer'; +ChildContainer.propTypes = propTypes; diff --git a/common/app/routes/challenges/views/map/Block.jsx b/common/app/Map/Block.jsx similarity index 68% rename from common/app/routes/challenges/views/map/Block.jsx rename to common/app/Map/Block.jsx index e07e185013..16e11ea83d 100644 --- a/common/app/routes/challenges/views/map/Block.jsx +++ b/common/app/Map/Block.jsx @@ -5,30 +5,35 @@ import FA from 'react-fontawesome'; import PureComponent from 'react-pure-render/component'; import { Panel } from 'react-bootstrap'; +import ns from './ns.json'; import Challenge from './Challenge.jsx'; -import { toggleThisPanel } from '../../redux/actions'; import { + toggleThisPanel, + makePanelOpenSelector, makePanelHiddenSelector -} from '../../redux/selectors'; +} from './redux'; + +import { makeBlockSelector } from '../entities'; const dispatchActions = { toggleThisPanel }; -const makeMapStateToProps = () => createSelector( - (_, props) => props.dashedName, - (state, props) => state.entities.block[props.dashedName], - makePanelOpenSelector(), - makePanelHiddenSelector(), - (dashedName, block, isOpen, isHidden) => { - return { - isOpen, - isHidden, - dashedName, - title: block.title, - time: block.time, - challenges: block.challenges - }; - } -); +function makeMapStateToProps(_, { dashedName }) { + return createSelector( + makeBlockSelector(dashedName), + makePanelOpenSelector(dashedName), + makePanelHiddenSelector(dashedName), + (block, isOpen, isHidden) => { + return { + isOpen, + isHidden, + dashedName, + title: block.title, + time: block.time, + challenges: block.challenges + }; + } + ); +} const propTypes = { challenges: PropTypes.array, dashedName: PropTypes.string, @@ -51,17 +56,16 @@ export class Block extends PureComponent { renderHeader(isOpen, title, time, isCompleted) { return ( -
-

- - - { title } - - ({ time }) -

+
+ + + { title } + + ({ time })
); } @@ -92,7 +96,7 @@ export class Block extends PureComponent { } return ( createSelector( - userSelector, - (_, props) => props.dashedName, - state => state.entities.challenge, - makePanelHiddenSelector(), - ( - { user: { challengeMap: userChallengeMap } }, - dashedName, - challengeMap, - isHidden - ) => { - const challenge = challengeMap[dashedName] || {}; - let isCompleted = false; - if (userChallengeMap) { - isCompleted = !!userChallengeMap[challenge.id]; +function mapDispatchToProps(dispatch, { dashedName }) { + const dispatchers = { + clickOnChallenge: e => { + e.preventDefault(); + return dispatch(clickOnChallenge(dashedName)); } - return { - dashedName, - challenge, - isHidden, - isCompleted, - title: challenge.title, - block: challenge.block, - isLocked: challenge.isLocked, - isRequired: challenge.isRequired, - isComingSoon: challenge.isComingSoon, - isDev: debug.enabled('fcc:*') - }; - } -); + }; + return () => dispatchers; +} + +function makeMapStateToProps(_, { dashedName }) { + return createSelector( + userSelector, + challengeMapSelector, + makePanelHiddenSelector(dashedName), + ( + { challengeMap: userChallengeMap }, + challengeMap, + isHidden + ) => { + const { + id, + title, + block, + isLocked, + isRequired, + isComingSoon + } = challengeMap[dashedName] || {}; + const isCompleted = userChallengeMap ? !!userChallengeMap[id] : false; + return { + dashedName, + isHidden, + isCompleted, + title, + block, + isLocked, + isRequired, + isComingSoon, + isDev: debug.enabled('fcc:*') + }; + } + ); +} export class Challenge extends PureComponent { - constructor(...args) { - super(...args); - this.handleChallengeClick = this.handleChallengeClick.bind(this); - } - - handleChallengeClick() { - this.props.updateCurrentChallenge(this.props.challenge); - } - renderCompleted(isCompleted, isLocked) { if (isLocked || !isCompleted) { return null; @@ -109,15 +116,16 @@ export class Challenge extends PureComponent { render() { const { - title, - dashedName, block, + clickOnChallenge, + dashedName, + isComingSoon, + isCompleted, + isDev, + isHidden, isLocked, isRequired, - isCompleted, - isComingSoon, - isDev, - isHidden + title } = this.props; if (isHidden) { return null; @@ -125,8 +133,7 @@ export class Challenge extends PureComponent { const challengeClassName = classnames({ 'text-primary': true, 'padded-ionic-icon': true, - 'negative-15': true, - 'challenge-title': true, + 'map-challenge-title': true, 'ion-checkmark-circled faded': !(isLocked || isComingSoon) && isCompleted, 'ion-ios-circle-outline': !(isLocked || isComingSoon) && !isCompleted, 'ion-locked': isLocked || isComingSoon, @@ -141,18 +148,18 @@ export class Challenge extends PureComponent { ); } return ( -

- + { title } { this.renderCompleted(isCompleted, isLocked) } { this.renderRequired(isRequired) } -

+
); } } diff --git a/common/app/Map/Map.jsx b/common/app/Map/Map.jsx new file mode 100644 index 0000000000..28c32c07ef --- /dev/null +++ b/common/app/Map/Map.jsx @@ -0,0 +1,54 @@ +import React, { PropTypes } from 'react'; +import { connect } from 'react-redux'; +import PureComponent from 'react-pure-render/component'; +import { Col, Row } from 'react-bootstrap'; + +import ns from './ns.json'; +import SuperBlock from './Super-Block.jsx'; +import { superBlocksSelector } from '../redux'; + +const mapStateToProps = state => ({ + superBlocks: superBlocksSelector(state) +}); + +const mapDispatchToProps = {}; +const propTypes = { + params: PropTypes.object, + superBlocks: PropTypes.array +}; + +export class ShowMap extends PureComponent { + renderSuperBlocks(superBlocks) { + if (!Array.isArray(superBlocks) || !superBlocks.length) { + return
No Super Blocks
; + } + return superBlocks.map(dashedName => ( + + )); + } + + render() { + const { superBlocks } = this.props; + return ( + + +
+ { this.renderSuperBlocks(superBlocks) } +
+
+ + + ); + } +} + +ShowMap.displayName = 'Map'; +ShowMap.propTypes = propTypes; + +export default connect( + mapStateToProps, + mapDispatchToProps +)(ShowMap); diff --git a/common/app/routes/challenges/views/map/Super-Block.jsx b/common/app/Map/Super-Block.jsx similarity index 74% rename from common/app/routes/challenges/views/map/Super-Block.jsx rename to common/app/Map/Super-Block.jsx index 8f6e4e439e..c112f620da 100644 --- a/common/app/routes/challenges/views/map/Super-Block.jsx +++ b/common/app/Map/Super-Block.jsx @@ -5,36 +5,36 @@ import PureComponent from 'react-pure-render/component'; import FA from 'react-fontawesome'; import { Panel } from 'react-bootstrap'; +import ns from './ns.json'; import Block from './Block.jsx'; -import { toggleThisPanel } from '../../redux/actions'; import { + toggleThisPanel, + makePanelOpenSelector, makePanelHiddenSelector -} from '../../redux/selectors'; +} from './redux'; +import { makeSuperBlockSelector } from '../entities'; const dispatchActions = { toggleThisPanel }; // make selectors unique to each component // see // reactjs/reselect // sharing-selectors-with-props-across-multiple-components -const makeMapStateToProps = () => { - const panelOpenSelector = makePanelOpenSelector(); - const panelHiddenSelector = makePanelHiddenSelector(); +function makeMapStateToProps(_, { dashedName }) { return createSelector( - (_, props) => props.dashedName, - (state, props) => state.entities.superBlock[props.dashedName], - panelOpenSelector, - panelHiddenSelector, - (dashedName, superBlock, isOpen, isHidden) => ({ + makeSuperBlockSelector(dashedName), + makePanelOpenSelector(dashedName), + makePanelHiddenSelector(dashedName), + (superBlock, isOpen, isHidden) => ({ isOpen, isHidden, dashedName, - title: superBlock.title, - blocks: superBlock.blocks, + title: superBlock.title || dashedName, + blocks: superBlock.blocks || [], message: superBlock.message }) ); -}; +} const propTypes = { blocks: PropTypes.array, @@ -58,7 +58,7 @@ export class SuperBlock extends PureComponent { renderBlocks(blocks) { if (!Array.isArray(blocks) || !blocks.length) { - return
No Blocks Found
; + return null; } return blocks.map(dashedName => ( +
{ message }
); @@ -81,13 +81,14 @@ export class SuperBlock extends PureComponent { renderHeader(isOpen, title, isCompleted) { return ( -

+
{ title } -

+
); } @@ -105,7 +106,7 @@ export class SuperBlock extends PureComponent { } return ( { this.renderMessage(message) } -
+
{ this.renderBlocks(blocks) }
diff --git a/common/app/routes/challenges/views/map/index.js b/common/app/Map/index.js similarity index 100% rename from common/app/routes/challenges/views/map/index.js rename to common/app/Map/index.js diff --git a/common/app/Map/map.less b/common/app/Map/map.less new file mode 100644 index 0000000000..0dfb49d8a3 --- /dev/null +++ b/common/app/Map/map.less @@ -0,0 +1,118 @@ +// should be the same as the filename and ./ns.json +@ns: map; + +.@{ns}-accordion { + max-width: 700px; + overflow-y: auto; + width: 100%; + + a:focus { + text-decoration: none; + color:darkgreen; + } + + a:focus:hover { + text-decoration: underline; + color:#001800; + } + + @media (max-width: 720px) { + left: 0; + right: 0; + width: 100%; + top: 195px; + bottom: 0; + // position:absolute; + overflow-x: hidden; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + h2 { + margin:15px 0; + padding:0; + &:first-of-type { + margin-top:0; + } + > a { + padding: 10px 0; + padding-left: 50px; + padding-right: 20px; + font-size: 20px; + } + } + a { + margin: 10px 0; + padding: 0; + > h3 { + clear:both; + font-size:20px; + } + } + } + + .@{ns}-caret { + color: @gray-dark; + text-decoration: none; + // make sure all carats are fixed width + width: 10px; + } +} + + +.@{ns}-accordion-block .@{ns}-accordion-panel-heading { + padding-left: 26px; +} + +.@{ns}-challenge-title, .@{ns}-accordion-panel-heading { + background: @body-bg; + line-height: 26px; + padding: 2px 12px 2px 10px; + width:100%; +} + +.@{ns}-challenge-title { + &::before { + padding-right: 6px; + } + padding-left: 40px; + a { + cursor: pointer; + } +} + +.@{ns}-accordion-panel-heading a { + cursor: pointer; + display: block; +} + +.@{ns}-accordion-panel-collapse { + transition: height 0.001s; +} + +.@{ns}-block-time { + color: #BBBBBB; + @media (min-width: 721px) { + float: right; + } +} + +.@{ns}-block-description { + margin:0; + margin-top:-10px; + padding:0 15px 23px 30px; +} + + +.night { + .@{ns}-accordion .no-link-underline { + color: @brand-primary; + } + .@{ns}-accordion h2 > a { + background: #666; + } + .@{ns}-accordion a:focus, #noneFound { + color: #ABABAB; + } + .challenge-title { + color: @night-text-color; + } +} diff --git a/common/app/Map/ns.json b/common/app/Map/ns.json new file mode 100644 index 0000000000..6bc66fc09e --- /dev/null +++ b/common/app/Map/ns.json @@ -0,0 +1 @@ +"map" diff --git a/common/app/Map/redux/index.js b/common/app/Map/redux/index.js new file mode 100644 index 0000000000..6c6bc452d6 --- /dev/null +++ b/common/app/Map/redux/index.js @@ -0,0 +1,129 @@ +import { createTypes } from 'redux-create-types'; +import { createAction, handleActions } from 'redux-actions'; +import { createSelector } from 'reselect'; +import identity from 'lodash/identity'; +import capitalize from 'lodash/capitalize'; + +import selectChallengeEpic from './select-challenge-epic.js'; + +import * as utils from './utils.js'; +import ns from '../ns.json'; +import { + types as app, + createEventMetaCreator +} from '../../redux'; + +export const epics = [ + selectChallengeEpic +]; + +export const types = createTypes([ + 'initMap', + + 'toggleThisPanel', + + 'isAllCollapsed', + 'collapseAll', + 'expandAll', + + 'clickOnChallenge' +], ns); + +export const initMap = createAction(types.initMap); + +export const toggleThisPanel = createAction(types.toggleThisPanel); +export const collapseAll = createAction(types.collapseAll); + +export const expandAll = createAction(types.expandAll); +export const clickOnChallenge = createAction( + types.clickOnChallenge, + identity, + createEventMetaCreator({ + category: capitalize(ns), + action: 'click', + label: types.clickOnChallenge + }) +); + +const initialState = { + mapUi: { isAllCollapsed: false }, + superBlocks: [] +}; + +export const getNS = state => state[ns]; +export const allColapsedSelector = state => state[ns].isAllCollapsed; +export const mapSelector = state => getNS(state).mapUi; +export function makePanelOpenSelector(name) { + return createSelector( + mapSelector, + mapUi => { + const node = utils.getNode(mapUi, name); + return node ? node.isOpen : false; + } + ); +} + +export function makePanelHiddenSelector(name) { + return createSelector( + mapSelector, + mapUi => { + const node = utils.getNode(mapUi, name); + return node ? node.isHidden : false; + } + ); +} +// interface Map{ +// children: [...{ +// name: (superBlock: String), +// isOpen: Boolean, +// isHidden: Boolean, +// children: [...{ +// name: (blockName: String), +// isOpen: Boolean, +// isHidden: Boolean, +// children: [...{ +// name: (challengeName: String), +// isHidden: Boolean +// }] +// }] +// } +// } +export default function createReducer() { + const reducer = handleActions( + { + [types.toggleThisPanel]: (state, { payload: name }) => { + return { + ...state, + mapUi: utils.toggleThisPanel(state.mapUi, name) + }; + }, + [types.collapseAll]: state => { + const mapUi = utils.collapseAllPanels(state.mapUi); + mapUi.isAllCollapsed = true; + return { + ...state, + mapUi + }; + }, + [types.expandAll]: state => { + const mapUi = utils.expandAllPanels(state.mapUi); + mapUi.isAllCollapsed = false; + return { + ...state, + mapUi + }; + }, + [app.fetchChallenges.complete]: (state, { payload }) => { + const { entities, result } = payload; + return { + ...state, + mapUi: utils.createMapUi(entities, result) + }; + } + }, + initialState + ); + + reducer.toString = () => ns; + return reducer; +} diff --git a/common/app/Map/redux/select-challenge-epic.js b/common/app/Map/redux/select-challenge-epic.js new file mode 100644 index 0000000000..090bc019cf --- /dev/null +++ b/common/app/Map/redux/select-challenge-epic.js @@ -0,0 +1,10 @@ +import { ofType } from 'redux-epic'; + +import { types } from './'; +import { updateCurrentChallenge } from '../../redux'; + +export default function selectChallengeEpic(actions) { + return actions::ofType(types.clickOnChallenge) + .pluck('payload') + .map(updateCurrentChallenge); +} diff --git a/common/app/Map/redux/utils.js b/common/app/Map/redux/utils.js new file mode 100644 index 0000000000..4afdbdfc8e --- /dev/null +++ b/common/app/Map/redux/utils.js @@ -0,0 +1,159 @@ +import protect from '../../utils/empty-protector'; + +const throwIfUndefined = () => { + throw new Error('Challenge does not have a title'); +}; + +export function createSearchTitle( + name = throwIfUndefined(), + challengeMap = {} +) { + return challengeMap[name] || name; +} +// interface Node { +// isHidden: Boolean, +// children: Void|[ ...Node ], +// isOpen?: Boolean +// } +// +// interface MapUi +// { +// children: [...{ +// name: (superBlock: String), +// isOpen: Boolean, +// isHidden: Boolean, +// children: [...{ +// name: (blockName: String), +// isOpen: Boolean, +// isHidden: Boolean, +// children: [...{ +// name: (challengeName: String), +// isHidden: Boolean +// }] +// }] +// }] +// } +export function createMapUi( + { + block: blockMap, + challenge: challengeMap, + superBlock: superBlockMap + } = {}, + { superBlocks } = {} +) { + if (!superBlocks || !superBlockMap || !blockMap) { + return {}; + } + return { + children: superBlocks.map(superBlock => { + return { + name: superBlock, + isOpen: false, + isHidden: false, + children: protect(superBlockMap[superBlock]).blocks.map(block => { + return { + name: block, + isOpen: false, + isHidden: false, + children: protect(blockMap[block]).challenges.map(challenge => { + return { + name: challenge, + title: createSearchTitle(challenge, challengeMap), + isHidden: false, + children: null + }; + }) + }; + }) + }; + }) + }; +} + +// synchronise +// traverseMapUi( +// tree: MapUi|Node, +// update: ((MapUi|Node) => MapUi|Node) +// ) => MapUi|Node +export function traverseMapUi(tree, update) { + let childrenChanged; + if (!Array.isArray(tree.children)) { + return update(tree); + } + const newChildren = tree.children.map(node => { + const newNode = traverseMapUi(node, update); + if (!childrenChanged && newNode !== node) { + childrenChanged = true; + } + return newNode; + }); + if (childrenChanged) { + tree = { + ...tree, + children: newChildren + }; + } + return update(tree); +} + +// synchronise +// getNode(tree: MapUi, name: String) => MapUi +export function getNode(tree, name) { + let node; + traverseMapUi(tree, thisNode => { + if (thisNode.name === name) { + node = thisNode; + } + return thisNode; + }); + return node; +} + +// synchronise +// updateSingelNode( +// tree: MapUi, +// name: String, +// update(MapUi|Node) => MapUi|Node +// ) => MapUi +export function updateSingleNode(tree, name, update) { + return traverseMapUi(tree, node => { + if (name !== node.name) { + return node; + } + return update(node); + }); +} + +// synchronise +// toggleThisPanel(tree: MapUi, name: String) => MapUi +export function toggleThisPanel(tree, name) { + return updateSingleNode(tree, name, node => { + return { + ...node, + isOpen: !node.isOpen + }; + }); +} + +// toggleAllPanels(tree: MapUi, isOpen: Boolean = false ) => MapUi +export function toggleAllPanels(tree, isOpen = false) { + return traverseMapUi(tree, node => { + if (!Array.isArray(node.children) || node.isOpen === isOpen) { + return node; + } + return { + ...node, + isOpen + }; + }); +} + +// collapseAllPanels(tree: MapUi) => MapUi +export function collapseAllPanels(tree) { + return toggleAllPanels(tree); +} + +// expandAllPanels(tree: MapUi) => MapUi +export function expandAllPanels(tree) { + return toggleAllPanels(tree, true); +} diff --git a/common/app/Map/redux/utils.test.js b/common/app/Map/redux/utils.test.js new file mode 100644 index 0000000000..de761a9c3c --- /dev/null +++ b/common/app/Map/redux/utils.test.js @@ -0,0 +1,215 @@ +import test from 'tape'; +import sinon from 'sinon'; + +import { + getNode, + createMapUi, + traverseMapUi, + updateSingleNode, + toggleThisPanel, + expandAllPanels, + collapseAllPanels +} from './utils.js'; + +test('createMapUi', t => { + t.plan(3); + t.test('should return an `{}` when proper args not supplied', t => { + t.plan(3); + t.equal( + Object.keys(createMapUi()).length, + 0 + ); + t.equal( + Object.keys(createMapUi({}, [])).length, + 0 + ); + t.equal( + Object.keys(createMapUi({ superBlock: {} }, [])).length, + 0 + ); + }); + t.test('should return a map tree', t => { + const expected = { + children: [{ + name: 'superBlockA', + children: [{ + name: 'blockA', + children: [{ + name: 'challengeA' + }] + }] + }] + }; + const actual = createMapUi({ + superBlock: { + superBlockA: { + blocks: [ + 'blockA' + ] + } + }, + block: { + blockA: { + challenges: [ + 'challengeA' + ] + } + } + }, + { superBlocks: ['superBlockA'] }, + { challengeA: 'ChallengeA title'} + ); + t.plan(3); + t.equal(actual.children[0].name, expected.children[0].name); + t.equal( + actual.children[0].children[0].name, + expected.children[0].children[0].name + ); + t.equal( + actual.children[0].children[0].children[0].name, + expected.children[0].children[0].children[0].name + ); + }); + t.test('should protect against malformed data', t => { + t.plan(2); + t.equal( + createMapUi({ + superBlock: {}, + block: { + blockA: { + challenges: [ + 'challengeA' + ] + } + } + }, { superBlocks: ['superBlockA'] }).children[0].children.length, + 0 + ); + t.equal( + createMapUi({ + superBlock: { + superBlockA: { + blocks: [ + 'blockA' + ] + } + }, + block: {} + }, + { superBlocks: ['superBlockA'] }).children[0].children[0].children.length, + 0 + ); + }); +}); +test('traverseMapUi', t => { + t.test('should return tree', t => { + t.plan(2); + const expectedTree = {}; + const actaulTree = traverseMapUi(expectedTree, tree => { + t.equal(tree, expectedTree); + return tree; + }); + t.equal(actaulTree, expectedTree); + }); + t.test('should hit every node', t => { + t.plan(4); + const expected = { children: [{ children: [{}] }] }; + const spy = sinon.spy(t => t); + spy.withArgs(expected); + spy.withArgs(expected.children[0]); + spy.withArgs(expected.children[0].children[0]); + traverseMapUi(expected, spy); + t.equal(spy.callCount, 3); + t.ok(spy.withArgs(expected).calledOnce, 'foo'); + t.ok(spy.withArgs(expected.children[0]).calledOnce, 'bar'); + t.ok(spy.withArgs(expected.children[0].children[0]).calledOnce, 'baz'); + }); + t.test('should create new object when children change', t => { + t.plan(9); + const expected = { children: [{ bar: true }, {}] }; + const actual = traverseMapUi(expected, node => ({ ...node, foo: true })); + t.notEqual(actual, expected); + t.notEqual(actual.children, expected.children); + t.notEqual(actual.children[0], expected.children[0]); + t.notEqual(actual.children[1], expected.children[1]); + t.equal(actual.children[0].bar, expected.children[0].bar); + t.notOk(expected.children[0].foo); + t.notOk(expected.children[1].foo); + t.true(actual.children[0].foo); + t.true(actual.children[1].foo); + }); +}); +test('getNode', t => { + t.test('should return node', t => { + t.plan(1); + const expected = { name: 'foo' }; + const tree = { children: [{ name: 'notfoo' }, expected ] }; + const actual = getNode(tree, 'foo'); + t.equal(expected, actual); + }); + t.test('should returned undefined if not found', t => { + t.plan(1); + const tree = { + children: [ { name: 'foo' }, { children: [ { name: 'bar' } ] } ] + }; + const actual = getNode(tree, 'baz'); + t.notOk(actual); + }); +}); +test('updateSingleNode', t => { + t.test('should update single node', t => { + const expected = { name: 'foo' }; + const untouched = { name: 'notFoo' }; + const actual = updateSingleNode( + { children: [ untouched, expected ] }, + 'foo', + node => ({ ...node, tag: true }) + ); + t.plan(4); + t.ok(actual.children[1].tag); + t.equal(actual.children[1].name, expected.name); + t.notEqual(actual.children[1], expected); + t.equal(actual.children[0], untouched); + }); +}); +test('toggleThisPanel', t => { + t.test('should update single node', t => { + const expected = { name: 'foo', isOpen: true }; + const actual = toggleThisPanel( + { children: [ { name: 'foo', isOpen: false }] }, + 'foo' + ); + t.plan(1); + t.deepLooseEqual(actual.children[0], expected); + }); +}); +test('toggleAllPanels', t => { + t.test('should add `isOpen: true` to every node without children', t => { + const expected = { + isOpen: true, + children: [{ + isOpen: true, + children: [{}, {}] + }] + }; + const actual = expandAllPanels({ children: [{ children: [{}, {}] }] }); + t.plan(1); + t.deepLooseEqual(actual, expected); + }); + t.test('should add `isOpen: false` to every node without children', t => { + const leaf = {}; + const expected = { + isOpen: false, + children: [{ + isOpen: false, + children: [{}, leaf] + }] + }; + const actual = collapseAllPanels( + { isOpen: true, children: [{ children: [{}, leaf]}]}, + ); + t.plan(2); + t.deepLooseEqual(actual, expected); + t.equal(actual.children[0].children[1], leaf); + }); +}); diff --git a/common/app/Nav/Bin-Button.jsx b/common/app/Nav/Bin-Button.jsx new file mode 100644 index 0000000000..47d822f316 --- /dev/null +++ b/common/app/Nav/Bin-Button.jsx @@ -0,0 +1,19 @@ +import React, { PropTypes } from 'react'; +import { NavItem } from 'react-bootstrap'; + +const propTypes = { + content: PropTypes.string, + handleClick: PropTypes.func.isRequired +}; + +export default function BinButton({ content, handleClick }) { + return ( + + { content } + + ); +} +BinButton.displayName = 'BinButton'; +BinButton.propTypes = propTypes; diff --git a/common/app/Nav/Nav.jsx b/common/app/Nav/Nav.jsx new file mode 100644 index 0000000000..1198275416 --- /dev/null +++ b/common/app/Nav/Nav.jsx @@ -0,0 +1,249 @@ +import React, { PropTypes } from 'react'; +import { bindActionCreators } from 'redux'; +import { connect } from 'react-redux'; +import noop from 'lodash/noop'; +import capitalize from 'lodash/capitalize'; +import { createSelector } from 'reselect'; + +import { LinkContainer } from 'react-router-bootstrap'; +import { + MenuItem, + Nav, + NavDropdown, + NavItem, + Navbar, + NavbarBrand +} from 'react-bootstrap'; + +import navLinks from './links.json'; +import SignUp from './Sign-Up.jsx'; +import BinButton from './Bin-Button.jsx'; +import { + clickOnLogo, + openDropdown, + closeDropdown, + createNavLinkActionCreator, + + dropdownSelector +} from './redux'; +import { + userSelector, + signInLoadingSelector +} from '../redux'; +import { nameToTypeSelector, panesSelector } from '../Panes/redux'; + + +const fCClogo = 'https://s3.amazonaws.com/freecodecamp/freecodecamp_logo.svg'; + +const mapStateToProps = createSelector( + userSelector, + dropdownSelector, + signInLoadingSelector, + panesSelector, + nameToTypeSelector, + ( + { username, picture, points }, + isDropdownOpen, + showLoading, + panes, + nameToType + ) => { + return { + panes: panes.map(name => { + return { + content: name, + action: nameToType[name] + }; + }, {}), + isDropdownOpen, + isSignedIn: !!username, + picture, + points, + showLoading, + username + }; + } +); + +function mapDispatchToProps(dispatch) { + const dispatchers = bindActionCreators(navLinks.reduce( + (mdtp, { content }) => { + const handler = `handle${capitalize(content)}Click`; + mdtp[handler] = createNavLinkActionCreator(content); + return mdtp; + }, + { + clickOnLogo: e => { + e.preventDefault(); + return clickOnLogo(); + }, + closeDropdown: () => closeDropdown(), + openDropdown: () => openDropdown() + } + ), dispatch); + dispatchers.dispatch = dispatch; + return () => dispatchers; +} + +function mergeProps(stateProps, dispatchProps, ownProps) { + const panes = stateProps.panes.map(pane => { + return { + ...pane, + actionCreator: () => dispatchProps.dispatch({ type: pane.action }) + }; + }); + return { + ...ownProps, + ...stateProps, + ...dispatchProps, + panes + }; +} + +const propTypes = navLinks.reduce( + (pt, { content }) => { + const handler = `handle${capitalize(content)}Click`; + pt[handler] = PropTypes.func.isRequired; + return pt; + }, + { + panes: PropTypes.array, + clickOnLogo: PropTypes.func.isRequired, + closeDropdown: PropTypes.func.isRequired, + isDropdownOpen: PropTypes.bool, + openDropdown: PropTypes.func.isRequired, + picture: PropTypes.string, + points: PropTypes.number, + showLoading: PropTypes.bool, + signedIn: PropTypes.bool, + username: PropTypes.string + } +); + +export class FCCNav extends React.Component { + renderLink(isNavItem, { isReact, isDropdown, content, link, links, target }) { + const Component = isNavItem ? NavItem : MenuItem; + const { + isDropdownOpen, + openDropdown, + closeDropdown + } = this.props; + + if (isDropdown) { + // adding a noop to NavDropdown to disable false warning + // about controlled component + return ( + + { links.map(this.renderLink.bind(this, false)) } + + ); + } + if (isReact) { + return ( + + + { content } + + + ); + } + return ( + + { content } + + ); + } + + render() { + const { + panes, + clickOnLogo, + username, + points, + picture, + showLoading + } = this.props; + + return ( + + + + + + learn to code javascript at freeCodeCamp logo + + + + + + + + ); + } +} + +FCCNav.displayName = 'FCCNav'; +FCCNav.propTypes = propTypes; + +export default connect( + mapStateToProps, + mapDispatchToProps, + mergeProps +)(FCCNav); diff --git a/common/app/Nav/Sign-Up.jsx b/common/app/Nav/Sign-Up.jsx new file mode 100644 index 0000000000..ea4d6a2e76 --- /dev/null +++ b/common/app/Nav/Sign-Up.jsx @@ -0,0 +1,54 @@ +import React, { PropTypes } from 'react'; +import { Link } from 'react-router'; +import { NavItem } from 'react-bootstrap'; + +// this is separated out to prevent react bootstrap's +// NavBar from injecting unknown props to the li component + +const propTypes = { + picture: PropTypes.string, + points: PropTypes.number, + showLoading: PropTypes.bool, + username: PropTypes.string +}; + +export default function SignUpButton({ + picture, + points, + showLoading, + username +}) { + if (showLoading) { + return null; + } + if (!username) { + return ( + + Sign Up + + ); + } + return ( +
  • + + { username } + [ { points || 1 } ] + + + + +
  • + ); +} + +SignUpButton.displayName = 'SignUpButton'; +SignUpButton.propTypes = propTypes; diff --git a/common/app/components/Nav/index.js b/common/app/Nav/index.js similarity index 100% rename from common/app/components/Nav/index.js rename to common/app/Nav/index.js diff --git a/common/app/components/Nav/links.json b/common/app/Nav/links.json similarity index 92% rename from common/app/components/Nav/links.json rename to common/app/Nav/links.json index d6c43de3b0..57b82e7ac3 100644 --- a/common/app/components/Nav/links.json +++ b/common/app/Nav/links.json @@ -34,13 +34,8 @@ } ] }, - { - "content": "Map", - "link": "/map", - "isReact": true - }, { "content": "Donate", "link": "https://www.freecodecamp.com/donate" } -] \ No newline at end of file +] diff --git a/common/app/Nav/nav.less b/common/app/Nav/nav.less new file mode 100644 index 0000000000..7493f6d0e5 --- /dev/null +++ b/common/app/Nav/nav.less @@ -0,0 +1,196 @@ +.navbar { + background-color: @brand-primary; + font-size: 14px; +} + +.navbar-nav > li > a { + color: @body-bg; + &:hover { + color: @brand-primary; + } +} + +.navbar > .container { + padding-right: 0px; + width: auto; + + @media (max-width: 767px) { + // container default padding size + padding-left: 15px; + padding-right: 15px; + } +} + +.nav-height { + border: none; + height: @navbar-height; + width: 100%; +} + +@navbar-logo-height: 25px; +@navbar-logo-padding: (@navbar-height - @navbar-logo-height) / 2; +.navbar-brand { + padding-top: @navbar-logo-padding; + padding-bottom: @navbar-logo-padding; +} + +.nav-logo { + height: @navbar-logo-height; +} + +.navbar-right { + background-color: @brand-primary; + text-align: center; + + @media (min-width: @screen-md-min) { + margin-right:0; + } + @media (min-width: @screen-md-max) and (max-width: 991px) { + left: 0; + margin-right: 0; + position: absolute; + right: 0; + white-space: nowrap; + } +} + +.navbar { + white-space: nowrap; + border: none; + line-height: 1; + @media (min-width: 767px) { + padding-left: 15px; + padding-right: 30px; + } +} + +// li is used here to get more specific +// and win against navbar.less#273 +li.nav-avatar { + span { + display: inline-block; + } + @media (min-width: @screen-sm-min) { + height: @navbar-height; + margin: 0; + padding: 0; + + > a { + margin: 0; + padding: 0 @navbar-padding-horizontal 0 @navbar-padding-horizontal; + } + } +} + +.nav-username { + padding-right: @navbar-padding-horizontal; +} + +.nav-points { + @media (min-width: @screen-sm-min) and (max-width: @screen-sm-max) { + padding: @navbar-padding-vertical 0 @navbar-padding-vertical 0; + } + @media (min-width: @screen-md-min) { + padding: @navbar-padding-vertical @navbar-padding-horizontal @navbar-padding-vertical 0; + } +} + +.nav-picture { + margin-top: @navbar-logo-padding; + margin-bottom: @navbar-logo-padding; + height: @navbar-logo-height; + width: @navbar-logo-height; +} + +.navbar-nav a { + color: @body-bg; + margin-top: -5px; + margin-bottom: -5px; +} + +.navbar-toggle { + color: @body-bg; + + &:hover, + &:focus { + color: #4a2b0f; + } +} + +.navbar-collapse { + border-top: 0; +} + +@media (max-width: @screen-xs-max) { + .navbar-header { + float: none; + } + + .navbar-toggle { + display: block; + } + + .navbar-collapse.collapse { + display: none !important; + } + + .navbar-nav { + margin-top: 0; + } + + .navbar-nav > li { + float: none; + } + + .navbar-nav > li > a { + padding-top: 10px; + padding-bottom: 10px; + } + + .navbar-text { + float: none; + margin: 15px 0; + } + + /* since 3.1.0 */ + .navbar-collapse.collapse.in { + display: block !important; + } + + .collapsing { + overflow: hidden !important; + position: absolute; + left: 0; + right: 0; + } +} + +.night { + .navbar-default { + .navbar-nav { + & > li > a { + color: #CCC; + } + .dropdown-menu { + background-color: @gray; + a { + color: @night-text-color !important; + } + } + a:focus, + a:hover, + .open #nav-Community-dropdown { + background-color: #666 !important; + color: @link-hover-color !important; + } + } + } + .navbar-toggle { + &:hover, + &:focus { + background-color: #666; + color: @link-hover-color; + border-color: #666; + } + } +} diff --git a/common/app/Nav/ns.json b/common/app/Nav/ns.json new file mode 100644 index 0000000000..ea84de45ff --- /dev/null +++ b/common/app/Nav/ns.json @@ -0,0 +1 @@ +"nav" diff --git a/common/app/Nav/redux/bin-epic.js b/common/app/Nav/redux/bin-epic.js new file mode 100644 index 0000000000..8ac0cf620e --- /dev/null +++ b/common/app/Nav/redux/bin-epic.js @@ -0,0 +1,10 @@ +import { ofType } from 'redux-epic'; + +import { types } from './'; + +import { hidePane } from '../../Panes/redux'; + +export default function binEpic(actions) { + return actions::ofType(types.clickOnMap) + .map(() => hidePane('Map')); +} diff --git a/common/app/Nav/redux/index.js b/common/app/Nav/redux/index.js new file mode 100644 index 0000000000..f6fd626085 --- /dev/null +++ b/common/app/Nav/redux/index.js @@ -0,0 +1,79 @@ +import capitalize from 'lodash/capitalize'; +import { createTypes } from 'redux-create-types'; +import { createAction, handleActions } from 'redux-actions'; +import noop from 'lodash/noop'; + +import loadCurrentChallengeEpic from './load-current-challenge-epic.js'; +import binEpic from './bin-epic.js'; +import ns from '../ns.json'; +import { createEventMetaCreator } from '../../redux'; + +export const epics = [ + loadCurrentChallengeEpic, + binEpic +]; + +export const types = createTypes([ + 'clickOnLogo', + 'clickOnMap', + 'navLinkClicked', + + 'closeDropdown', + 'openDropdown' +], ns); + +export const clickOnLogo = createAction( + types.clickOnLogo, + noop, + createEventMetaCreator({ + category: 'Nav', + action: 'clicked', + label: 'fcc logo clicked' + }) +); + +export const clickOnMap = createAction( + types.clickOnMap, + noop, + createEventMetaCreator({ + category: 'Nav', + action: 'clicked', + label: 'map button clicked' + }) +); + +export const closeDropdown = createAction(types.closeDropdown); +export const openDropdown = createAction(types.openDropdown); +export function createNavLinkActionCreator(link) { + return createAction( + types.navLinkClicked, + noop, + createEventMetaCreator({ + category: capitalize(ns), + action: 'click', + label: `${link} link` + }) + ); +} + +const initialState = { + isDropdownOpen: false +}; + +export const dropdownSelector = state => state[ns].isDropdownOpen; + +export default function createReducer() { + const reducer = handleActions({ + [types.closeDropdown]: state => ({ + ...state, + isDropdownOpen: false + }), + [types.openDropdown]: state => ({ + ...state, + isDropdownOpen: true + }) + }, initialState); + + reducer.toString = () => ns; + return reducer; +} diff --git a/common/app/Nav/redux/load-current-challenge-epic.js b/common/app/Nav/redux/load-current-challenge-epic.js new file mode 100644 index 0000000000..9e609ecbfa --- /dev/null +++ b/common/app/Nav/redux/load-current-challenge-epic.js @@ -0,0 +1,61 @@ +import { ofType } from 'redux-epic'; + +import { types } from './'; +import { + updateCurrentChallenge, + + userSelector, + firstChallengeSelector, + challengeSelector +} from '../../redux'; +import { entitiesSelector } from '../../entities'; + +export default function loadCurrentChallengeEpic(actions, { getState }) { + return actions::ofType(types.clickOnLogo) + .debounce(500) + .map(() => { + let finalChallenge; + const state = getState(); + const { id: currentlyLoadedChallengeId } = challengeSelector(state); + const { + challenge: challengeMap, + challengeIdToName + } = entitiesSelector(state); + const { + routing: { + locationBeforeTransitions: { pathname } = {} + } + } = state; + const firstChallenge = firstChallengeSelector(state); + const { currentChallengeId } = userSelector(state); + const isOnAChallenge = (/^\/[^\/]{2,6}\/challenges/).test(pathname); + + if (!currentChallengeId) { + finalChallenge = firstChallenge; + } else { + finalChallenge = challengeMap[ + challengeIdToName[ currentChallengeId ] + ]; + } + return { + finalChallenge, + isOnAChallenge, + currentlyLoadedChallengeId + }; + }) + .filter(({ + finalChallenge, + isOnAChallenge, + currentlyLoadedChallengeId + }) => ( + // data might not be there yet, filter out for now + !!finalChallenge && + // are we already on that challenge? if not load challenge + (!isOnAChallenge || finalChallenge.id !== currentlyLoadedChallengeId) + // don't reload if the challenge is already loaded. + // This may change to toast to avoid user confusion + )) + .map(({ finalChallenge }) => { + return updateCurrentChallenge(finalChallenge.dashedName); + }); +} diff --git a/common/app/components/NotFound/index.jsx b/common/app/NotFound/Not-Found.jsx similarity index 90% rename from common/app/components/NotFound/index.jsx rename to common/app/NotFound/Not-Found.jsx index 9acc2b34f9..f8223a9357 100644 --- a/common/app/components/NotFound/index.jsx +++ b/common/app/NotFound/Not-Found.jsx @@ -1,6 +1,6 @@ import React, { PropTypes } from 'react'; import { connect } from 'react-redux'; -import { hardGoTo } from '../../redux/actions'; +import { hardGoTo } from '../redux'; const propTypes = { hardGoTo: PropTypes.func, @@ -8,8 +8,6 @@ const propTypes = { }; export class NotFound extends React.Component { - - componentWillMount() { this.props.hardGoTo(this.props.location.pathname); } diff --git a/common/app/NotFound/index.js b/common/app/NotFound/index.js new file mode 100644 index 0000000000..b174c4f934 --- /dev/null +++ b/common/app/NotFound/index.js @@ -0,0 +1 @@ +export default from './Not-Found.jsx'; diff --git a/common/app/Panes/Divider.jsx b/common/app/Panes/Divider.jsx new file mode 100644 index 0000000000..3eb97df6b0 --- /dev/null +++ b/common/app/Panes/Divider.jsx @@ -0,0 +1,50 @@ +import React, { PropTypes } from 'react'; +import { connect } from 'react-redux'; + +import { dividerClicked } from './redux'; + +const mapStateToProps = null; +function mapDispatchToProps(dispatch, { name }) { + const dispatchers = { + dividerClicked: () => dispatch(dividerClicked(name)) + }; + return () => dispatchers; +} + +const propTypes = { + dividerClicked: PropTypes.func.isRequired, + left: PropTypes.number.isRequired, + name: PropTypes.string.isRequired +}; + +export function Divider({ left, dividerClicked }) { + const style = { + borderLeft: '1px solid rgb(204, 204, 204)', + bottom: 0, + cursor: 'col-resize', + height: '100%', + left: left + '%', + marginLeft: '-4px', + position: 'absolute', + right: 'auto', + top: 0, + width: '8px', + zIndex: 100 + }; + // use onMouseDown as onClick does not fire + // until onMouseUp + // note(berks): do we need touch support? + return ( +
    + ); +} +Divider.displayName = 'Divider'; +Divider.propTypes = propTypes; + +export default connect( + mapStateToProps, + mapDispatchToProps +)(Divider); diff --git a/common/app/Panes/Pane.jsx b/common/app/Panes/Pane.jsx new file mode 100644 index 0000000000..dee72c5da6 --- /dev/null +++ b/common/app/Panes/Pane.jsx @@ -0,0 +1,38 @@ +import React, { PropTypes } from 'react'; +import { connect } from 'react-redux'; + +const mapStateToProps = null; +const mapDispatchToProps = null; +const propTypes = { + children: PropTypes.element, + left: PropTypes.number.isRequired, + right: PropTypes.number.isRequired +}; + +export function Pane({ + children, + left, + right +}) { + const style = { + bottom: 0, + left: left + '%', + overflowX: 'hidden', + overflowY: 'auto', + position: 'absolute', + right: right + '%', + top: 0 + }; + return ( +
    + { children } +
    + ); +} +Pane.displayName = 'Pane'; +Pane.propTypes = propTypes; + +export default connect( + mapStateToProps, + mapDispatchToProps +)(Pane); diff --git a/common/app/Panes/Panes-Container.jsx b/common/app/Panes/Panes-Container.jsx new file mode 100644 index 0000000000..cec7878bcd --- /dev/null +++ b/common/app/Panes/Panes-Container.jsx @@ -0,0 +1,58 @@ +import React, { PureComponent, PropTypes } from 'react'; +import { connect } from 'react-redux'; + +import Panes from './Panes.jsx'; +import { + panesMounted, + panesUpdated, + panesWillMount, + panesWillUnmount +} from './redux'; + +const mapStateToProps = null; +const mapDispatchToProps = { + panesMounted, + panesUpdated, + panesWillMount, + panesWillUnmount +}; + +const propTypes = { + nameToComponent: PropTypes.object.isRequired, + panesMounted: PropTypes.func.isRequired, + panesUpdated: PropTypes.func.isRequired, + panesWillMount: PropTypes.func.isRequired, + panesWillUnmount: PropTypes.func.isRequired +}; + +export class PanesContainer extends PureComponent { + componentWillMount() { + this.props.panesWillMount(Object.keys(this.props.nameToComponent)); + } + componentDidMount() { + this.props.panesMounted(); + } + + componentWillUnmount() { + this.props.panesWillUnmount(); + } + + componentWillReceiveProps(nextProps) { + if (nextProps.nameToComponent !== this.props.nameToComponent) { + this.props.panesUpdated(Object.keys(nextProps.nameToComponent)); + } + } + + render() { + return ( + + ); + } +} +PanesContainer.displayName = 'PanesContainer'; +PanesContainer.propTypes = propTypes; + +export default connect( + mapStateToProps, + mapDispatchToProps +)(PanesContainer); diff --git a/common/app/Panes/Panes.jsx b/common/app/Panes/Panes.jsx new file mode 100644 index 0000000000..2bc7d9a2f4 --- /dev/null +++ b/common/app/Panes/Panes.jsx @@ -0,0 +1,111 @@ +import React, { PureComponent, PropTypes } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; + +import { + panesSelector, + panesByNameSelector, + heightSelector, + widthSelector +} from './redux'; +import Pane from './Pane.jsx'; +import Divider from './Divider.jsx'; + +const mapStateToProps = createSelector( + panesSelector, + panesByNameSelector, + heightSelector, + widthSelector, + (panes, panesByName, height) => { + let lastDividerPosition = 0; + return { + panes: panes + .map(name => panesByName[name]) + .filter(({ isHidden })=> !isHidden) + .map((pane, index, { length: numOfPanes }) => { + const dividerLeft = pane.dividerLeft || 0; + const left = lastDividerPosition; + lastDividerPosition = dividerLeft; + return { + ...pane, + left: index === 0 ? 0 : left, + right: index + 1 === numOfPanes ? 0 : 100 - dividerLeft + }; + }, {}), + height + }; + } +); + +const mapDispatchToProps = null; + +const propTypes = { + height: PropTypes.number.isRequired, + nameToComponent: PropTypes.object.isRequired, + panes: PropTypes.array +}; + +export class Panes extends PureComponent { + renderPanes() { + const { + nameToComponent, + panes + } = this.props; + return panes.map(({ name, left, right, dividerLeft }) => { + const { Component } = nameToComponent[name] || {}; + const FinalComponent = Component ? Component : 'span'; + const divider = dividerLeft ? + ( + + ) : + null; + + return [ + + + , + divider + ]; + }).reduce((panes, pane) => panes.concat(pane), []) + .filter(Boolean); + } + + render() { + const { height } = this.props; + const outerStyle = { + height, + position: 'relative', + width: '100%' + }; + const innerStyle = { + position: 'absolute', + top: 0, + bottom: 0, + left: 0, + right: 0 + }; + return ( +
    +
    + { this.renderPanes() } +
    +
    + ); + } +} + +Panes.displayName = 'Panes'; +Panes.propTypes = propTypes; + +export default connect( + mapStateToProps, + mapDispatchToProps +)(Panes); diff --git a/common/app/Panes/index.js b/common/app/Panes/index.js new file mode 100644 index 0000000000..a6ad764581 --- /dev/null +++ b/common/app/Panes/index.js @@ -0,0 +1 @@ +export default from './Panes-Container.jsx'; diff --git a/common/app/Panes/ns.json b/common/app/Panes/ns.json new file mode 100644 index 0000000000..23b82c1253 --- /dev/null +++ b/common/app/Panes/ns.json @@ -0,0 +1 @@ +"panes" diff --git a/common/app/Panes/redux/divider-epic.js b/common/app/Panes/redux/divider-epic.js new file mode 100644 index 0000000000..130c472588 --- /dev/null +++ b/common/app/Panes/redux/divider-epic.js @@ -0,0 +1,40 @@ +import 'rx-dom'; +import { Observable, Scheduler } from 'rx'; +import { combineEpics, ofType } from 'redux-epic'; + +import { + types, + + mouseReleased, + dividerMoved, + + pressedDividerSelector +} from './'; + +export function dividerReleasedEpic(actions, _, { document }) { + return actions::ofType(types.dividerClicked) + .switchMap(() => Observable.fromEvent(document, 'mouseup') + .map(() => mouseReleased()) + // allow mouse up on divider to go first + .delay(1) + .takeUntil(actions::ofType(types.mouseReleased)) + ); +} + +export function dividerMovedEpic(actions, { getState }, { document }) { + return actions::ofType(types.dividerClicked) + .switchMap(() => Observable.fromEvent(document, 'mousemove') + // prevent mouse drags from highlighting text + .do(e => e.preventDefault()) + .map(({ clientX }) => clientX) + .throttle(1, Scheduler.requestAnimationFrame) + .filter(() => { + const divider = pressedDividerSelector(getState()); + return !!divider || divider === 0; + }) + .map(dividerMoved) + .takeUntil(actions::ofType(types.mouseReleased)) + ); +} + +export default combineEpics(dividerReleasedEpic, dividerMovedEpic); diff --git a/common/app/Panes/redux/index.js b/common/app/Panes/redux/index.js new file mode 100644 index 0000000000..ad4e608b1b --- /dev/null +++ b/common/app/Panes/redux/index.js @@ -0,0 +1,236 @@ +import { combineActions, createAction, handleActions } from 'redux-actions'; +import { createTypes } from 'redux-create-types'; +import clamp from 'lodash/clamp'; + +import ns from '../ns.json'; + +import windowEpic from './window-epic.js'; +import dividerEpic from './divider-epic.js'; + +const isDev = process.env.NODE_ENV !== 'production'; +export const epics = [ + windowEpic, + dividerEpic +]; + +export const types = createTypes([ + 'panesMounted', + 'panesUpdated', + 'panesWillMount', + 'panesWillUnmount', + 'updateSize', + + 'dividerClicked', + 'dividerMoved', + 'mouseReleased', + 'windowResized', + + // commands + 'updateNavHeight', + 'hidePane' +], ns); + +export const panesMounted = createAction(types.panesMounted); +export const panesUpdated = createAction(types.panesUpdated); +export const panesWillMount = createAction(types.panesWillMount); +export const panesWillUnmount = createAction(types.panesWillUnmount); + +export const dividerClicked = createAction(types.dividerClicked); +export const dividerMoved = createAction(types.dividerMoved); +export const mouseReleased = createAction(types.mouseReleased); +export const windowResized = createAction(types.windowResized); + +// commands +export const updateNavHeight = createAction(types.updateNavHeight); +export const hidePane = createAction(types.hidePane); + +const initialState = { + height: 600, + width: 800, + navHeight: 50, + panes: [], + panesByName: {}, + pressedDivider: null, + nameToType: {} +}; +export const getNS = state => state[ns]; +export const heightSelector = state => { + const { navHeight, height } = getNS(state); + return height - navHeight; +}; + +export const panesSelector = state => getNS(state).panes; +export const panesByNameSelector = state => getNS(state).panesByName; +export const pressedDividerSelector = + state => getNS(state).pressedDivider; +export const widthSelector = state => getNS(state).width; +export const nameToTypeSelector = state => getNS(state).nameToType; + +function isPanesAction({ type } = {}, typeToName) { + return !!typeToName[type]; +} + +function getDividerLeft(numOfPanes, index) { + let dividerLeft = null; + if (numOfPanes > 1 && numOfPanes !== index + 1) { + dividerLeft = (100 / numOfPanes) * (index + 1); + } + return dividerLeft; +} + +export default function createPanesAspects(typeToName) { + if (isDev) { + Object.keys(typeToName).forEach(actionType => { + if (actionType === 'undefined') { + throw new Error( + `action type for ${typeToName[actionType]} is undefined` + ); + } + }); + } + const nameToType = Object.keys(typeToName).reduce((map, type) => { + map[typeToName[type]] = type; + return map; + }, {}); + function getInitialState() { + return { + ...initialState, + nameToType + }; + } + + function middleware() { + return next => action => { + let finalAction = action; + if (isPanesAction(action, typeToName)) { + finalAction = { + ...action, + meta: { + ...action.meta, + isPaneAction: true + } + }; + } + return next(finalAction); + }; + } + + const reducer = handleActions({ + [types.dividerClicked]: (state, { payload: name }) => ({ + ...state, + pressedDivider: name + }), + [types.dividerMoved]: (state, { payload: clientX }) => { + const { width, pressedDivider: paneName } = state; + const dividerBuffer = (200 / width) * 100; + const paneIndex = state.panes.indexOf(paneName); + const currentPane = state.panesByName[paneName]; + const rightPane = state.panesByName[state.panes[paneIndex + 1]] || {}; + const leftPane = state.panesByName[state.panes[paneIndex - 1]] || {}; + const rightBound = (rightPane.dividerLeft || 100) - dividerBuffer; + const leftBound = (leftPane.dividerLeft || 0) + dividerBuffer; + const newPosition = clamp( + (clientX / width) * 100, + leftBound, + rightBound + ); + return { + ...state, + panesByName: { + ...state.panesByName, + [currentPane.name]: { + ...currentPane, + dividerLeft: newPosition + } + } + }; + }, + [types.mouseReleased]: state => ({ ...state, pressedDivider: null }), + [types.windowResized]: (state, { payload: { height, width } }) => ({ + ...state, + height, + width + }), + // used to clear bin buttons + [types.panesWillUnmount]: state => ({ + ...state, + panes: [], + panesByName: {}, + pressedDivider: null + }), + [ + combineActions( + panesWillMount, + panesUpdated + ) + ]: (state, { payload: panes }) => { + const numOfPanes = panes.length; + return { + ...state, + panes, + panesByName: panes.reduce((panes, name, index) => { + const dividerLeft = getDividerLeft(numOfPanes, index); + panes[name] = { + name, + dividerLeft, + isHidden: false + }; + return panes; + }, {}) + }; + }, + [types.updateNavHeight]: (state, { payload: navHeight }) => ({ + ...state, + navHeight + }) + }, getInitialState()); + function metaReducer(state = getInitialState(), action) { + if (action.meta && action.meta.isPaneAction) { + const name = typeToName[action.type]; + const oldPane = state.panesByName[name]; + const pane = { + ...oldPane, + isHidden: !oldPane.isHidden + }; + const panesByName = { + ...state.panesByName, + [name]: pane + }; + const numOfPanes = state.panes.reduce((sum, name) => { + return panesByName[name].isHidden ? sum : sum + 1; + }, 0); + let numOfHidden = 0; + return { + ...state, + panesByName: state.panes.reduce( + (panesByName, name, index) => { + if (!panesByName[name].isHidden) { + const dividerLeft = getDividerLeft( + numOfPanes, + index - numOfHidden + ); + panesByName[name] = { + ...panesByName[name], + dividerLeft + }; + } else { + numOfHidden = numOfHidden + 1; + } + return panesByName; + }, + panesByName + ) + }; + } + return state; + } + + function finalReducer(state, action) { + return reducer(metaReducer(state, action), action); + } + finalReducer.toString = () => ns; + return { + reducer: finalReducer, + middleware + }; +} diff --git a/client/less/challenge.less b/common/app/Panes/redux/utils.js similarity index 100% rename from client/less/challenge.less rename to common/app/Panes/redux/utils.js diff --git a/common/app/Panes/redux/window-epic.js b/common/app/Panes/redux/window-epic.js new file mode 100644 index 0000000000..68a92512db --- /dev/null +++ b/common/app/Panes/redux/window-epic.js @@ -0,0 +1,21 @@ +import { Observable } from 'rx'; +import { ofType } from 'redux-epic'; + +import { + types, + windowResized +} from './'; + +export default function windowEpic(actions, _, { window }) { + return actions::ofType(types.panesMounted) + .switchMap(() => { + return Observable.fromEvent(window, 'resize', () => windowResized({ + width: window.innerWidth, + height: window.innerHeight + })) + .startWith(windowResized({ + width: window.innerWidth, + height: window.innerHeight + })); + }); +} diff --git a/common/app/toasts/Toasts.jsx b/common/app/Toasts/Toasts.jsx similarity index 96% rename from common/app/toasts/Toasts.jsx rename to common/app/Toasts/Toasts.jsx index 035bc4b466..8e55ae9ac0 100644 --- a/common/app/toasts/Toasts.jsx +++ b/common/app/Toasts/Toasts.jsx @@ -3,11 +3,11 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import { NotificationStack } from 'react-notification'; -import { removeToast } from './redux/actions'; +import { removeToast } from './redux'; import { submitChallenge, resetChallenge -} from '../routes/challenges/redux/actions'; +} from '../routes/challenges/redux'; const registeredActions = { submitChallenge, diff --git a/common/app/Toasts/index.js b/common/app/Toasts/index.js new file mode 100644 index 0000000000..2518a55c0f --- /dev/null +++ b/common/app/Toasts/index.js @@ -0,0 +1 @@ +export default from './Toasts.jsx'; diff --git a/common/app/Toasts/ns.json b/common/app/Toasts/ns.json new file mode 100644 index 0000000000..2471c4eb56 --- /dev/null +++ b/common/app/Toasts/ns.json @@ -0,0 +1 @@ +"toasts" diff --git a/common/app/Toasts/redux/index.js b/common/app/Toasts/redux/index.js new file mode 100644 index 0000000000..af05b57b1f --- /dev/null +++ b/common/app/Toasts/redux/index.js @@ -0,0 +1,45 @@ +import { createTypes } from 'redux-create-types'; +import { createAction, handleActions } from 'redux-actions'; + +import ns from '../ns.json'; + +export const types = createTypes([ + 'makeToast', + 'removeToast' +], 'toast'); + +let key = 0; +export const makeToast = createAction( + types.makeToast, + ({ timeout, ...rest }) => ({ + ...rest, + // assign current value of key to new toast + // and then increment key value + key: key++, + dismissAfter: timeout || 6000, + position: rest.position === 'left' ? 'left' : 'right' + }) +); + +export const removeToast = createAction( + types.removeToast, + ({ key }) => key +); + + +const initialState = []; + +export default function createReducer() { + const reducer = handleActions({ + [types.makeToast]: (state, { payload: toast }) => [ + ...state, + toast + ], + [types.removeToast]: (state, { payload: key }) => state.filter( + toast => toast.key !== key + ) + }, initialState); + + reducer.toString = () => ns; + return reducer; +} diff --git a/common/app/app.less b/common/app/app.less index 3705a8db5f..5a90c57e1f 100644 --- a/common/app/app.less +++ b/common/app/app.less @@ -2,13 +2,22 @@ @ns: app; .@{ns}-container { - .column(); + // we invert the nav and content since + // the content needs to render first + // Here we invert the order in which + // they are painted using css so the + // nav is on top again + .grid(@direction: column-reverse); width: 100vw; } .@{ns}-content { - .center(@value: @container-xl, @padding: @grid-gutter-width); // makes the inital content height 0px // then lets it grow to fit the rest of the space flex: 1 0 0px; } + +.@{ns}-centered { + .center(@value: @container-xl, @padding: @grid-gutter-width); + margin-top: @navbar-margin-bottom; +} diff --git a/common/app/components/Nav/Avatar-Points-Nav-Item.jsx b/common/app/components/Nav/Avatar-Points-Nav-Item.jsx deleted file mode 100644 index d8907f2b1b..0000000000 --- a/common/app/components/Nav/Avatar-Points-Nav-Item.jsx +++ /dev/null @@ -1,36 +0,0 @@ -import React, { PropTypes } from 'react'; -import { Link } from 'react-router'; - -// this is separated out to prevent react bootstrap's -// NavBar from injecting unknown props to the li component - -const propTypes = { - picture: PropTypes.string, - points: PropTypes.number, - username: PropTypes.string -}; - -export default function AvatarPointsNavItem({ picture, points, username }) { - return ( -
  • - - - { username } - [ { points || 1 } ] - - - - - -
  • - ); -} - -AvatarPointsNavItem.displayName = 'AvatarPointsNavItem'; -AvatarPointsNavItem.propTypes = propTypes; diff --git a/common/app/components/Nav/Nav.jsx b/common/app/components/Nav/Nav.jsx deleted file mode 100644 index 033c4af227..0000000000 --- a/common/app/components/Nav/Nav.jsx +++ /dev/null @@ -1,205 +0,0 @@ -import React, { PropTypes } from 'react'; -import { LinkContainer } from 'react-router-bootstrap'; -import { - Col, - MenuItem, - Nav, - NavDropdown, - NavItem, - Navbar, - NavbarBrand -} from 'react-bootstrap'; -import noop from 'lodash/noop'; - -import navLinks from './links.json'; -import AvatarPointsNavItem from './Avatar-Points-Nav-Item.jsx'; - -const fCClogo = 'https://s3.amazonaws.com/freecodecamp/freecodecamp_logo.svg'; - -const toggleButtonChild = ( - - Menu - -); - -function handleNavLinkEvent(content) { - this.props.trackEvent({ - category: 'Nav', - action: 'clicked', - label: `${content} link` - }); -} - -const propTypes = { - closeDropdown: PropTypes.func.isRequired, - isNavDropdownOpen: PropTypes.bool, - loadCurrentChallenge: PropTypes.func.isRequired, - openDropdown: PropTypes.func.isRequired, - picture: PropTypes.string, - points: PropTypes.number, - showLoading: PropTypes.bool, - signedIn: PropTypes.bool, - trackEvent: PropTypes.func.isRequired, - username: PropTypes.string -}; - -export default class FCCNav extends React.Component { - constructor(...props) { - super(...props); - this.handleMapClickOnMap = this.handleMapClickOnMap.bind(this); - this.handleLogoClick = this.handleLogoClick.bind(this); - navLinks.forEach(({ content }) => { - this[`handle${content}Click`] = handleNavLinkEvent.bind(this, content); - }); - } - - handleMapClickOnMap(e) { - e.preventDefault(); - this.props.trackEvent({ - category: 'Nav', - action: 'clicked', - label: 'map clicked while on map' - }); - } - - handleNavClick() { - this.props.trackEvent({ - category: 'Nav', - action: 'clicked', - label: 'map clicked while on map' - }); - } - - handleLogoClick(e) { - e.preventDefault(); - this.props.loadCurrentChallenge(); - } - - renderLink(isNavItem, { isReact, isDropdown, content, link, links, target }) { - const Component = isNavItem ? NavItem : MenuItem; - const { - isNavDropdownOpen, - openDropdown, - closeDropdown - } = this.props; - - if (isDropdown) { - // adding a noop to NavDropdown to disable false warning - // about controlled component - return ( - - { links.map(this.renderLink.bind(this, false)) } - - ); - } - if (isReact) { - return ( - - - { content } - - - ); - } - return ( - - { content } - - ); - } - - renderSignIn(username, points, picture, showLoading) { - if (showLoading) { - return null; - } - if (username) { - return ( - - ); - } else { - return ( - - Sign Up - - ); - } - } - - render() { - const { - username, - points, - picture, - showLoading - } = this.props; - - return ( - - - - - - learn to code javascript at freeCodeCamp logo - - - - - - - - ); - } -} - -FCCNav.displayName = 'FCCNav'; -FCCNav.propTypes = propTypes; diff --git a/common/app/components/README.md b/common/app/components/README.md deleted file mode 100644 index e5b2bbdca3..0000000000 --- a/common/app/components/README.md +++ /dev/null @@ -1 +0,0 @@ -things like NavBar and Footer go here diff --git a/common/app/create-app.jsx b/common/app/create-app.jsx index edb63d29fc..efb77b269e 100644 --- a/common/app/create-app.jsx +++ b/common/app/create-app.jsx @@ -2,17 +2,13 @@ import { Observable } from 'rx'; import { match } from 'react-router'; import { compose, createStore, applyMiddleware } from 'redux'; -// main app -import App from './App.jsx'; -// app routes -import createChildRoute from './routes'; - -// redux import { createEpic } from 'redux-epic'; import createReducer from './create-reducer'; -import sagas from './sagas'; +import createRoutes from './create-routes.js'; +import createPanesMap from './create-panes-map.js'; +import createPanesAspects from './Panes/redux'; +import epics from './epics'; -// general utils import servicesCreator from '../utils/services-creator'; const createRouteProps = Observable.fromNodeCallback(match); @@ -27,7 +23,7 @@ const createRouteProps = Observable.fromNodeCallback(match); // middlewares?: Function[], // sideReducers?: Object // enhancers?: Function[], -// sagas?: Function[], +// epics?: Function[], // }) => Observable // // Either location or history must be defined @@ -41,44 +37,49 @@ export default function createApp({ middlewares: sideMiddlewares = [], enhancers: sideEnhancers = [], reducers: sideReducers = {}, - sagas: sideSagas = [], - sagaOptions: sideSagaOptions = {} + epics: sideEpics = [], + epicOptions: sideEpicOptions = {} }) { - const sagaOptions = { - ...sideSagaOptions, + const epicOptions = { + ...sideEpicOptions, services: servicesCreator(serviceOptions) }; - const sagaMiddleware = createEpic( - sagaOptions, - ...sagas, - ...sideSagas + const epicMiddleware = createEpic( + epicOptions, + ...epics, + ...sideEpics ); - const enhancers = [ + const { + reducer: panesReducer, + middleware: panesMiddleware + } = createPanesAspects(createPanesMap()); + const enhancer = compose( applyMiddleware( - ...sideMiddlewares, - sagaMiddleware + panesMiddleware, + epicMiddleware, + ...sideMiddlewares ), // enhancers must come after middlewares // on client side these are things like Redux DevTools ...sideEnhancers - ]; - const reducer = createReducer(sideReducers); + ); + const reducer = createReducer( + { + [panesReducer]: panesReducer, + ...sideReducers + }, + ); // create composed store enhancer // use store enhancer function to enhance `createStore` function // call enhanced createStore function with reducer and initialState // to create store - const store = compose(...enhancers)(createStore)(reducer, initialState); + const store = createStore(reducer, initialState, enhancer); // sync history client side with store. // server side this is an identity function and history is undefined history = syncHistoryWithStore(history, store, syncOptions); - const routes = { - components: App, - ...createChildRoute({ - getState() { return store.getState(); } - }) - }; + const routes = createRoutes(store); // createRouteProps({ // redirect: LocationDescriptor, // history: History, @@ -90,6 +91,6 @@ export default function createApp({ props, reducer, store, - epic: sagaMiddleware + epic: epicMiddleware })); } diff --git a/common/app/create-panes-map.js b/common/app/create-panes-map.js new file mode 100644 index 0000000000..5ce26a90d0 --- /dev/null +++ b/common/app/create-panes-map.js @@ -0,0 +1,7 @@ +import { createPanesMap as routesPanes } from './routes/'; + +export default function createPanesMap() { + return { + ...routesPanes() + }; +} diff --git a/common/app/create-reducer.js b/common/app/create-reducer.js index 80964bc069..869890cbcc 100644 --- a/common/app/create-reducer.js +++ b/common/app/create-reducer.js @@ -1,21 +1,42 @@ import { combineReducers } from 'redux'; import { reducer as formReducer } from 'redux-form'; -import { reducer as app } from './redux'; -import { reducer as toasts } from './toasts/redux'; -import entitiesReducer from './redux/entities-reducer'; -import { - reducer as challengesApp, - projectNormalizer -} from './routes/challenges/redux'; +import app from './redux'; +import entities from './entities'; +import map from './Map/redux'; +import nav from './Nav/redux'; +import routes from './routes/redux'; +import toasts from './Toasts/redux'; +// not ideal but should go away once we move to react-redux-form +import { projectNormalizer } from './routes/challenges/redux'; -export default function createReducer(sideReducers = {}) { - return combineReducers({ - ...sideReducers, - entities: entitiesReducer, +export default function createReducer(sideReducers) { + // reducers exported from features need to be factories + // this helps avoid cyclic requires messing up reducer creation + // We end up with exports from files being undefined as node tries + // to resolve cyclic dependencies. + // This prevents that by wrapping the `handleActions` call so that the ref + // to types imported from parent features are closures and can be resolved + // by node before we need them. + const reducerMap = [ app, - toasts, - challengesApp, - form: formReducer.normalize({ ...projectNormalizer }) - }); + entities, + map, + nav, + routes, + toasts + ] + .map(createReducer => createReducer()) + .reduce((arr, cur) => arr.concat(cur), []) + .reduce( + (reducerMap, reducer) => { + reducerMap[reducer] = reducer; + return reducerMap; + }, + { + form: formReducer.normalize({ ...projectNormalizer }), + ...sideReducers + } + ); + return combineReducers(reducerMap); } diff --git a/common/app/create-routes.js b/common/app/create-routes.js new file mode 100644 index 0000000000..bae05c8e84 --- /dev/null +++ b/common/app/create-routes.js @@ -0,0 +1,9 @@ +import App from './App.jsx'; +import createChildRoute from './routes'; + +export default function createRoutes(store) { + return { + components: App, + ...createChildRoute(store) + }; +} diff --git a/common/app/entities/index.js b/common/app/entities/index.js new file mode 100644 index 0000000000..e9e28c1a2c --- /dev/null +++ b/common/app/entities/index.js @@ -0,0 +1,163 @@ +import { createTypes } from 'redux-create-types'; +import { createAction, handleActions } from 'redux-actions'; + +export const ns = 'entities'; +export const getNS = state => state[ns]; +export const entitiesSelector = getNS; +export const types = createTypes([ + 'updateUserPoints', + 'updateUserFlag', + 'updateUserEmail', + 'updateUserLang', + 'updateUserChallenge', + 'updateUserCurrentChallenge' +], ns); + +// updateUserPoints(username: String, points: Number) => Action +export const updateUserPoints = createAction( + types.updateUserPoints, + (username, points) => ({ username, points }) +); +// updateUserFlag(username: String, flag: String) => Action +export const updateUserFlag = createAction( + types.updateUserFlag, + (username, flag) => ({ username, flag }) +); +// updateUserEmail(username: String, email: String) => Action +export const updateUserEmail = createAction( + types.updateUserFlag, + (username, email) => ({ username, email }) +); +// updateUserLang(username: String, lang: String) => Action +export const updateUserLang = createAction( + types.updateUserLang, + (username, lang) => ({ username, languageTag: lang }) +); + +// updateUserChallenge( +// username: String, +// challengeInfo: Object +// ) => Action +export const updateUserChallenge = createAction( + types.updateUserChallenge, + (username, challengeInfo) => ({ username, challengeInfo }) +); + +export const updateUserCurrentChallenge = createAction( + types.updateUserCurrentChallenge +); + + +const initialState = { + superBlock: {}, + block: {}, + challenge: {}, + user: {} +}; + +export const challengeMapSelector = state => getNS(state).challenge || {}; +export function makeBlockSelector(block) { + return state => { + const blockMap = getNS(state).block || {}; + return blockMap[block] || {}; + }; +} +export function makeSuperBlockSelector(name) { + return state => { + const superBlock = getNS(state).superBlock || {}; + return superBlock[name] || {}; + }; +} + +export default function createReducer() { + const userReducer = handleActions( + { + [types.updateUserPoints]: (state, { payload: { username, points } }) => ({ + ...state, + [username]: { + ...state[username], + points + } + }), + [types.updateUserFlag]: (state, { payload: { username, flag } }) => ({ + ...state, + [username]: { + ...state[username], + [flag]: !state[username][flag] + } + }), + [types.updateUserEmail]: (state, { payload: { username, email } }) => ({ + ...state, + [username]: { + ...state[username], + email + } + }), + [types.updateUserLang]: + ( + state, + { + payload: { username, languageTag } + } + ) => ({ + ...state, + [username]: { + ...state[username], + languageTag + } + }), + [types.updateUserCurrentChallenge]: + ( + state, + { + payload: { username, currentChallengeId } + } + ) => ({ + ...state, + [username]: { + ...state[username], + currentChallengeId + } + }), + [types.updateUserChallenge]: + ( + state, + { + payload: { username, challengeInfo } + } + ) => ({ + ...state, + [username]: { + ...state[username], + challengeMap: { + ...state[username].challengeMap, + [challengeInfo.id]: challengeInfo + } + } + }) + }, + initialState.user + ); + + function metaReducer(state = initialState, action) { + if (action.meta && action.meta.entities) { + return { + ...state, + ...action.meta.entities + }; + } + return state; + } + + function entitiesReducer(state, action) { + const newState = metaReducer(state, action); + const user = userReducer(newState.user, action); + if (newState.user !== user) { + return { ...newState, user }; + } + return newState; + } + + entitiesReducer.toString = () => ns; + return entitiesReducer; +} diff --git a/common/app/epics.js b/common/app/epics.js new file mode 100644 index 0000000000..1b83fd1d2e --- /dev/null +++ b/common/app/epics.js @@ -0,0 +1,15 @@ +import { epics as app } from './redux'; +import { epics as challenge } from './routes/challenges/redux'; +import { epics as settings } from './routes/settings/redux'; +import { epics as nav } from './Nav/redux'; +import { epics as map } from './Map/redux'; +import { epics as panes } from './Panes/redux'; + +export default [ + ...app, + ...challenge, + ...settings, + ...nav, + ...map, + ...panes +]; diff --git a/common/app/index.less b/common/app/index.less index 69bf5eb725..4a0fa6d828 100644 --- a/common/app/index.less +++ b/common/app/index.less @@ -1,2 +1,4 @@ &{ @import "./app.less"; } +&{ @import "./Map/map.less"; } +&{ @import "./Nav/nav.less"; } &{ @import "./routes/index.less"; } diff --git a/common/app/redux/actions.js b/common/app/redux/actions.js deleted file mode 100644 index 6768f58627..0000000000 --- a/common/app/redux/actions.js +++ /dev/null @@ -1,147 +0,0 @@ -import { Observable } from 'rx'; -import { createAction } from 'redux-actions'; -import types from './types'; -import noop from 'lodash/noop'; - -const throwIfUndefined = () => { - throw new TypeError('Argument must not be of type `undefined`'); -}; - -export const createEventMeta = ({ - category = throwIfUndefined, - action = throwIfUndefined, - label, - value -} = throwIfUndefined) => ({ - analytics: { - type: 'event', - category, - action, - label, - value - } -}); - -export const trackEvent = createAction( - types.analytics, - null, - createEventMeta -); - -export const trackSocial = createAction( - types.analytics, - null, - ( - network = throwIfUndefined, - action = throwIfUndefined, - target = throwIfUndefined - ) => ({ - analytics: { - type: 'event', - network, - action, - target - } - }) -); -// updateTitle(title: String) => Action -export const updateTitle = createAction(types.updateTitle); - -// fetchUser() => Action -// used in combination with fetch-user-saga -export const fetchUser = createAction(types.fetchUser); - -// addUser( -// entities: { [userId]: User } -// ) => Action -export const addUser = createAction( - types.addUser, - () => {}, - entities => ({ entities }) -); -export const updateThisUser = createAction(types.updateThisUser); -export const showSignIn = createAction(types.showSignIn); -export const loadCurrentChallenge = createAction( - types.loadCurrentChallenge, - null, - () => createEventMeta({ - category: 'Nav', - action: 'clicked', - label: 'fcc logo clicked' - }) -); -export const updateMyCurrentChallenge = createAction( - types.updateMyCurrentChallenge, - (username, currentChallengeId) => ({ username, currentChallengeId }) -); - -// updateUserPoints(username: String, points: Number) => Action -export const updateUserPoints = createAction( - types.updateUserPoints, - (username, points) => ({ username, points }) -); -// updateUserFlag(username: String, flag: String) => Action -export const updateUserFlag = createAction( - types.updateUserFlag, - (username, flag) => ({ username, flag }) -); -// updateUserEmail(username: String, email: String) => Action -export const updateUserEmail = createAction( - types.updateUserFlag, - (username, email) => ({ username, email }) -); -// updateUserLang(username: String, lang: String) => Action -export const updateUserLang = createAction( - types.updateUserLang, - (username, lang) => ({ username, languageTag: lang }) -); - -// updateUserChallenge( -// username: String, -// challengeInfo: Object -// ) => Action -export const updateUserChallenge = createAction( - types.updateUserChallenge, - (username, challengeInfo) => ({ username, challengeInfo }) -); - -export const updateAppLang = createAction(types.updateAppLang); - -// used when server needs client to redirect -export const delayedRedirect = createAction(types.delayedRedirect); - -// hardGoTo(path: String) => Action -export const hardGoTo = createAction(types.hardGoTo); - -// data -export const updateChallengesData = createAction(types.updateChallengesData); -export const updateHikesData = createAction(types.updateHikesData); - -export const createErrorObservable = error => Observable.just({ - type: types.handleError, - error -}); -// doActionOnError( -// actionCreator: (() => Action|Null) -// ) => (error: Error) => Observable[Action] -export const doActionOnError = actionCreator => error => Observable.of( - { - type: types.handleError, - error - }, - actionCreator() -); - -export const toggleNightMode = createAction( - types.toggleNightMode, - // we use this function to avoid hanging onto the eventObject - // so that react can recycle it - () => null -); -// updateTheme(theme: /night|default/) => Action -export const updateTheme = createAction(types.updateTheme); -// addThemeToBody(theme: /night|default/) => Action -export const addThemeToBody = createAction(types.addThemeToBody); - -export const openDropdown = createAction(types.openDropdown, noop); -export const closeDropdown = createAction(types.closeDropdown, noop); diff --git a/common/app/redux/entities-reducer.js b/common/app/redux/entities-reducer.js deleted file mode 100644 index 9e3df2c6c4..0000000000 --- a/common/app/redux/entities-reducer.js +++ /dev/null @@ -1,98 +0,0 @@ -import { handleActions } from 'redux-actions'; - -import types from './types'; - -const initialState = { - superBlock: {}, - block: {}, - challenge: {}, - user: {} -}; - -const userReducer = handleActions( - { - [types.updateUserPoints]: (state, { payload: { username, points } }) => ({ - ...state, - [username]: { - ...state[username], - points - } - }), - [types.updateUserFlag]: (state, { payload: { username, flag } }) => ({ - ...state, - [username]: { - ...state[username], - [flag]: !state[username][flag] - } - }), - [types.updateUserEmail]: (state, { payload: { username, email } }) => ({ - ...state, - [username]: { - ...state[username], - email - } - }), - [types.updateUserLang]: - ( - state, - { - payload: { username, languageTag } - } - ) => ({ - ...state, - [username]: { - ...state[username], - languageTag - } - }), - [types.updateMyCurrentChallenge]: - ( - state, - { - payload: { username, currentChallengeId } - } - ) => ({ - ...state, - [username]: { - ...state[username], - currentChallengeId - } - }), - [types.updateUserChallenge]: - ( - state, - { - payload: { username, challengeInfo } - } - ) => ({ - ...state, - [username]: { - ...state[username], - challengeMap: { - ...state[username].challengeMap, - [challengeInfo.id]: challengeInfo - } - } - }) - }, - initialState.user -); - -function metaReducer(state = initialState, action) { - if (action.meta && action.meta.entities) { - return { - ...state, - ...action.meta.entities - }; - } - return state; -} - -export default function entitiesReducer(state, action) { - const newState = metaReducer(state, action); - const user = userReducer(newState.user, action); - if (newState.user !== user) { - return { ...newState, user }; - } - return newState; -} diff --git a/common/app/redux/fetch-challenges-epic.js b/common/app/redux/fetch-challenges-epic.js new file mode 100644 index 0000000000..ab8468530c --- /dev/null +++ b/common/app/redux/fetch-challenges-epic.js @@ -0,0 +1,86 @@ +import { Observable } from 'rx'; +import { combineEpics, ofType } from 'redux-epic'; +import debug from 'debug'; + +import { + types, + + createErrorObservable, + delayedRedirect, + + fetchChallengeCompleted, + fetchChallengesCompleted, + + langSelector +} from './'; +import { shapeChallenges } from './utils'; + +const isDev = debug.enabled('fcc:*'); + +export function fetchChallengeEpic(actions, { getState }, { services }) { + return actions::ofType('' + types.fetchChallenge) + .flatMap(({ payload: { dashedName, block } }) => { + const lang = langSelector(getState()); + const options = { + service: 'map', + params: { block, dashedName, lang } + }; + return services.readService$(options) + .retry(3) + .map(({ entities, ...rest }) => ({ + entities: shapeChallenges(entities, isDev), + ...rest + })) + .flatMap(({ entities, result, redirect } = {}) => { + const actions = [ + fetchChallengeCompleted({ + entities, + currentChallenge: result.challenge, + challenge: entities.challenge[result.challenge], + result + }), + redirect ? delayedRedirect(redirect) : null + ]; + return Observable.from(actions).filter(Boolean); + }) + .catch(createErrorObservable); + }); +} + +export function fetchChallengesEpic( + actions, + { getState }, + { services } +) { + return actions::ofType( + // async type + '' + types.fetchChallenges, + types.appMounted + ) + .flatMapLatest(() => { + const lang = langSelector(getState()); + const options = { + lang, + service: 'map' + }; + return services.readService$(options) + .retry(3) + .map(({ entities, ...res }) => ({ + entities: shapeChallenges( + entities, + isDev + ), + ...res + })) + .map(({ entities, result } = {}) => { + return fetchChallengesCompleted( + entities, + result + ); + }) + .startWith({ type: types.fetchChallenges.start }) + .catch(createErrorObservable); + }); +} + +export default combineEpics(fetchChallengeEpic, fetchChallengesEpic); diff --git a/common/app/redux/fetch-user-saga.js b/common/app/redux/fetch-user-epic.js similarity index 61% rename from common/app/redux/fetch-user-saga.js rename to common/app/redux/fetch-user-epic.js index b2b8a36495..a409311e7a 100644 --- a/common/app/redux/fetch-user-saga.js +++ b/common/app/redux/fetch-user-epic.js @@ -1,33 +1,33 @@ import { Observable } from 'rx'; -import types from './types'; +import { ofType } from 'redux-epic'; import { + types, + addUser, updateThisUser, createErrorObservable, showSignIn, updateTheme, addThemeToBody -} from './actions'; +} from './'; -const { fetchUser } = types; -export default function getUserSaga(action$, getState, { services }) { - return action$ - .filter(action => action.type === fetchUser) +export default function getUserEpic(actions, { getState }, { services }) { + return actions::ofType(types.fetchUser) .flatMap(() => { return services.readService$({ service: 'user' }) + .filter(({ entities, result }) => entities && !!result) .flatMap(({ entities, result })=> { - if (!entities || !result) { - return Observable.just(showSignIn()); - } const user = entities.user[result]; const isNightMode = user.theme === 'night'; - return Observable.of( + const actions = [ addUser(entities), updateThisUser(result), isNightMode ? updateTheme(user.theme) : null, isNightMode ? addThemeToBody(user.theme) : null - ); + ]; + return Observable.from(actions).filter(Boolean); }) + .defaultIfEmpty(showSignIn()) .catch(createErrorObservable); }); } diff --git a/common/app/redux/index.js b/common/app/redux/index.js index 4f29ce7e0d..e256de78ed 100644 --- a/common/app/redux/index.js +++ b/common/app/redux/index.js @@ -1,10 +1,279 @@ -import fetchUserSaga from './fetch-user-saga'; -import loadCurrentChallengeSaga from './load-current-challenge-saga'; +import { Observable } from 'rx'; +import { createTypes, createAsyncTypes } from 'redux-create-types'; +import { combineActions, createAction, handleActions } from 'redux-actions'; +import { createSelector } from 'reselect'; +import noop from 'lodash/noop'; +import identity from 'lodash/identity'; -export { default as reducer } from './reducer'; -export * as actions from './actions'; -export { default as types } from './types'; -export const sagas = [ - fetchUserSaga, - loadCurrentChallengeSaga +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 ns from '../ns.json'; + +export const epics = [ + fetchUserEpic, + fetchChallengesEpic, + updateMyCurrentChallengeEpic, + navSizeEpic ]; + +export const types = createTypes([ + 'appMounted', + 'analytics', + 'updateTitle', + 'updateAppLang', + + createAsyncTypes('fetchChallenge'), + createAsyncTypes('fetchChallenges'), + 'updateCurrentChallenge', + + 'fetchUser', + 'addUser', + 'updateThisUser', + 'showSignIn', + + 'handleError', + // used to hit the server + 'hardGoTo', + 'delayedRedirect', + + // night mode + 'toggleNightMode', + 'updateTheme', + 'addThemeToBody' +], ns); + +const throwIfUndefined = () => { + throw new TypeError('Argument must not be of type `undefined`'); +}; + +// createEventMetaCreator({ +// category: String, +// action: String, +// label?: String, +// value?: Number +// }) => () => Object +export const createEventMetaCreator = ({ + // categories are features or namespaces of the app (capitalized): + // Map, Nav, Challenges, and so on + category = throwIfUndefined, + // can be a one word the event + // click, play, toggle. + // This is not a hard and fast rule + action = throwIfUndefined, + // any additional information + // when in doubt use redux action type + // or a short sentence describing the action + label, + // used to tack some specific value for a GA event + value +} = throwIfUndefined) => () => ({ + analytics: { + type: 'event', + category, + action, + label, + value + } +}); + +export const appMounted = createAction(types.appMounted); +export const fetchChallenge = createAction( + '' + types.fetchChallenge, + (dashedName, block) => ({ dashedName, block }) +); +export const fetchChallengeCompleted = createAction( + types.fetchChallenge.complete, + null, + identity +); +export const fetchChallenges = createAction('' + types.fetchChallenges); +export const fetchChallengesCompleted = createAction( + types.fetchChallenges.complete, + (entities, result) => ({ entities, result }), + entities => ({ entities }) +); +export const updateCurrentChallenge = createAction( + types.updateCurrentChallenge +); + +// updateTitle(title: String) => Action +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 updateThisUser = createAction(types.updateThisUser); +export const showSignIn = createAction(types.showSignIn); + +export const updateAppLang = createAction(types.updateAppLang); + +// used when server needs client to redirect +export const delayedRedirect = createAction(types.delayedRedirect); + +// hardGoTo(path: String) => Action +export const hardGoTo = createAction(types.hardGoTo); + +export const createErrorObservable = error => Observable.just({ + type: types.handleError, + error +}); +// doActionOnError( +// actionCreator: (() => Action|Null) +// ) => (error: Error) => Observable[Action] +export const doActionOnError = actionCreator => error => Observable.of( + { + type: types.handleError, + error + }, + actionCreator() +); + +export const toggleNightMode = createAction( + types.toggleNightMode, + // we use this function to avoid hanging onto the eventObject + // so that react can recycle it + () => null +); +// updateTheme(theme: /night|default/) => Action +export const updateTheme = createAction(types.updateTheme); +// addThemeToBody(theme: /night|default/) => Action +export const addThemeToBody = createAction(types.addThemeToBody); + +const initialState = { + title: 'Learn To Code | freeCodeCamp', + isSignInAttempted: false, + user: '', + lang: '', + csrfToken: '', + theme: 'default', + // eventually this should be only in the user object + currentChallenge: '', + superBlocks: [] +}; + +export const getNS = state => state[ns]; +export const langSelector = state => getNS(state).lang; +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 userSelector = createSelector( + state => getNS(state).user, + state => entitiesSelector(state).user, + (username, userMap) => userMap[username] || {} +); + +export const challengeSelector = createSelector( + currentChallengeSelector, + state => entitiesSelector(state).challenge, + (challengeName, challengeMap = {}) => { + return challengeMap[challengeName] || {}; + } +); + +export const firstChallengeSelector = createSelector( + entitiesSelector, + superBlocksSelector, + ( + { + challengeMap, + blockMap, + superBlockMap + }, + superBlocks + ) => { + if ( + !challengeMap || + !blockMap || + !superBlockMap || + !superBlocks + ) { + return {}; + } + try { + return challengeMap[ + blockMap[ + superBlockMap[ + superBlocks[0] + ].blocks[0] + ].challenges[0] + ]; + } catch (err) { + console.error(err); + return {}; + } + } +); + +export default function createReducer() { + const reducer = handleActions( + { + [types.updateTitle]: (state, { payload = 'Learn To Code' }) => ({ + ...state, + title: payload + ' | freeCodeCamp' + }), + + [types.updateThisUser]: (state, { payload: user }) => ({ + ...state, + user + }), + [types.fetchChallenge.complete]: (state, { payload }) => ({ + ...state, + currentChallenge: payload.currentChallenge + }), + [combineActions( + types.fetchChallenge.complete, + types.fetchChallenges.complete + )]: (state, { payload }) => ({ + ...state, + superBlocks: payload.result.superBlocks + }), + [types.updateCurrentChallenge]: (state, { payload = '' }) => ({ + ...state, + currentChallenge: payload + }), + [types.updateAppLang]: (state, { payload = 'en' }) =>({ + ...state, + lang: payload + }), + [types.updateTheme]: (state, { payload = 'default' }) => ({ + ...state, + theme: payload + }), + [combineActions(types.showSignIn, types.updateThisUser)]: state => ({ + ...state, + isSignInAttempted: true + }), + + [types.challengeSaved]: (state, { payload: { points = 0 } }) => ({ + ...state, + points + }), + [types.delayedRedirect]: (state, { payload }) => ({ + ...state, + delayedRedirect: payload + }) + }, + initialState + ); + + reducer.toString = () => ns; + return reducer; +} diff --git a/common/app/redux/load-current-challenge-saga.js b/common/app/redux/load-current-challenge-saga.js deleted file mode 100644 index c89c8f87cf..0000000000 --- a/common/app/redux/load-current-challenge-saga.js +++ /dev/null @@ -1,92 +0,0 @@ -import { Observable } from 'rx'; -import debug from 'debug'; -import { push } from 'react-router-redux'; - -import types from './types'; -import { - updateMyCurrentChallenge, - createErrorObservable -} from './actions'; -import { - userSelector, - firstChallengeSelector -} from './selectors'; -import { updateCurrentChallenge } from '../routes/challenges/redux/actions'; -import getActionsOfType from '../../utils/get-actions-of-type'; -import combineSagas from '../../utils/combine-sagas'; -import { postJSON$ } from '../../utils/ajax-stream'; - -const log = debug('fcc:app/redux/load-current-challenge-saga'); -export function updateMyCurrentChallengeSaga(actions, getState) { - const updateChallenge$ = getActionsOfType( - actions, - updateCurrentChallenge.toString() - ) - .map(({ payload: { id } }) => id) - .filter(() => { - const { app: { user: username } } = getState(); - return !!username; - }) - .distinctUntilChanged(); - const optimistic = updateChallenge$.map(id => { - const { app: { user: username } } = getState(); - return updateMyCurrentChallenge(username, id); - }); - const ajaxUpdate = updateChallenge$ - .debounce(250) - .flatMapLatest(currentChallengeId => { - const { app: { csrfToken: _csrf } } = getState(); - return postJSON$( - '/update-my-current-challenge', - { _csrf, currentChallengeId } - ) - .map(({ message }) => log(message)) - .catch(createErrorObservable); - }); - return Observable.merge(optimistic, ajaxUpdate); -} - -export function loadCurrentChallengeSaga(actions, getState) { - return getActionsOfType(actions, types.loadCurrentChallenge) - .flatMap(() => { - let finalChallenge; - const state = getState(); - const { - entities: { challenge: challengeMap, challengeIdToName }, - challengesApp: { id: currentlyLoadedChallengeId }, - locationBeforeTransition: { pathname } = {} - } = state; - const firstChallenge = firstChallengeSelector(state); - const { user: { currentChallengeId } } = userSelector(state); - const isOnAChallenge = (/^\/[^\/]{2,6}\/challenges/).test(pathname); - - if (!currentChallengeId) { - finalChallenge = firstChallenge; - } else { - finalChallenge = challengeMap[ - challengeIdToName[ currentChallengeId ] - ]; - } - if ( - // data might not be there yet, ignore for now - !finalChallenge || - // are we already on that challenge? - (isOnAChallenge && finalChallenge.id === currentlyLoadedChallengeId) - ) { - // don't reload if the challenge is already loaded. - // This may change to toast to avoid user confusion - return Observable.empty(); - } - return Observable.of( - updateCurrentChallenge(finalChallenge), - push( - `/challenges/${finalChallenge.block}/${finalChallenge.dashedName}` - ) - ); - }); -} - -export default combineSagas( - updateMyCurrentChallengeSaga, - loadCurrentChallengeSaga -); diff --git a/common/app/redux/nav-size-epic.js b/common/app/redux/nav-size-epic.js new file mode 100644 index 0000000000..2a89a861cb --- /dev/null +++ b/common/app/redux/nav-size-epic.js @@ -0,0 +1,13 @@ +import { ofType } from 'redux-epic'; + +import { types } from './'; + +import { updateNavHeight } from '../Panes/redux'; + +export default function navSizeEpic(actions, _, { document }) { + return actions::ofType(types.appMounted) + .map(() => { + const navbar = document.getElementById('navbar'); + return updateNavHeight(navbar.clientHeight || 50); + }); +} diff --git a/common/app/redux/reducer.js b/common/app/redux/reducer.js deleted file mode 100644 index 754345ac76..0000000000 --- a/common/app/redux/reducer.js +++ /dev/null @@ -1,56 +0,0 @@ -import { handleActions } from 'redux-actions'; -import types from './types'; - -const initialState = { - title: 'Learn To Code | freeCodeCamp', - isSignInAttempted: false, - user: '', - lang: '', - csrfToken: '', - theme: 'default' -}; - -export default handleActions( - { - [types.updateTitle]: (state, { payload = 'Learn To Code' }) => ({ - ...state, - title: payload + ' | freeCodeCamp' - }), - - [types.updateThisUser]: (state, { payload: user }) => ({ - ...state, - user, - isSignInAttempted: true - }), - [types.updateAppLang]: (state, { payload = 'en' }) =>({ - ...state, - lang: payload - }), - [types.updateTheme]: (state, { payload = 'default' }) => ({ - ...state, - theme: payload - }), - [types.showSignIn]: state => ({ - ...state, - isSignInAttempted: true - }), - - [types.challengeSaved]: (state, { payload: { points = 0 } }) => ({ - ...state, - points - }), - [types.delayedRedirect]: (state, { payload }) => ({ - ...state, - delayedRedirect: payload - }), - [types.openDropdown]: state => ({ - ...state, - isNavDropdownOpen: true - }), - [types.closeDropdown]: state => ({ - ...state, - isNavDropdownOpen: false - }) - }, - initialState -); diff --git a/common/app/redux/selectors.js b/common/app/redux/selectors.js deleted file mode 100644 index 0a56f23a23..0000000000 --- a/common/app/redux/selectors.js +++ /dev/null @@ -1,38 +0,0 @@ -import { createSelector } from 'reselect'; - -export const userSelector = createSelector( - state => state.app.user, - state => state.entities.user, - (username, userMap) => ({ - user: userMap[username] || {} - }) -); - -export const firstChallengeSelector = createSelector( - state => state.entities.challenge, - state => state.entities.block, - state => state.entities.superBlock, - state => state.challengesApp.superBlocks, - (challengeMap, blockMap, superBlockMap, superBlocks) => { - if ( - !challengeMap || - !blockMap || - !superBlockMap || - !superBlocks - ) { - return {}; - } - try { - return challengeMap[ - blockMap[ - superBlockMap[ - superBlocks[0] - ].blocks[0] - ].challenges[0] - ]; - } catch (err) { - console.error(err); - return {}; - } - } -); diff --git a/common/app/redux/types.js b/common/app/redux/types.js deleted file mode 100644 index 766b695c85..0000000000 --- a/common/app/redux/types.js +++ /dev/null @@ -1,37 +0,0 @@ -import { createTypes } from 'redux-create-types'; - -export default createTypes([ - 'analytics', - 'updateTitle', - 'updateAppLang', - - 'fetchUser', - 'addUser', - 'updateThisUser', - 'updateUserPoints', - 'updateUserFlag', - 'updateUserEmail', - 'updateUserLang', - 'updateUserChallenge', - 'showSignIn', - 'loadCurrentChallenge', - 'updateMyCurrentChallenge', - - 'handleError', - // used to hit the server - 'hardGoTo', - 'delayedRedirect', - - // data handling - 'updateChallengesData', - 'updateHikesData', - - // night mode - 'toggleNightMode', - 'updateTheme', - 'addThemeToBody', - - // nav - 'openDropdown', - 'closeDropdown' -], 'app'); diff --git a/common/app/redux/update-my-challenge-epic.js b/common/app/redux/update-my-challenge-epic.js new file mode 100644 index 0000000000..26a74d9058 --- /dev/null +++ b/common/app/redux/update-my-challenge-epic.js @@ -0,0 +1,48 @@ +import { Observable } from 'rx'; +import { ofType } from 'redux-epic'; +import debug from 'debug'; + +import { + types, + createErrorObservable, + + challengeSelector, + csrfSelector, + userSelector +} from './'; +import { updateUserCurrentChallenge } from '../entities'; +import { postJSON$ } from '../../utils/ajax-stream'; + +const log = debug('fcc:app:redux:up-my-challenge-epic'); +export default function updateMyCurrentChallengeEpic(actions, { getState }) { + const updateChallenge = actions::ofType(types.updateCurrentChallenge) + .map(() => { + const state = getState(); + const { username } = userSelector(state); + const { id } = challengeSelector(state); + const csrf = csrfSelector(state); + return { + username, + csrf, + currentChallengeId: id + }; + }) + .filter(({ username }) => !!username) + .distinctUntilChanged(x => x.currentChallengeId); + const optimistic = updateChallenge.map(updateUserCurrentChallenge); + const ajaxUpdate = updateChallenge + .debounce(250) + .flatMapLatest(({ csrf, currentChallengeId }) => { + return postJSON$( + '/update-my-current-challenge', + { + currentChallengeId, + _csrf: csrf + } + ) + .map(({ message }) => log(message)) + .ignoreElements() + .catch(createErrorObservable); + }); + return Observable.merge(optimistic, ajaxUpdate); +} diff --git a/common/app/redux/utils.js b/common/app/redux/utils.js new file mode 100644 index 0000000000..9be79e8804 --- /dev/null +++ b/common/app/redux/utils.js @@ -0,0 +1,33 @@ +import flowRight from 'lodash/flowRight'; +import createNameIdMap from '../../utils/create-name-id-map.js'; + + +export function filterComingSoonBetaChallenge( + isDev = false, + { isComingSoon, isBeta } +) { + return !(isComingSoon || isBeta) || + isDev; +} + +export function filterComingSoonBetaFromEntities( + { challenge: challengeMap, ...rest }, + isDev = false +) { + const filter = filterComingSoonBetaChallenge.bind(null, isDev); + return { + ...rest, + challenge: Object.keys(challengeMap) + .map(dashedName => challengeMap[dashedName]) + .filter(filter) + .reduce((challengeMap, challenge) => { + challengeMap[challenge.dashedName] = challenge; + return challengeMap; + }, {}) + }; +} + +export const shapeChallenges = flowRight( + filterComingSoonBetaFromEntities, + createNameIdMap +); diff --git a/common/app/redux/utils.test.js b/common/app/redux/utils.test.js new file mode 100644 index 0000000000..08cee9e2ed --- /dev/null +++ b/common/app/redux/utils.test.js @@ -0,0 +1,92 @@ +import test from 'tape'; +import { + filterComingSoonBetaChallenge, + filterComingSoonBetaFromEntities +} from './utils.js'; + + +test.test('filterComingSoonBetaChallenge', t => { + t.plan(4); + t.test('should return true when not coming-soon/beta', t => { + let isDev; + t.ok(filterComingSoonBetaChallenge(isDev, {})); + t.ok(filterComingSoonBetaChallenge(true, {})); + t.end(); + }); + t.test('should return false when isComingSoon', t => { + let isDev; + t.notOk(filterComingSoonBetaChallenge(isDev, { isComingSoon: true })); + t.end(); + }); + t.test('should return false when isBeta', t => { + let isDev; + t.notOk(filterComingSoonBetaChallenge(isDev, { isBeta: true })); + t.end(); + }); + t.test('should always return true when in dev', t => { + let isDev = true; + t.ok(filterComingSoonBetaChallenge(isDev, { isBeta: true })); + t.ok(filterComingSoonBetaChallenge(isDev, { isComingSoon: true })); + t.ok(filterComingSoonBetaChallenge( + isDev, + { isBeta: true, isCompleted: true } + )); + t.end(); + }); +}); +test.test('filterComingSoonBetaFromEntities', t => { + t.plan(2); + t.test('should filter isBeta|coming-soon by default', t => { + t.plan(2); + const normalChallenge = { dashedName: 'normal-challenge' }; + const entities = { + challenge: { + 'coming-soon': { + isComingSoon: true + }, + 'is-beta': { + isBeta: true + }, + [normalChallenge.dashedName]: normalChallenge + } + }; + const actual = filterComingSoonBetaFromEntities(entities); + t.isEqual( + Object.keys(actual.challenge).length, + 1, + 'did not filter the correct amount of challenges' + ); + t.isEqual( + actual.challenge[normalChallenge.dashedName], + normalChallenge, + 'did not return the correct challenge' + ); + }); + t.test('should not filter isBeta|coming-soon when isDev', t => { + t.plan(1); + const normalChallenge = { dashedName: 'normal-challenge' }; + const entities = { + challenge: { + 'coming-soon': { + dashedName: 'coming-soon', + isComingSoon: true + }, + 'is-beta': { + dashedName: 'is-beta', + isBeta: true + }, + 'is-both': { + dashedName: 'is-both', + isBeta: true + }, + [normalChallenge.dashedName]: normalChallenge + } + }; + const actual = filterComingSoonBetaFromEntities(entities, true); + t.isEqual( + Object.keys(actual.challenge).length, + 4, + 'filtered challenges' + ); + }); +}); diff --git a/common/app/routes/challenges/Bug-Modal.jsx b/common/app/routes/challenges/Bug-Modal.jsx index d67c218574..5d301c163b 100644 --- a/common/app/routes/challenges/Bug-Modal.jsx +++ b/common/app/routes/challenges/Bug-Modal.jsx @@ -4,10 +4,15 @@ import { Button, Modal } from 'react-bootstrap'; import PureComponent from 'react-pure-render/component'; import ns from './ns.json'; +import { + createIssue, + openIssueSearch, + closeBugModal, -import { createIssue, openIssueSearch, closeBugModal } from './redux/actions'; + bugModalSelector +} from './redux'; -const mapStateToProps = state => ({ isOpen: state.challengesApp.isBugOpen }); +const mapStateToProps = state => ({ isOpen: bugModalSelector(state) }); const mapDispatchToProps = { createIssue, openIssueSearch, closeBugModal }; const bugLink = 'http://forum.freecodecamp.com/t/how-to-report-a-bug/19543'; diff --git a/common/app/routes/challenges/Code-Mirror-Skeleton.jsx b/common/app/routes/challenges/Code-Mirror-Skeleton.jsx index a9fc788d79..b694652ba4 100644 --- a/common/app/routes/challenges/Code-Mirror-Skeleton.jsx +++ b/common/app/routes/challenges/Code-Mirror-Skeleton.jsx @@ -2,6 +2,8 @@ import React, { PropTypes } from 'react'; import PureComponent from 'react-pure-render/component'; import { Grid, Col, Row } from 'react-bootstrap'; +import ns from './ns.json'; + const propTypes = { content: PropTypes.string }; @@ -10,7 +12,7 @@ export default class CodeMirrorSkeleton extends PureComponent { renderLine(line, i) { return ( -
    +
    diff --git a/common/app/routes/challenges/Completion-Modal.jsx b/common/app/routes/challenges/Completion-Modal.jsx new file mode 100644 index 0000000000..242a0cda56 --- /dev/null +++ b/common/app/routes/challenges/Completion-Modal.jsx @@ -0,0 +1,104 @@ +import noop from 'lodash/noop'; +import React, { PureComponent, PropTypes } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { Button, Modal } from 'react-bootstrap'; +import FontAwesome from 'react-fontawesome'; + +import ns from './ns.json'; +import { + closeChallengeModal, + submitChallenge, + + challengeModalSelector, + successMessageSelector +} from './redux'; + +const mapStateToProps = createSelector( + challengeModalSelector, + successMessageSelector, + (isOpen, message) => ({ + isOpen, + message + }) +); + +const mapDispatchToProps = function(dispatch) { + const dispatchers = { + close: () => dispatch(closeChallengeModal()), + submitChallenge: (e) => { + if ( + e.keyCode === 13 && + (e.ctrlKey || e.meta) + ) { + dispatch(submitChallenge()); + } + } + }; + return () => dispatchers; +}; + +const propTypes = { + close: PropTypes.func.isRequired, + isOpen: PropTypes.bool, + message: PropTypes.string, + submitChallenge: PropTypes.func.isRequired +}; + +export class CompletionModal extends PureComponent { + render() { + const { + close, + isOpen, + submitChallenge, + message + } = this.props; + return ( + + + { message } + + +
    +
    +
    + +
    +
    +
    +
    + + + +
    + ); + } +} + +CompletionModal.displayName = 'CompletionModal'; +CompletionModal.propTypes = propTypes; + +export default connect( + mapStateToProps, + mapDispatchToProps +)(CompletionModal); diff --git a/common/app/routes/challenges/Show.jsx b/common/app/routes/challenges/Show.jsx index 7d955c8c2b..1e03366527 100644 --- a/common/app/routes/challenges/Show.jsx +++ b/common/app/routes/challenges/Show.jsx @@ -5,61 +5,52 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import PureComponent from 'react-pure-render/component'; +import CompletionModal from './Completion-Modal.jsx'; import Classic from './views/classic'; import Step from './views/step'; import Project from './views/project'; -import Video from './views/video'; import BackEnd from './views/backend'; +import { challengeMetaSelector } from './redux'; import { + updateTitle, + updateCurrentChallenge, fetchChallenge, - fetchChallenges, - replaceChallenge, - resetUi -} from './redux/actions'; -import { challengeSelector } from './redux/selectors'; -import { updateTitle } from '../../redux/actions'; -import { makeToast } from '../../toasts/redux/actions'; + + challengeSelector, + langSelector +} from '../../redux'; +import { makeToast } from '../../Toasts/redux'; const views = { backend: BackEnd, classic: Classic, project: Project, simple: Project, - step: Step, - video: Video + step: Step }; const mapDispatchToProps = { fetchChallenge, - fetchChallenges, makeToast, - replaceChallenge, - resetUi, + updateCurrentChallenge, updateTitle }; const mapStateToProps = createSelector( challengeSelector, - state => state.challengesApp.challenge, - state => state.challengesApp.superBlocks, - state => state.app.lang, + challengeMetaSelector, + langSelector, ( - { - challenge: { isTranslated } = {}, - viewType, - title - }, - challenge, - superBlocks = [], + { dashedName, isTranslated }, + { viewType, title }, lang ) => ({ lang, isTranslated, title, - challenge, - viewType, - areChallengesLoaded: superBlocks.length > 0 + challenge: dashedName, + viewType }) ); @@ -74,26 +65,23 @@ const fetchOptions = { }; const link = 'http://forum.freecodecamp.com/t/' + - 'guidelines-for-translating-free-code-camp' + - '-to-any-language/19111'; + 'guidelines-for-translating-free-code-camp' + + '-to-any-language/19111'; const propTypes = { areChallengesLoaded: PropTypes.bool, - fetchChallenges: PropTypes.func.isRequired, isStep: PropTypes.bool, isTranslated: PropTypes.bool, lang: PropTypes.string.isRequired, makeToast: PropTypes.func.isRequired, params: PropTypes.object.isRequired, - replaceChallenge: PropTypes.func.isRequired, - resetUi: PropTypes.func.isRequired, title: PropTypes.string, + updateCurrentChallenge: PropTypes.func.isRequired, updateTitle: PropTypes.func.isRequired, viewType: PropTypes.string }; export class Show extends PureComponent { - componentWillMount() { const { lang, isTranslated, makeToast } = this.props; if (lang !== 'en' && !isTranslated) { @@ -106,27 +94,19 @@ export class Show extends PureComponent { } componentDidMount() { - if (!this.props.areChallengesLoaded) { - this.props.fetchChallenges(); - } if (this.props.title) { this.props.updateTitle(this.props.title); } } - componentWillUnmount() { - this.props.resetUi(); - } - componentWillReceiveProps(nextProps) { const { title } = nextProps; - const { block, dashedName } = nextProps.params; + const { dashedName } = nextProps.params; const { lang, isTranslated } = nextProps; - const { resetUi, updateTitle, replaceChallenge, makeToast } = this.props; + const { updateTitle, updateCurrentChallenge, makeToast } = this.props; if (this.props.params.dashedName !== dashedName) { + updateCurrentChallenge(dashedName); updateTitle(title); - resetUi(); - replaceChallenge({ dashedName, block }); if (lang !== 'en' && !isTranslated) { makeToast({ message: 'We haven\'t translated this challenge yet.', @@ -140,7 +120,12 @@ export class Show extends PureComponent { render() { const { viewType } = this.props; const View = views[viewType] || Classic; - return ; + return ( +
    + + +
    + ); } } diff --git a/common/app/routes/challenges/challenges.less b/common/app/routes/challenges/challenges.less index efda7469a8..b1816a5368 100644 --- a/common/app/routes/challenges/challenges.less +++ b/common/app/routes/challenges/challenges.less @@ -78,4 +78,86 @@ word-wrap: break-word; } +@keyframes skeletonShimmer{ + 0% { + transform: translateX(-48px); + } + 100% { + transform: translateX(1000px); + } +} + +.@{ns}-shimmer { + position: relative; + min-height: 18px; + + .row { + height: 18px; + + .col-xs-12 { + padding-right: 12px; + height: 17px; + } + } + + .sprite-wrapper { + background-color: #333; + height: 17px; + width: 75%; + } + + .sprite { + animation-name: skeletonShimmer; + animation-duration: 2.5s; + animation-timing-function: linear; + animation-iteration-count: infinite; + animation-direction: normal; + background: white; + box-shadow: 0 0 3px 2px; + height: 17px; + width: 2px; + z-index: 5; + } +} + +.@{ns}-success-modal { + display: flex; + flex-direction: column; + justify-content: center; + height: 50vh; + + .modal-title { + color: @gray-lighter; + } + + .modal-header { + background-color: @brand-primary; + margin-bottom: 0; + + .close { + color: #eee; + font-size: 4rem; + opacity: 0.6; + transition: all 300ms ease-out; + margin-top: 0; + padding-left: 0; + + &:hover { + opacity: 1; + } + } + } + + .modal-body { + padding: 35px; + display: flex; + flex-direction: column; + justify-content: center; + + .fa { + margin-right: 0; + } + } +} + &{ @import "./views/index.less"; } diff --git a/common/app/routes/challenges/index.js b/common/app/routes/challenges/index.js index c422581b24..77768a2197 100644 --- a/common/app/routes/challenges/index.js +++ b/common/app/routes/challenges/index.js @@ -1,8 +1,20 @@ import Show from './Show.jsx'; -import _Map from './views/map'; +import { panesMap as backendPanesMap } from './views/backend'; +import { panesMap as classicPanesMap } from './views/classic'; +import { panesMap as stepPanesMap } from './views/step'; +import { panesMap as projectPanesMap } from './views/project'; -export function challengesRoute() { +export function createPanesMap() { return { + ...backendPanesMap, + ...classicPanesMap, + ...stepPanesMap, + ...projectPanesMap + }; +} + +export default function challengesRoutes() { + return [{ path: 'challenges(/:dashedName)', component: Show, onEnter(nextState, replace) { @@ -11,19 +23,8 @@ export function challengesRoute() { replace('/map'); } } - }; -} - -export function modernChallengesRoute() { - return { + }, { path: 'challenges/:block/:dashedName', component: Show - }; -} - -export function mapRoute() { - return { - path: 'map', - component: _Map - }; + }]; } diff --git a/common/app/routes/challenges/redux/actions.js b/common/app/routes/challenges/redux/actions.js deleted file mode 100644 index ca411382b3..0000000000 --- a/common/app/routes/challenges/redux/actions.js +++ /dev/null @@ -1,146 +0,0 @@ -import { createAction } from 'redux-actions'; -import { setContent } from '../../../../utils/polyvinyl'; -import { getMouse, loggerToStr } from '../utils'; - -import types from './types'; - -// step -export const stepForward = createAction(types.stepForward); -export const stepBackward = createAction(types.stepBackward); -export const goToStep = createAction( - types.goToStep, - (step, isUnlocked) => ({ step, isUnlocked }) -); -export const completeAction = createAction(types.completeAction); -export const updateUnlockedSteps = createAction(types.updateUnlockedSteps); -export const openLightBoxImage = createAction(types.openLightBoxImage); -export const closeLightBoxImage = createAction(types.closeLightBoxImage); - -// challenges -export const fetchChallenge = createAction( - types.fetchChallenge, - (dashedName, block) => ({ dashedName, block }) -); -export const fetchChallengeCompleted = createAction( - types.fetchChallengeCompleted, - (_, challenge) => challenge, - entities => ({ entities }) -); -export const closeChallengeModal = createAction(types.closeChallengeModal); -export const resetUi = createAction(types.resetUi); -export const updateHint = createAction(types.updateHint); -export const lockUntrustedCode = createAction(types.lockUntrustedCode); -export const unlockUntrustedCode = createAction( - types.unlockUntrustedCode, - () => null -); -export const updateSuccessMessage = createAction(types.updateSuccessMessage); -export const fetchChallenges = createAction(types.fetchChallenges); -export const fetchChallengesCompleted = createAction( - types.fetchChallengesCompleted, - (_, superBlocks) => superBlocks, - entities => ({ entities }) -); - -export const updateCurrentChallenge = createAction( - types.updateCurrentChallenge -); -export const resetChallenge = createAction(types.resetChallenge); -// replaceChallenge(dashedname) => Action -export const replaceChallenge = createAction(types.replaceChallenge); - -// map -export const updateFilter = createAction( - types.updateFilter, - e => e.target.value -); - -export const initMap = createAction(types.initMap); -export const toggleThisPanel = createAction(types.toggleThisPanel); -export const collapseAll = createAction(types.collapseAll); -export const expandAll = createAction(types.expandAll); - -export const clearFilter = createAction(types.clearFilter); - -// files -export const updateFile = createAction( - types.updateFile, - (content, file) => setContent(content, file) -); - -export const updateFiles = createAction(types.updateFiles); - -// rechallenge -export const executeChallenge = createAction( - types.executeChallenge, - () => null -); - -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); -export const updateOutput = createAction(types.updateOutput, loggerToStr); - -export const checkChallenge = createAction(types.checkChallenge); - -export const showProjectSubmit = createAction(types.showProjectSubmit); - -export const submitChallenge = createAction(types.submitChallenge); -export const moveToNextChallenge = createAction(types.moveToNextChallenge); - -// code storage -export const saveCode = createAction(types.saveCode); -export const loadCode = createAction(types.loadCode); -export const savedCodeFound = createAction( - types.savedCodeFound, - (files, challenge) => ({ files, challenge }) -); -export const clearSavedCode = createAction(types.clearSavedCode); - - -// video challenges -export const toggleQuestionView = createAction(types.toggleQuestionView); -export const grabQuestion = createAction(types.grabQuestion, e => { - let { pageX, pageY, touches } = e; - if (touches) { - e.preventDefault(); - // these re-assigns the values of pageX, pageY from touches - ({ pageX, pageY } = touches[0]); - } - const delta = [pageX, pageY]; - const mouse = [0, 0]; - - return { delta, mouse }; -}); - -export const releaseQuestion = createAction(types.releaseQuestion); -export const moveQuestion = createAction( - types.moveQuestion, - ({ e, delta }) => getMouse(e, delta) -); - -// answer({ -// e: Event, -// answer: Boolean, -// userAnswer: Boolean, -// info: String, -// threshold: Number -// }) => Action -export const answerQuestion = createAction(types.answerQuestion); - -export const startShake = createAction(types.startShake); -export const endShake = createAction(types.primeNextQuestion); - -export const goToNextQuestion = createAction(types.goToNextQuestion); -export const videoCompleted = createAction(types.videoCompleted); - -// bug -export const openBugModal = createAction(types.openBugModal); -export const closeBugModal = createAction(types.closeBugModal); -export const openIssueSearch = createAction(types.openIssueSearch); -export const createIssue = createAction(types.createIssue); diff --git a/common/app/routes/challenges/redux/answer-saga.js b/common/app/routes/challenges/redux/answer-saga.js deleted file mode 100644 index f5bb4aece6..0000000000 --- a/common/app/routes/challenges/redux/answer-saga.js +++ /dev/null @@ -1,86 +0,0 @@ -import { Observable } from 'rx'; -import types from './types'; -import { getMouse } from '../utils'; - -import { submitChallenge, videoCompleted } from './actions'; -import { createErrorObservable } from '../../../redux/actions'; -import { makeToast } from '../../../toasts/redux/actions'; -import { challengeSelector } from './selectors'; - -export default function answerSaga(action$, getState) { - return action$ - .filter(action => action.type === types.answerQuestion) - .flatMap(({ - payload: { - e, - answer, - userAnswer, - info, - threshold - } - }) => { - const state = getState(); - const { - challenge: { tests } - } = challengeSelector(state); - const { - challengesApp: { - currentQuestion, - delta = [ 0, 0 ] - } - } = state; - - let finalAnswer; - // drag answer, compute response - if (typeof userAnswer === 'undefined') { - const [positionX] = getMouse(e, delta); - - // question released under threshold - if (Math.abs(positionX) < threshold) { - return Observable.just(null); - } - - if (positionX >= threshold) { - finalAnswer = true; - } - - if (positionX <= -threshold) { - finalAnswer = false; - } - } else { - finalAnswer = userAnswer; - } - - // incorrect question - if (answer !== finalAnswer) { - let infoAction; - if (info) { - infoAction = makeToast({ - message: info, - timeout: 5000 - }); - } - - return Observable - .just({ type: types.endShake }) - .delay(500) - .startWith(infoAction, { type: types.startShake }); - } - - if (tests[currentQuestion]) { - return Observable - .just({ type: types.goToNextQuestion }) - .delay(300) - .startWith({ type: types.primeNextQuestion }); - } - - - return Observable.just(submitChallenge()) - .delay(300) - // moves question to the appropriate side of the screen - .startWith(videoCompleted(finalAnswer)) - // end with action so we know it is ok to transition - .concat(Observable.just({ type: types.transitionHike })) - .catch(createErrorObservable); - }); -} diff --git a/common/app/routes/challenges/redux/bug-saga.js b/common/app/routes/challenges/redux/bug-epic.js similarity index 81% rename from common/app/routes/challenges/redux/bug-saga.js rename to common/app/routes/challenges/redux/bug-epic.js index c7a3610200..f1d261c570 100644 --- a/common/app/routes/challenges/redux/bug-saga.js +++ b/common/app/routes/challenges/redux/bug-epic.js @@ -1,5 +1,12 @@ -import types from '../redux/types'; -import { closeBugModal } from '../redux/actions'; +import { ofType } from 'redux-epic'; +import { + types, + closeBugModal, + + filesSelector +} from '../redux'; + +import { currentChallengeSelector } from '../../../redux'; function filesToMarkdown(files = {}) { const moreThenOneFile = Object.keys(files).length > 1; @@ -22,19 +29,12 @@ function filesToMarkdown(files = {}) { }, '\n'); } -export default function bugSaga(actions$, getState, { window }) { - return actions$ - .filter(({ type }) => ( - type === types.openIssueSearch || - type === types.createIssue - )) +export default function bugEpic(actions, { getState }, { window }) { + return actions::ofType(types.openIssueSearch, types.createIssue) .map(({ type }) => { - const { - challengesApp: { - legacyKey: challengeName, - files - } - } = getState(); + const state = getState(); + const files = filesSelector(state); + const challengeName = currentChallengeSelector(state); const { navigator: { userAgent }, location: { href } diff --git a/common/app/routes/challenges/redux/next-challenge-saga.js b/common/app/routes/challenges/redux/challenge-epic.js similarity index 58% rename from common/app/routes/challenges/redux/next-challenge-saga.js rename to common/app/routes/challenges/redux/challenge-epic.js index 77fd920113..3f69fe0bce 100644 --- a/common/app/routes/challenges/redux/next-challenge-saga.js +++ b/common/app/routes/challenges/redux/challenge-epic.js @@ -1,22 +1,59 @@ +import debug from 'debug'; import { Observable } from 'rx'; +import { combineEpics, ofType } from 'redux-epic'; import { push } from 'react-router-redux'; -import types from './types'; -import { resetUi, updateCurrentChallenge } from './actions'; -import { createErrorObservable } from '../../../redux/actions'; -import { makeToast } from '../../../toasts/redux/actions'; + +import { + types, + + updateMain, + challengeUpdated +} from './'; +import { getNS as entitiesSelector } from '../../../entities'; import { getNextChallenge, getFirstChallengeOfNextBlock, getFirstChallengeOfNextSuperBlock } from '../utils'; -import debug from 'debug'; +import { + types as app, + + createErrorObservable, + updateCurrentChallenge, + + currentChallengeSelector, + challengeSelector, + superBlocksSelector +} from '../../../redux'; +import { makeToast } from '../../../Toasts/redux'; const isDev = debug.enabled('fcc:*'); -const { moveToNextChallenge } = types; -export default function nextChallengeSaga(actions$, getState) { - return actions$ - .filter(({ type }) => type === moveToNextChallenge) +export function challengeUpdatedEpic(actions, { getState }) { + return actions::ofType(app.updateCurrentChallenge) + .flatMap(() => { + const challenge = challengeSelector(getState()); + return Observable.of( + challengeUpdated(challenge), + push(`/challenges/${challenge.block}/${challenge.dashedName}`) + ); + }); +} + +// used to reset users code on request +export function resetChallengeEpic(actions, { getState }) { + return actions::ofType(types.resetChallenge) + .flatMap(() => { + const currentChallenge = currentChallengeSelector(getState()); + return Observable.of( + updateCurrentChallenge(currentChallenge), + updateMain() + ); + }); +} + +export function nextChallengeEpic(actions, { getState }) { + return actions::ofType(types.moveToNextChallenge) .flatMap(() => { let nextChallenge; // let message = ''; @@ -24,8 +61,9 @@ export default function nextChallengeSaga(actions$, getState) { // let isNewSuperBlock = false; try { const state = getState(); - const { challenge, superBlocks } = state.challengesApp; - const { entities } = state; + const superBlocks = superBlocksSelector(state); + const challenge = currentChallengeSelector(state); + const entities = entitiesSelector(state); nextChallenge = getNextChallenge(challenge, entities, { isDev }); // block completed. if (!nextChallenge) { @@ -73,13 +111,17 @@ export default function nextChallengeSaga(actions$, getState) { ); } return Observable.of( - updateCurrentChallenge(nextChallenge), - resetUi(), - makeToast({ message: 'Your next challenge has arrived.' }), - push(`/challenges/${nextChallenge.block}/${nextChallenge.dashedName}`) + updateCurrentChallenge(nextChallenge.dashedName), + makeToast({ message: 'Your next challenge has arrived.' }) ); } catch (err) { return createErrorObservable(err); } }); } + +export default combineEpics( + challengeUpdatedEpic, + nextChallengeEpic, + resetChallengeEpic +); diff --git a/common/app/routes/challenges/redux/completion-saga.js b/common/app/routes/challenges/redux/completion-epic.js similarity index 63% rename from common/app/routes/challenges/redux/completion-saga.js rename to common/app/routes/challenges/redux/completion-epic.js index 9205cc1153..ab7265aab1 100644 --- a/common/app/routes/challenges/redux/completion-saga.js +++ b/common/app/routes/challenges/redux/completion-epic.js @@ -1,25 +1,35 @@ import { Observable } from 'rx'; +import { ofType } from 'redux-epic'; -import types from './types.js'; import { - moveToNextChallenge, - clearSavedCode -} from './actions.js'; + types, + + moveToNextChallenge, + clearSavedCode, + + challengeMetaSelector, + filesSelector, + testsSelector +} from './'; -import { challengeSelector } from './selectors.js'; import { createErrorObservable, + + challengeSelector, + csrfSelector, + userSelector +} from '../../../redux'; +import { updateUserPoints, updateUserChallenge -} from '../../../redux/actions.js'; +} from '../../../entities'; import { backEndProject } from '../../../utils/challengeTypes.js'; -import { makeToast } from '../../../toasts/redux/actions.js'; +import { makeToast } from '../../../Toasts/redux'; import { postJSON$ } from '../../../../utils/ajax-stream.js'; -import { ofType } from '../../../../utils/get-actions-of-type.js'; function postChallenge(url, username, _csrf, challengeInfo) { const body = { ...challengeInfo, _csrf }; - const saveChallenge$ = postJSON$(url, body) + const saveChallenge = postJSON$(url, body) .retry(3) .flatMap(({ points, lastUpdated, completedDate }) => { return Observable.of( @@ -32,29 +42,27 @@ function postChallenge(url, username, _csrf, challengeInfo) { ); }) .catch(createErrorObservable); - const challengeCompleted$ = Observable.of(moveToNextChallenge()); - return Observable.merge(saveChallenge$, challengeCompleted$); + const challengeCompleted = Observable.of(moveToNextChallenge()); + return Observable.merge(saveChallenge, challengeCompleted); } function submitModern(type, state) { - const { tests } = state.challengesApp; + const tests = testsSelector(state); if (tests.length > 0 && tests.every(test => test.pass && !test.err)) { if (type === types.checkChallenge) { - return Observable.just(null); + return Observable.empty(); } if (type === types.submitChallenge) { - const { challenge: { id } } = challengeSelector(state); - const { - app: { user, csrfToken }, - challengesApp: { files } - } = state; - const challengeInfo = { id, files }; + const { id } = challengeSelector(state); + const files = filesSelector(state); + const { username } = userSelector(state); + const csrfToken = csrfSelector(state); return postChallenge( '/modern-challenge-completed', - user, + username, csrfToken, - challengeInfo + { id, files } ); } } @@ -64,42 +72,36 @@ function submitModern(type, state) { } function submitProject(type, state, { solution, githubLink }) { - const { - challenge: { id, challengeType } - } = challengeSelector(state); - const { - app: { user, csrfToken } - } = state; + const { id, challengeType } = challengeSelector(state); + const { username } = userSelector(state); + const csrfToken = csrfSelector(state); const challengeInfo = { id, challengeType, solution }; if (challengeType === backEndProject) { challengeInfo.githubLink = githubLink; } return postChallenge( '/project-completed', - user, + username, csrfToken, challengeInfo ); } function submitSimpleChallenge(type, state) { - const { - challenge: { id } - } = challengeSelector(state); - const { - app: { user, csrfToken } - } = state; + const { id } = challengeSelector(state); + const { username } = userSelector(state); + const csrfToken = csrfSelector(state); const challengeInfo = { id }; return postChallenge( '/challenge-completed', - user, + username, csrfToken, challengeInfo ); } function submitBackendChallenge(type, state, { solution }) { - const { tests } = state.challengesApp; + const tests = testsSelector(state); if ( type === types.checkChallenge && tests.length > 0 && @@ -115,13 +117,13 @@ function submitBackendChallenge(type, state, { solution }) { }) ); */ - - const { challenge: { id } } = challengeSelector(state); - const { app: { user, csrfToken } } = state; + const { id } = challengeSelector(state); + const { username } = userSelector(state); + const csrfToken = csrfSelector(state); const challengeInfo = { id, solution }; return postChallenge( '/backend-challenge-completed', - user, + username, csrfToken, challengeInfo ); @@ -141,14 +143,12 @@ const submitters = { 'project.simple': submitSimpleChallenge }; -export default function completionSaga(actions$, getState) { - return actions$ - ::ofType(types.checkChallenge, types.submitChallenge) +export default function completionEpic(actions, { getState }) { + return actions::ofType(types.checkChallenge, types.submitChallenge) .flatMap(({ type, payload }) => { const state = getState(); - const { submitType } = challengeSelector(state); - const submitter = submitters[submitType] || - (() => Observable.just(null)); + const { submitType } = challengeMetaSelector(state); + const submitter = submitters[submitType] || (() => Observable.empty()); return submitter(type, state, payload); }); } diff --git a/common/app/routes/challenges/redux/editor-epic.js b/common/app/routes/challenges/redux/editor-epic.js new file mode 100644 index 0000000000..33eda718cc --- /dev/null +++ b/common/app/routes/challenges/redux/editor-epic.js @@ -0,0 +1,17 @@ +import { ofType } from 'redux-epic'; + +import { + types, + updateFile, + + keySelector +} from './'; + +export default function editorEpic(actions, { getState }) { + return actions::ofType(types.classicEditorUpdated) + .pluck('payload') + .map(content => updateFile({ + content, + key: keySelector(getState()) + })); +} diff --git a/common/app/routes/challenges/redux/fetch-challenges-saga.js b/common/app/routes/challenges/redux/fetch-challenges-saga.js deleted file mode 100644 index 5ad27638d3..0000000000 --- a/common/app/routes/challenges/redux/fetch-challenges-saga.js +++ /dev/null @@ -1,89 +0,0 @@ -import { Observable } from 'rx'; -import debug from 'debug'; - -import { challengeSelector } from './selectors'; -import types from './types'; -import { - fetchChallengeCompleted, - fetchChallengesCompleted, - updateCurrentChallenge, - initMap -} from './actions'; -import { - createMapUi, - filterComingSoonBetaFromEntities, - searchableChallengeTitles -} from '../utils'; -import { - delayedRedirect, - createErrorObservable -} from '../../../redux/actions'; -import createNameIdMap from '../../../../utils/create-name-id-map'; - -const isDev = debug.enabled('fcc:*'); - -const { fetchChallenge, fetchChallenges, replaceChallenge } = types; - -export default function fetchChallengesSaga(action$, getState, { services }) { - return action$ - .filter(({ type }) => ( - type === fetchChallenges || - type === fetchChallenge || - type === replaceChallenge - )) - .flatMap(({ type, payload: { dashedName, block } = {} }) => { - const state = getState(); - const lang = state.app.lang; - if (type === replaceChallenge) { - const { challenge: newChallenge } = challengeSelector({ - ...state, - challengesApp: { - ...state.challengesApp, - challenge: dashedName - } - }); - if (state.challengesApp.challenge !== newChallenge.dashedName) { - return Observable.just(updateCurrentChallenge(newChallenge)); - } - return Observable.just(null); - } - const options = { service: 'map' }; - options.params = { lang }; - if (type === fetchChallenge) { - options.params.dashedName = dashedName; - options.params.block = block; - } - return services.readService$(options) - .retry(3) - .flatMap(({ entities, result, redirect } = {}) => { - if (type === fetchChallenge) { - return Observable.of( - fetchChallengeCompleted( - createNameIdMap(entities), - result - ), - updateCurrentChallenge(entities.challenge[result.challenge]), - redirect ? delayedRedirect(redirect) : null - ); - } - const filteredEntities = filterComingSoonBetaFromEntities( - entities, - isDev - ); - const searchNames = searchableChallengeTitles(filteredEntities); - return Observable.of( - fetchChallengesCompleted( - createNameIdMap(filteredEntities), - result - ), - initMap( - createMapUi( - filteredEntities, - result, - searchNames - )), - ); - }) - .catch(createErrorObservable); - }); -} diff --git a/common/app/routes/challenges/redux/index.js b/common/app/routes/challenges/redux/index.js index 1ea0b8dbea..61cef59898 100644 --- a/common/app/routes/challenges/redux/index.js +++ b/common/app/routes/challenges/redux/index.js @@ -1,25 +1,366 @@ -import fetchChallengesSaga from './fetch-challenges-saga'; -import completionSaga from './completion-saga'; -import nextChallengeSaga from './next-challenge-saga'; -import answerSaga from './answer-saga'; -import resetChallengeSaga from './reset-challenge-saga'; -import bugSaga from './bug-saga'; -import mapUiSaga from './map-ui-saga'; -import stepChallengeEpic from './step-challenge-epic'; +import { createTypes } from 'redux-create-types'; +import { createAction, combineActions, handleActions } from 'redux-actions'; +import { createSelector } from 'reselect'; +import noop from 'lodash/noop'; -export * as actions from './actions'; -export reducer from './reducer'; -export types from './types'; +import bugEpic from './bug-epic'; +import completionEpic from './completion-epic.js'; +import challengeEpic from './challenge-epic.js'; +import editorEpic from './editor-epic.js'; -export projectNormalizer from './project-normalizer'; +import ns from '../ns.json'; +import { + arrayToString, + buildSeed, + createTests, + getFileKey, + getPreFile, + loggerToStr, + submitTypes, + viewTypes +} from '../utils'; +import { + types as app, + challengeSelector +} from '../../../redux'; +import { bonfire, html, js } from '../../../utils/challengeTypes'; +import blockNameify from '../../../utils/blockNameify'; +import { createPoly, setContent } from '../../../../utils/polyvinyl'; +import createStepReducer, { epics as stepEpics } from '../views/step/redux'; +import createProjectReducer from '../views/project/redux'; -export const sagas = [ - fetchChallengesSaga, - completionSaga, - nextChallengeSaga, - answerSaga, - resetChallengeSaga, - bugSaga, - mapUiSaga, - stepChallengeEpic +// this is not great but is ok until we move to a different form type +export projectNormalizer from '../views/project/redux'; + +export const epics = [ + bugEpic, + completionEpic, + challengeEpic, + editorEpic, + ...stepEpics ]; + +export const types = createTypes([ + // challenges + // |- classic + 'classicEditorUpdated', + 'challengeUpdated', + 'resetChallenge', + 'updateHint', + 'lockUntrustedCode', + 'unlockUntrustedCode', + 'closeChallengeModal', + 'updateSuccessMessage', + + // files + 'updateFile', + 'updateFiles', + + // rechallenge + 'executeChallenge', + 'updateMain', + 'runTests', + 'frameMain', + 'frameTests', + 'updateOutput', + 'initOutput', + 'updateTests', + 'checkChallenge', + 'submitChallenge', + 'moveToNextChallenge', + + // code storage + 'saveCode', + 'loadCode', + 'savedCodeFound', + 'clearSavedCode', + + // bug + 'openBugModal', + 'closeBugModal', + 'openIssueSearch', + 'createIssue', + + // panes + 'toggleClassicEditor', + 'toggleMain', + 'toggleMap', + 'togglePreview', + 'toggleSidePanel', + 'toggleStep' +], ns); + +// classic +export const classicEditorUpdated = createAction(types.classicEditorUpdated); +// 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 +); +export const updateSuccessMessage = createAction(types.updateSuccessMessage); +export const challengeUpdated = createAction( + types.challengeUpdated, + challenge => ({ challenge }) +); +export const resetChallenge = createAction(types.resetChallenge); +// files +export const updateFile = createAction(types.updateFile); +export const updateFiles = createAction(types.updateFiles); + +// rechallenge +export const executeChallenge = createAction( + types.executeChallenge, + 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); +export const updateOutput = createAction(types.updateOutput, loggerToStr); + +export const checkChallenge = createAction(types.checkChallenge); + +export const submitChallenge = createAction(types.submitChallenge); +export const moveToNextChallenge = createAction(types.moveToNextChallenge); + +// code storage +export const saveCode = createAction(types.saveCode); +export const loadCode = createAction(types.loadCode); +export const savedCodeFound = createAction( + types.savedCodeFound, + (files, challenge) => ({ files, challenge }) +); +export const clearSavedCode = createAction(types.clearSavedCode); + +// bug +export const openBugModal = createAction(types.openBugModal); +export const closeBugModal = createAction(types.closeBugModal); +export const openIssueSearch = createAction(types.openIssueSearch); +export const createIssue = createAction(types.createIssue); + +const initialUiState = { + output: null, + isChallengeModalOpen: false, + isBugOpen: false, + successMessage: 'Happy Coding!', + hintIndex: 0, + numOfHints: 0 +}; + +const initialState = { + isCodeLocked: false, + id: '', + challenge: '', + helpChatRoom: 'Help', + // old code storage key + legacyKey: '', + files: {}, + // map + superBlocks: [], + // misc + ...initialUiState +}; + +export const getNS = state => state[ns]; +export const keySelector = state => getNS(state).key; +export const filesSelector = state => getNS(state).files; +export const testsSelector = state => getNS(state).tests; + +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 chatRoomSelector = state => getNS(state).helpChatRoom; +export const challengeModalSelector = + state => getNS(state).isChallengeModalOpen; + +export const bugModalSelector = state => getNS(state).isBugOpen; + +export const challengeMetaSelector = createSelector( + challengeSelector, + challenge => { + if (!challenge.id) { + return {}; + } + const challengeType = challenge && challenge.challengeType; + const type = challenge && challenge.type; + const viewType = viewTypes[type] || viewTypes[challengeType] || 'classic'; + const blockName = blockNameify(challenge.block); + const title = blockName && challenge.title ? + `${blockName}: ${challenge.title}` : + challenge.title; + + return { + title, + viewType, + submitType: + submitTypes[challengeType] || + submitTypes[challenge && challenge.type] || + 'tests', + showPreview: challengeType === html, + mode: challenge && challengeType === html ? + 'text/html' : + 'javascript' + }; + } +); + +export default function createReducers() { + const setChallengeType = combineActions( + types.challengeUpdated, + app.fetchChallenge.complete + ); + + const mainReducer = handleActions( + { + [setChallengeType]: (state, { payload: { challenge } }) => { + return { + ...state, + ...initialUiState, + id: challenge.id, + challenge: challenge.dashedName, + key: getFileKey(challenge), + tests: createTests(challenge), + helpChatRoom: challenge.helpRoom || 'Help', + numOfHints: Array.isArray(challenge.hints) ? + challenge.hints.length : + 0 + }; + }, + [types.updateTests]: (state, { payload: tests }) => ({ + ...state, + tests, + isChallengeModalOpen: ( + tests.length > 0 && + tests.every(test => test.pass && !test.err) + ) + }), + [types.closeChallengeModal]: state => ({ + ...state, + isChallengeModalOpen: false + }), + [types.updateSuccessMessage]: (state, { payload }) => ({ + ...state, + successMessage: payload + }), + [types.updateHint]: state => ({ + ...state, + hintIndex: state.hintIndex + 1 >= state.numOfHints ? + 0 : + state.hintIndex + 1 + }), + [types.lockUntrustedCode]: state => ({ + ...state, + isCodeLocked: true + }), + [types.unlockUntrustedCode]: state => ({ + ...state, + isCodeLocked: false + }), + [types.executeChallenge]: state => ({ + ...state, + tests: state.tests.map(test => ({ ...test, err: false, pass: false })) + }), + + // classic/modern + [types.initOutput]: (state, { payload: output }) => ({ + ...state, + output + }), + [types.updateOutput]: (state, { payload: output }) => ({ + ...state, + output: (state.output || '') + output + }), + + [types.openBugModal]: state => ({ ...state, isBugOpen: true }), + [types.closeBugModal]: state => ({ ...state, isBugOpen: false }) + }, + initialState + ); + + const filesReducer = 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 === 'mod') { + // this may need to change to update head/tail + return challenge.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({ + ...files[preFile.key], + // make sure head/tail are always fresh + head: arrayToString(challenge.head), + tail: arrayToString(challenge.tail) + }) + }; + }, + [setChallengeType]: (state, { payload: { challenge } }) => { + if (challenge.type === 'mod') { + return challenge.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) + }) + }; + } + }, + {} + ); + + function reducer(state, action) { + const newState = mainReducer(state, action); + const files = filesReducer(state && state.files || {}, action); + if (newState.files !== files) { + return { ...newState, files }; + } + return newState; + } + + reducer.toString = () => ns; + return [ + reducer, + ...createStepReducer(), + ...createProjectReducer() + ]; +} diff --git a/common/app/routes/challenges/redux/map-ui-saga.js b/common/app/routes/challenges/redux/map-ui-saga.js deleted file mode 100644 index 9d3cff1c61..0000000000 --- a/common/app/routes/challenges/redux/map-ui-saga.js +++ /dev/null @@ -1,34 +0,0 @@ -import types from './types'; -import { initMap } from './actions'; -import { unfilterMapUi, applyFilterToMap } from '../utils'; - -export default function mapUiSaga(actions$, getState) { - return actions$ - .filter(({ type }) => ( - type === types.updateFilter || - type === types.clearFilter - )) - .debounce(250) - .map(({ payload: filter = '' }) => filter) - .distinctUntilChanged() - .map(filter => { - const { challengesApp: { mapUi = {} } } = getState(); - let newMapUi; - if (filter.length <= 3) { - newMapUi = unfilterMapUi(mapUi); - } else { - const regexString = filter - // replace spaces with any key to match dashes - .replace(/ /g, '.') - // makes search more fuzzy (thanks @xRahul) - .split('') - .join('.*'); - const filterRegex = new RegExp(regexString, 'i'); - newMapUi = applyFilterToMap(mapUi, filterRegex); - } - if (!newMapUi || newMapUi === mapUi) { - return null; - } - return initMap(newMapUi); - }); -} diff --git a/common/app/routes/challenges/redux/reducer.js b/common/app/routes/challenges/redux/reducer.js deleted file mode 100644 index cda99f4fb2..0000000000 --- a/common/app/routes/challenges/redux/reducer.js +++ /dev/null @@ -1,333 +0,0 @@ -import { handleActions } from 'redux-actions'; -import { createPoly } from '../../../../utils/polyvinyl'; - -import types from './types'; -import { bonfire, html, js } from '../../../utils/challengeTypes'; -import { - arrayToString, - buildSeed, - createTests, - getPreFile, - getFileKey, - toggleThisPanel, - collapseAllPanels, - expandAllPanels -} from '../utils'; - -const initialUiState = { - hintIndex: 0, - // step index tracing - currentIndex: 0, - previousIndex: -1, - // step action - isActionCompleted: false, - isLightBoxOpen: false, - // project is ready to submit - isSubmitting: false, - output: null, - // video - // 1 indexed - currentQuestion: 1, - // [ xPosition, yPosition ] - mouse: [ 0, 0 ], - // change in mouse position since pressed - // [ xDelta, yDelta ] - delta: [ 0, 0 ], - isPressed: false, - isCorrect: false, - shouldShakeQuestion: false, - shouldShowQuestions: false, - isChallengeModalOpen: false, - successMessage: 'Happy Coding!', - unlockedSteps: [] -}; -const initialState = { - isCodeLocked: false, - id: '', - challenge: '', - helpChatRoom: 'Help', - isBugOpen: false, - // old code storage key - legacyKey: '', - files: {}, - // map - mapUi: { isAllCollapsed: false }, - filter: '', - superBlocks: [], - // misc - toast: 0, - ...initialUiState -}; - -const mainReducer = handleActions( - { - [types.fetchChallengeCompleted]: (state, { payload = '' }) => ({ - ...state, - challenge: payload - }), - [types.updateCurrentChallenge]: (state, { payload: challenge = {} }) => ({ - ...state, - id: challenge.id, - // used mainly to find code storage - legacyKey: challenge.name, - challenge: challenge.dashedName, - key: getFileKey(challenge), - tests: createTests(challenge), - helpChatRoom: challenge.helpRoom || 'Help', - numOfHints: Array.isArray(challenge.hints) ? challenge.hints.length : 0 - }), - [types.updateTests]: (state, { payload: tests }) => ({ - ...state, - tests, - isChallengeModalOpen: ( - tests.length > 0 && - tests.every(test => test.pass && !test.err) - ) - }), - [types.closeChallengeModal]: state => ({ - ...state, - isChallengeModalOpen: false - }), - [types.updateSuccessMessage]: (state, { payload }) => ({ - ...state, - successMessage: payload - }), - [types.updateHint]: state => ({ - ...state, - hintIndex: state.hintIndex + 1 >= state.numOfHints ? - 0 : - state.hintIndex + 1 - }), - [types.lockUntrustedCode]: state => ({ - ...state, - isCodeLocked: true - }), - [types.unlockUntrustedCode]: state => ({ - ...state, - isCodeLocked: false - }), - [types.executeChallenge]: state => ({ - ...state, - tests: state.tests.map(test => ({ ...test, err: false, pass: false })) - }), - [types.showChallengeComplete]: (state, { payload: toast }) => ({ - ...state, - toast - }), - [types.showProjectSubmit]: state => ({ - ...state, - isSubmitting: true - }), - [types.resetUi]: (state) => ({ - ...state, - ...initialUiState - }), - - // map - [types.updateFilter]: (state, { payload = ''}) => ({ - ...state, - filter: payload - }), - [types.clearFilter]: (state) => ({ - ...state, - filter: '' - }), - [types.fetchChallengesCompleted]: (state, { payload = [] }) => ({ - ...state, - superBlocks: payload - }), - - // step - [types.goToStep]: (state, { payload: { step = 0, isUnlocked }}) => ({ - ...state, - currentIndex: step, - previousIndex: state.currentIndex, - isActionCompleted: isUnlocked - }), - [types.completeAction]: state => ({ - ...state, - isActionCompleted: true - }), - [types.updateUnlockedSteps]: (state, { payload }) => ({ - ...state, - unlockedSteps: payload - }), - [types.openLightBoxImage]: state => ({ - ...state, - isLightBoxOpen: true - }), - [types.closeLightBoxImage]: state => ({ - ...state, - isLightBoxOpen: false - }), - - // classic/modern - [types.initOutput]: (state, { payload: output }) => ({ - ...state, - output - }), - [types.updateOutput]: (state, { payload: output }) => ({ - ...state, - output: (state.output || '') + output - }), - // video - [types.toggleQuestionView]: state => ({ - ...state, - shouldShowQuestions: !state.shouldShowQuestions, - currentQuestion: 1 - }), - - [types.grabQuestion]: (state, { payload: { delta, mouse } }) => ({ - ...state, - isPressed: true, - delta, - mouse - }), - - [types.releaseQuestion]: state => ({ - ...state, - isPressed: false, - mouse: [ 0, 0 ] - }), - - [types.moveQuestion]: (state, { payload: mouse }) => ({ ...state, mouse }), - [types.startShake]: state => ({ ...state, shouldShakeQuestion: true }), - [types.endShake]: state => ({ ...state, shouldShakeQuestion: false }), - - [types.primeNextQuestion]: (state, { payload: userAnswer }) => ({ - ...state, - currentQuestion: state.currentQuestion + 1, - mouse: [ userAnswer ? 1000 : -1000, 0], - isPressed: false - }), - - [types.goToNextQuestion]: state => ({ - ...state, - mouse: [ 0, 0 ] - }), - - [types.videoCompleted]: (state, { payload: userAnswer }) => ({ - ...state, - isCorrect: true, - isPressed: false, - delta: [ 0, 0 ], - mouse: [ userAnswer ? 1000 : -1000, 0] - }), - - [types.openBugModal]: state => ({ ...state, isBugOpen: true }), - [types.closeBugModal]: state => ({ ...state, isBugOpen: false }) - }, - initialState -); - -const filesReducer = handleActions( - { - [types.updateFile]: (state, { payload: file }) => ({ - ...state, - [file.key]: file - }), - [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 === 'mod') { - // this may need to change to update head/tail - return challenge.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({ - ...files[preFile.key], - // make sure head/tail are always fresh - head: arrayToString(challenge.head), - tail: arrayToString(challenge.tail) - }) - }; - }, - [types.updateCurrentChallenge]: (state, { payload: challenge = {} }) => { - if (challenge.type === 'mod') { - return challenge.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) - }) - }; - } - }, - {} -); - -// { -// children: [...{ -// name: (superBlock: String), -// isOpen: Boolean, -// isHidden: Boolean, -// children: [...{ -// name: (blockName: String), -// isOpen: Boolean, -// isHidden: Boolean, -// children: [...{ -// name: (challengeName: String), -// isHidden: Boolean -// }] -// }] -// } -// } -const mapReducer = handleActions( - { - [types.initMap]: (state, { payload }) => payload, - [types.toggleThisPanel]: (state, { payload: name }) => { - return toggleThisPanel(state, name); - }, - [types.collapseAll]: state => { - const newState = collapseAllPanels(state); - newState.isAllCollapsed = true; - return newState; - }, - [types.expandAll]: state => { - const newState = expandAllPanels(state); - newState.isAllCollapsed = false; - return newState; - } - }, - initialState.mapUi -); - -export default function challengeReducers(state, action) { - const newState = mainReducer(state, action); - const files = filesReducer(state && state.files || {}, action); - if (newState.files !== files) { - return { ...newState, files }; - } - // map actions only effect this reducer; - const mapUi = mapReducer(state && state.mapUi || {}, action); - if (newState.mapUi !== mapUi) { - return { ...newState, mapUi }; - } - return newState; -} diff --git a/common/app/routes/challenges/redux/reset-challenge-saga.js b/common/app/routes/challenges/redux/reset-challenge-saga.js deleted file mode 100644 index 65ff5ec5ad..0000000000 --- a/common/app/routes/challenges/redux/reset-challenge-saga.js +++ /dev/null @@ -1,22 +0,0 @@ -import { Observable } from 'rx'; -import types from './types'; -import { - updateCurrentChallenge, - updateMain -} from './actions'; - -export default function resetChallengeSaga(actions$, getState) { - return actions$ - .filter(({ type }) => type === types.resetChallenge) - .flatMap(() => { - const { - challengesApp: { challenge: dashedName }, - entities: { challenge: challengeMap } - } = getState(); - const currentChallenge = challengeMap[dashedName]; - return Observable.of( - updateCurrentChallenge(currentChallenge), - updateMain() - ); - }); -} diff --git a/common/app/routes/challenges/redux/selectors.js b/common/app/routes/challenges/redux/selectors.js deleted file mode 100644 index 7ab4063529..0000000000 --- a/common/app/routes/challenges/redux/selectors.js +++ /dev/null @@ -1,54 +0,0 @@ -import { createSelector } from 'reselect'; - -import { viewTypes, submitTypes, getNode } from '../utils'; -import blockNameify from '../../../utils/blockNameify'; -import { html } from '../../../utils/challengeTypes'; - -export const challengeSelector = createSelector( - state => state.challengesApp.challenge, - state => state.entities.challenge, - (challengeName, challengeMap) => { - if (!challengeName || !challengeMap) { - return {}; - } - const challenge = challengeMap[challengeName]; - const challengeType = challenge && challenge.challengeType; - const type = challenge && challenge.type; - const viewType = viewTypes[type] || viewTypes[challengeType] || 'classic'; - const blockName = blockNameify(challenge.block); - const title = blockName && challenge.title ? - `${blockName}: ${challenge.title}` : - challenge.title; - return { - challenge, - title, - viewType, - submitType: - submitTypes[challengeType] || - submitTypes[challenge && challenge.type] || - 'tests', - showPreview: challengeType === html, - mode: challenge && challengeType === html ? - 'text/html' : - 'javascript' - }; - } -); - -export const makePanelOpenSelector = () => createSelector( - state => state.challengesApp.mapUi, - (_, props) => props.dashedName, - (mapUi, name) => { - const node = getNode(mapUi, name); - return node ? node.isOpen : true; - } -); - -export const makePanelHiddenSelector = () => createSelector( - state => state.challengesApp.mapUi, - (_, props) => props.dashedName, - (mapUi, name) => { - const node = getNode(mapUi, name); - return node ? node.isHidden : false; - } -); diff --git a/common/app/routes/challenges/redux/types.js b/common/app/routes/challenges/redux/types.js deleted file mode 100644 index d04cd0bb38..0000000000 --- a/common/app/routes/challenges/redux/types.js +++ /dev/null @@ -1,82 +0,0 @@ -import createTypes from '../../../utils/create-types'; - -export default createTypes([ - // step - 'stepForward', - 'stepBackward', - 'goToStep', - 'completeAction', - 'openLightBoxImage', - 'closeLightBoxImage', - 'updateUnlockedSteps', - - // challenges - 'fetchChallenge', - 'fetchChallenges', - 'fetchChallengeCompleted', - 'fetchChallengesCompleted', - 'updateCurrentChallenge', - 'resetChallenge', - 'replaceChallenge', - 'resetUi', - 'updateHint', - 'lockUntrustedCode', - 'unlockUntrustedCode', - 'closeChallengeModal', - 'updateSuccessMessage', - - // map - 'updateFilter', - 'clearFilter', - 'initMap', - 'toggleThisPanel', - 'collapseAll', - 'expandAll', - - // files - 'updateFile', - 'updateFiles', - - // rechallenge - 'executeChallenge', - 'updateMain', - 'runTests', - 'frameMain', - 'frameTests', - 'updateOutput', - 'initOutput', - 'updateTests', - 'checkChallenge', - 'showChallengeComplete', - 'showProjectSubmit', - 'submitChallenge', - 'moveToNextChallenge', - - // code storage - 'saveCode', - 'loadCode', - 'savedCodeFound', - 'clearSavedCode', - - // video challenges - 'toggleQuestionView', - 'grabQuestion', - 'releaseQuestion', - 'moveQuestion', - - 'answerQuestion', - - 'startShake', - 'endShake', - - 'primeNextQuestion', - 'goToNextQuestion', - 'transitionVideo', - 'videoCompleted', - - // bug - 'openBugModal', - 'closeBugModal', - 'openIssueSearch', - 'createIssue' -], 'challenges'); diff --git a/common/app/routes/challenges/utils.js b/common/app/routes/challenges/utils.js index 29e65f2e36..39140c3429 100644 --- a/common/app/routes/challenges/utils.js +++ b/common/app/routes/challenges/utils.js @@ -1,6 +1,5 @@ import flow from 'lodash/flow'; import * as challengeTypes from '../../utils/challengeTypes'; -import protect from '../../utils/empty-protector'; import { decodeScriptTags } from '../../../utils/encode-decode'; // determine the component to view for each challenge @@ -298,265 +297,3 @@ export function getCurrentSuperBlockName(current, entities) { const block = blockMap[challenge.block]; return block.superBlock; } - -// gets new mouse position -// getMouse( -// e: MouseEvent|TouchEvent, -// [ dx: Number, dy: Number ] -// ) => [ Number, Number ] -export function getMouse(e, [dx, dy]) { - let { pageX, pageY, touches, changedTouches } = e; - - // touches can be empty on touchend - if (touches || changedTouches) { - e.preventDefault(); - // these re-assigns the values of pageX, pageY from touches - ({ pageX, pageY } = touches[0] || changedTouches[0]); - } - - return [pageX - dx, pageY - dy]; -} - -export function filterComingSoonBetaChallenge( - isDev = false, - { isComingSoon, isBeta } -) { - return !(isComingSoon || isBeta) || - isDev; -} - -export function filterComingSoonBetaFromEntities( - { challenge: challengeMap, ...rest }, - isDev = false -) { - const filter = filterComingSoonBetaChallenge.bind(null, isDev); - return { - ...rest, - challenge: Object.keys(challengeMap) - .map(dashedName => challengeMap[dashedName]) - .filter(filter) - .reduce((challengeMap, challenge) => { - challengeMap[challenge.dashedName] = challenge; - return challengeMap; - }, {}) - }; -} - -export function searchableChallengeTitles({ challenge: challengeMap } = {}) { - return Object.keys(challengeMap) - .map(dashedName => challengeMap[dashedName]) - .reduce((accu, current) => { - accu[current.dashedName] = current.title; - return accu; - } - , {}); -} - -// interface Node { -// isHidden: Boolean, -// children: Void|[ ...Node ], -// isOpen?: Boolean -// } -// -// interface MapUi -// { -// children: [...{ -// name: (superBlock: String), -// isOpen: Boolean, -// isHidden: Boolean, -// children: [...{ -// name: (blockName: String), -// isOpen: Boolean, -// isHidden: Boolean, -// children: [...{ -// name: (challengeName: String), -// isHidden: Boolean -// }] -// }] -// }] -// } -export function createMapUi( - { superBlock: superBlockMap, block: blockMap } = {}, - superBlocks, - searchNameMap -) { - if (!superBlocks || !superBlockMap || !blockMap) { - return {}; - } - return { - children: superBlocks.map(superBlock => { - return { - name: superBlock, - isOpen: false, - isHidden: false, - children: protect(superBlockMap[superBlock]).blocks.map(block => { - return { - name: block, - isOpen: false, - isHidden: false, - children: protect(blockMap[block]).challenges.map(challenge => { - return { - name: challenge, - title: searchNameMap[challenge], - isHidden: false, - children: null - }; - }) - }; - }) - }; - }) - }; -} - -// synchronise -// traverseMapUi( -// tree: MapUi|Node, -// update: ((MapUi|Node) => MapUi|Node) -// ) => MapUi|Node -export function traverseMapUi(tree, update) { - let childrenChanged; - if (!Array.isArray(tree.children)) { - return update(tree); - } - const newChildren = tree.children.map(node => { - const newNode = traverseMapUi(node, update); - if (!childrenChanged && newNode !== node) { - childrenChanged = true; - } - return newNode; - }); - if (childrenChanged) { - tree = { - ...tree, - children: newChildren - }; - } - return update(tree); -} - -// synchronise -// getNode(tree: MapUi, name: String) => MapUi -export function getNode(tree, name) { - let node; - traverseMapUi(tree, thisNode => { - if (thisNode.name === name) { - node = thisNode; - } - return thisNode; - }); - return node; -} - -// synchronise -// updateSingelNode( -// tree: MapUi, -// name: String, -// update(MapUi|Node) => MapUi|Node -// ) => MapUi -export function updateSingleNode(tree, name, update) { - return traverseMapUi(tree, node => { - if (name !== node.name) { - return node; - } - return update(node); - }); -} - -// synchronise -// toggleThisPanel(tree: MapUi, name: String) => MapUi -export function toggleThisPanel(tree, name) { - return updateSingleNode(tree, name, node => { - return { - ...node, - isOpen: !node.isOpen - }; - }); -} - -// toggleAllPanels(tree: MapUi, isOpen: Boolean = false ) => MapUi -export function toggleAllPanels(tree, isOpen = false) { - return traverseMapUi(tree, node => { - if (!Array.isArray(node.children) || node.isOpen === isOpen) { - return node; - } - return { - ...node, - isOpen - }; - }); -} - -// collapseAllPanels(tree: MapUi) => MapUi -export function collapseAllPanels(tree) { - return toggleAllPanels(tree); -} - -// expandAllPanels(tree: MapUi) => MapUi -export function expandAllPanels(tree) { - return toggleAllPanels(tree, true); -} - -// applyFilterToMap(tree: MapUi, filterRegex: RegExp) => MapUi -export function applyFilterToMap(tree, filterRegex) { - return traverseMapUi( - tree, - node => { - // no children indicates a challenge node - // if leaf (challenge) then test if regex is a match - if (!Array.isArray(node.children)) { - // does challenge name meet filter criteria? - if (filterRegex.test(node.title)) { - // is challenge currently hidden? - if (node.isHidden) { - // unhide challenge, it matches - return { - ...node, - isHidden: false - }; - } - } else if (!node.isHidden) { - return { - ...node, - isHidden: true - }; - } - return node; - } - // if not leaf node (challenge) then - // test to see if all its children are hidden - if (node.children.every(node => node.isHidden)) { - if (node.isHidden) { - return node; - } - return { - ...node, - isHidden: true - }; - } else if (node.isHidden) { - return { - ...node, - isHidden: false - }; - } - // nothing has changed - return node; - } - ); -} - -// unfilterMapUi(tree: MapUi) => MapUi -export function unfilterMapUi(tree) { - return traverseMapUi( - tree, - node => { - if (!node.isHidden) { - return node; - } - return { - ...node, - isHidden: false - }; - } - ); -} diff --git a/common/app/routes/challenges/utils.test.js b/common/app/routes/challenges/utils.test.js index a93cf6845d..06956afcea 100644 --- a/common/app/routes/challenges/utils.test.js +++ b/common/app/routes/challenges/utils.test.js @@ -1,1263 +1,848 @@ import test from 'tape'; -import sinon from 'sinon'; import { getNextChallenge, getFirstChallengeOfNextBlock, - getFirstChallengeOfNextSuperBlock, - filterComingSoonBetaChallenge, - filterComingSoonBetaFromEntities, - createMapUi, - traverseMapUi, - getNode, - updateSingleNode, - toggleThisPanel, - expandAllPanels, - collapseAllPanels, - applyFilterToMap, - unfilterMapUi + getFirstChallengeOfNextSuperBlock } from './utils.js'; +test('getNextChallenge', t => { + t.plan(7); + t.test('should return falsey when current challenge is not found', t => { + t.plan(1); + const entities = { + challenge: {}, + block: {} + }; + t.notOk( + getNextChallenge('non-existent-challenge', entities), + 'getNextChallenge did not return falsey when challenge is not found' + ); + }); + t.test('should return falsey when last challenge in block', t => { + t.plan(1); + const currentChallenge = { + dashedName: 'current-challenge', + block: 'current-block' + }; + const nextChallenge = { + dashedName: 'next-challenge', + block: 'current-block' + }; + const shouldBeNext = getNextChallenge( + 'next-challenge', + { + challenge: { + 'current-challenge': currentChallenge, + 'next-challenge': nextChallenge + }, + block: { + 'current-block': { + challenges: [ + 'current-challenge', + 'next-challenge' + ] + } + } + } + ); + t.false( + shouldBeNext, + 'getNextChallenge should return null or undefined' + ); + }); -test('common/app/routes/challenges/utils', function(t) { - t.test('getNextChallenge', t => { - t.plan(7); - t.test('should return falsey when current challenge is not found', t => { - t.plan(1); - const entities = { - challenge: {}, - block: {} - }; - t.notOk( - getNextChallenge('non-existent-challenge', entities), - 'getNextChallenge did not return falsey when challenge is not found' - ); - }); - t.test('should return falsey when last challenge in block', t => { - t.plan(1); - const currentChallenge = { - dashedName: 'current-challenge', - block: 'current-block' - }; - const nextChallenge = { - dashedName: 'next-challenge', - block: 'current-block' - }; - const shouldBeNext = getNextChallenge( - 'next-challenge', - { - challenge: { - 'current-challenge': currentChallenge, - 'next-challenge': nextChallenge - }, - block: { - 'current-block': { - challenges: [ - 'current-challenge', - 'next-challenge' - ] - } + t.test('should return next challenge when it exists', t => { + t.plan(1); + const currentChallenge = { + dashedName: 'current-challenge', + block: 'current-block' + }; + const nextChallenge = { + dashedName: 'next-challenge', + block: 'current-block' + }; + const shouldBeNext = getNextChallenge( + 'current-challenge', + { + challenge: { + 'current-challenge': currentChallenge, + 'next-challenge': nextChallenge + }, + block: { + 'current-block': { + challenges: [ + 'current-challenge', + 'next-challenge' + ] } } - ); - t.false( - shouldBeNext, - 'getNextChallenge should return null or undefined' - ); - }); - - t.test('should return next challenge when it exists', t => { - t.plan(1); - const currentChallenge = { - dashedName: 'current-challenge', - block: 'current-block' - }; - const nextChallenge = { - dashedName: 'next-challenge', - block: 'current-block' - }; - const shouldBeNext = getNextChallenge( - 'current-challenge', - { - challenge: { - 'current-challenge': currentChallenge, - 'next-challenge': nextChallenge - }, - block: { - 'current-block': { - challenges: [ - 'current-challenge', - 'next-challenge' - ] - } - } - } - ); - t.isEqual(shouldBeNext, nextChallenge); - }); - t.test('should skip isComingSoon challenge', t => { - t.plan(1); - const currentChallenge = { - dashedName: 'current-challenge', - block: 'current-block' - }; - const comingSoon = { - dashedName: 'coming-soon', - isComingSoon: true, - block: 'current-block' - }; - const nextChallenge = { - dashedName: 'next-challenge', - block: 'current-block' - }; - const shouldBeNext = getNextChallenge( - 'current-challenge', - { - challenge: { - 'current-challenge': currentChallenge, - 'next-challenge': nextChallenge, - 'coming-soon': comingSoon, - 'coming-soon2': comingSoon - }, - block: { - 'current-block': { - challenges: [ - 'current-challenge', - 'coming-soon', - 'coming-soon2', - 'next-challenge' - ] - } - } - } - ); - t.isEqual(shouldBeNext, nextChallenge); - }); - t.test('should not skip isComingSoon challenge in dev', t => { - t.plan(1); - const currentChallenge = { - dashedName: 'current-challenge', - block: 'current-block' - }; - const comingSoon = { - dashedName: 'coming-soon', - isComingSoon: true, - block: 'current-block' - }; - const nextChallenge = { - dashedName: 'next-challenge', - block: 'current-block' - }; - const entities = { + } + ); + t.isEqual(shouldBeNext, nextChallenge); + }); + t.test('should skip isComingSoon challenge', t => { + t.plan(1); + const currentChallenge = { + dashedName: 'current-challenge', + block: 'current-block' + }; + const comingSoon = { + dashedName: 'coming-soon', + isComingSoon: true, + block: 'current-block' + }; + const nextChallenge = { + dashedName: 'next-challenge', + block: 'current-block' + }; + const shouldBeNext = getNextChallenge( + 'current-challenge', + { challenge: { 'current-challenge': currentChallenge, 'next-challenge': nextChallenge, - 'coming-soon': comingSoon + 'coming-soon': comingSoon, + 'coming-soon2': comingSoon }, block: { 'current-block': { challenges: [ 'current-challenge', 'coming-soon', + 'coming-soon2', 'next-challenge' ] } } - }; - t.isEqual( - getNextChallenge('current-challenge', entities, { isDev: true }), - comingSoon - ); - }); - t.test('should skip isBeta challenge', t => { - t.plan(1); - const currentChallenge = { - dashedName: 'current-challenge', - block: 'current-block' - }; - const beta = { - dashedName: 'beta-challenge', - isBeta: true, - block: 'current-block' - }; - const nextChallenge = { - dashedName: 'next-challenge', - block: 'current-block' - }; - const shouldBeNext = getNextChallenge( - 'current-challenge', - { - challenge: { - 'current-challenge': currentChallenge, - 'next-challenge': nextChallenge, - 'beta-challenge': beta, - 'beta-challenge2': beta - }, - block: { - 'current-block': { - challenges: [ - 'current-challenge', - 'beta-challenge', - 'beta-challenge2', - 'next-challenge' - ] - } - } + } + ); + t.isEqual(shouldBeNext, nextChallenge); + }); + t.test('should not skip isComingSoon challenge in dev', t => { + t.plan(1); + const currentChallenge = { + dashedName: 'current-challenge', + block: 'current-block' + }; + const comingSoon = { + dashedName: 'coming-soon', + isComingSoon: true, + block: 'current-block' + }; + const nextChallenge = { + dashedName: 'next-challenge', + block: 'current-block' + }; + const entities = { + challenge: { + 'current-challenge': currentChallenge, + 'next-challenge': nextChallenge, + 'coming-soon': comingSoon + }, + block: { + 'current-block': { + challenges: [ + 'current-challenge', + 'coming-soon', + 'next-challenge' + ] } - ); - t.isEqual(shouldBeNext, nextChallenge); - }); - t.test('should not skip isBeta challenge if in dev', t => { - t.plan(1); - const currentChallenge = { - dashedName: 'current-challenge', - block: 'current-block' - }; - const beta = { - dashedName: 'beta-challenge', - isBeta: true, - block: 'current-block' - }; - const nextChallenge = { - dashedName: 'next-challenge', - block: 'current-block' - }; - const entities = { + } + }; + t.isEqual( + getNextChallenge('current-challenge', entities, { isDev: true }), + comingSoon + ); + }); + t.test('should skip isBeta challenge', t => { + t.plan(1); + const currentChallenge = { + dashedName: 'current-challenge', + block: 'current-block' + }; + const beta = { + dashedName: 'beta-challenge', + isBeta: true, + block: 'current-block' + }; + const nextChallenge = { + dashedName: 'next-challenge', + block: 'current-block' + }; + const shouldBeNext = getNextChallenge( + 'current-challenge', + { challenge: { 'current-challenge': currentChallenge, 'next-challenge': nextChallenge, - 'beta-challenge': beta + 'beta-challenge': beta, + 'beta-challenge2': beta }, block: { 'current-block': { challenges: [ 'current-challenge', 'beta-challenge', + 'beta-challenge2', 'next-challenge' ] } } - }; - t.isEqual( - getNextChallenge('current-challenge', entities, { isDev: true }), - beta - ); - }); - }); - - t.test('getFirstChallengeOfNextBlock', t => { - t.plan(8); - t.test('should return falsey when current challenge is not found', t => { - t.plan(1); - const entities = { - challenge: {}, - block: {} - }; - t.notOk( - getFirstChallengeOfNextBlock('non-existent-challenge', entities), - ` - gitFirstChallengeOfNextBlock returned true value for non-existant - challenge - ` - ); - }); - t.test('should return falsey when current block is not found', t => { - t.plan(1); - const entities = { - challenge: { - 'current-challenge': { - block: 'non-existent-block' - } - }, - block: {} - }; - t.notOk( - getFirstChallengeOfNextBlock('current-challenge', entities), - ` - getFirstChallengeOfNextBlock did not returned true value block - did non exist - ` - ); - }); - t.test('should return falsey if no current superBlock found', t => { - t.plan(1); - const entities = { - challenge: { 'current-challenge': { block: 'current-block' } }, - block: { - 'current-block': { - dashedName: 'current-block', - superBlock: 'current-super-block' - } - }, - superBlock: {} - }; - t.notOk( - getFirstChallengeOfNextBlock('current-challenge', entities), - ` - getFirstChallengeOfNextBlock returned a true value - when superBlock is undefined - ` - ); - }); - t.test('should return falsey when no next block found', t => { - t.plan(1); - const entities = { - challenge: { 'current-challenge': { block: 'current-block' } }, - block: { - 'current-block': { - dashedName: 'current-block', - superBlock: 'current-super-block' - } - }, - superBlock: { - 'current-super-block': { - blocks: [ - 'current-block', - 'non-exitent-block' - ] - } - } - }; - t.notOk( - getFirstChallengeOfNextBlock('current-challenge', entities), - ` - getFirstChallengeOfNextBlock returned a value when next block - does not exist - ` - ); - }); - t.test('should return first challenge of next block', t => { - t.plan(1); - const currentChallenge = { - dashedName: 'current-challenge', - block: 'current-block' - }; - const firstChallenge = { - dashedName: 'first-challenge', - block: 'next-block' - }; - const entities = { - challenge: { - [currentChallenge.dashedName]: currentChallenge, - [firstChallenge.dashedName]: firstChallenge - }, - block: { - 'current-block': { - dashedName: 'current-block', - superBlock: 'current-super-block' - }, - 'next-block': { - dashedName: 'next-block', - superBlock: 'current-super-block', - challenges: [ 'first-challenge' ] - } - }, - superBlock: { - 'current-super-block': { - dashedName: 'current-super-block', - blocks: [ 'current-block', 'next-block' ] - } - } - }; - t.equal( - getFirstChallengeOfNextBlock(currentChallenge.dashedName, entities), - firstChallenge, - 'getFirstChallengeOfNextBlock did not return the correct challenge' - ); - }); - t.test('should skip coming soon challenge of next block', t => { - t.plan(2); - const currentChallenge = { - dashedName: 'current-challenge', - block: 'current-block' - }; - const firstChallenge = { - dashedName: 'first-challenge', - block: 'next-block' - }; - const comingSoon = { - dashedName: 'coming-soon', - block: 'next-block', - isComingSoon: true - }; - const comingSoon2 = { - dashedName: 'coming-soon2', - block: 'next-block', - isComingSoon: true - }; - const entities = { - challenge: { - [currentChallenge.dashedName]: currentChallenge, - [firstChallenge.dashedName]: firstChallenge, - 'coming-soon': comingSoon, - 'coming-soon2': comingSoon2 - }, - block: { - 'current-block': { - dashedName: 'current-block', - superBlock: 'current-super-block' - }, - 'next-block': { - dashedName: 'next-block', - superBlock: 'current-super-block', - challenges: [ - 'coming-soon', - 'coming-soon2', - 'first-challenge' - ] - } - }, - superBlock: { - 'current-super-block': { - dashedName: 'current-super-block', - blocks: [ 'current-block', 'next-block' ] - } - } - }; - t.notEqual( - getFirstChallengeOfNextBlock(currentChallenge.dashedName, entities), - comingSoon, - 'getFirstChallengeOfNextBlock returned isComingSoon challenge' - ); - t.equal( - getFirstChallengeOfNextBlock(currentChallenge.dashedName, entities), - firstChallenge, - 'getFirstChallengeOfNextBlock did not return the correct challenge' - ); - }); - t.test('should not skip coming soon in dev mode', t => { - t.plan(1); - const currentChallenge = { - dashedName: 'current-challenge', - block: 'current-block' - }; - const firstChallenge = { - dashedName: 'first-challenge', - block: 'next-block' - }; - const comingSoon = { - dashedName: 'coming-soon', - block: 'next-block', - isComingSoon: true - }; - const entities = { - challenge: { - [currentChallenge.dashedName]: currentChallenge, - [firstChallenge.dashedName]: firstChallenge, - 'coming-soon': comingSoon - }, - block: { - 'current-block': { - dashedName: 'current-block', - superBlock: 'current-super-block' - }, - 'next-block': { - dashedName: 'next-block', - superBlock: 'current-super-block', - challenges: [ - 'coming-soon', - 'first-challenge' - ] - } - }, - superBlock: { - 'current-super-block': { - dashedName: 'current-super-block', - blocks: [ 'current-block', 'next-block' ] - } - } - }; - t.equal( - getFirstChallengeOfNextBlock( - currentChallenge.dashedName, - entities, - { isDev: true } - ), - comingSoon, - 'getFirstChallengeOfNextBlock returned isComingSoon challenge' - ); - }); - t.test('should skip block if all challenges are coming soon', t => { - t.plan(2); - const currentChallenge = { - dashedName: 'current-challenge', - block: 'current-block' - }; - const firstChallenge = { - dashedName: 'first-challenge', - block: 'next-block' - }; - const comingSoon = { - dashedName: 'coming-soon', - block: 'coming-soon-block', - isComingSoon: true - }; - const comingSoon2 = { - dashedName: 'coming-soon2', - block: 'coming-soon-block', - isComingSoon: true - }; - const entities = { - challenge: { - [currentChallenge.dashedName]: currentChallenge, - [firstChallenge.dashedName]: firstChallenge, - [comingSoon.dashedName]: comingSoon, - [comingSoon2.dashedName]: comingSoon2 - }, - block: { - 'current-block': { - dashedName: 'current-block', - superBlock: 'current-super-block' - }, - 'coming-soon-block': { - dashedName: 'coming-soon-block', - superBlock: 'current-super-block', - challenges: [ - 'coming-soon', - 'coming-soon2' - ] - }, - 'next-block': { - dashedName: 'next-block', - superBlock: 'current-super-block', - challenges: [ - 'first-challenge' - ] - } - }, - superBlock: { - 'current-super-block': { - dashedName: 'current-super-block', - blocks: [ - 'current-block', - 'coming-soon-block', - 'next-block' - ] - } - } - }; - t.notEqual( - getFirstChallengeOfNextBlock(currentChallenge.dashedName, entities), - comingSoon, - 'getFirstChallengeOfNextBlock returned isComingSoon challenge' - ); - t.equal( - getFirstChallengeOfNextBlock(currentChallenge.dashedName, entities), - firstChallenge, - 'getFirstChallengeOfNextBlock did not return the correct challenge' - ); - }); - }); - - t.test('getFirstChallengeOfNextBlock', t => { - t.plan(10); - t.test('should return falsey if current challenge not found', t => { - t.plan(1); - const entities = { - challenge: {} - }; - t.notOk( - getFirstChallengeOfNextSuperBlock('current-challenge', entities), - ); - }); - t.test('should return falsey if current block not found', t => { - t.plan(1); - const entities = { - challenge: { 'current-challenge': { block: 'current-block' } }, - block: {} - }; - t.notOk( - getFirstChallengeOfNextSuperBlock('current-challenge', entities) - ); - }); - t.test('should return falsey if current superBlock is not found', t => { - t.plan(1); - const entities = { - challenge: { 'current-challenge': { block: 'current-block' } }, - block: { 'current-block': { superBlock: 'current-super-block' } }, - superBlock: {} - }; - t.notOk( - getFirstChallengeOfNextSuperBlock('current-challenge', entities) - ); - }); - t.test('should return falsey when last superBlock', t => { - t.plan(1); - const entities = { - challenge: { 'current-challenge': { block: 'current-block' } }, - block: { 'current-block': { superBlock: 'current-super-block' } }, - superBlock: { - 'current-super-block': { dashedName: 'current-super-block' } - } - }; - const superBlocks = [ 'current-super-block' ]; - t.notOk(getFirstChallengeOfNextSuperBlock( - 'current-challenge', - entities, - superBlocks - )); - }); - t.test('should return falsey when last block of new superblock', t => { - t.plan(1); - const entities = { - challenge: { 'current-challenge': { block: 'current-block' } }, - block: { - 'current-block': { - superBlock: 'current-super-block' - } - }, - superBlock: { - 'current-super-block': { dashedName: 'current-super-block' }, - 'next-super-block': { - dashedName: 'next-super-block', - blocks: [ - 'first-block' - ] - } - } - }; - const superBlocks = [ 'current-super-block', 'next-super-block' ]; - t.notOk(getFirstChallengeOfNextSuperBlock( - 'current-challenge', - entities, - superBlocks - )); - }); - t.test('should return first challenge of next superBlock', t => { - t.plan(1); - const firstChallenge = { - dashedName: 'first-challenge', - block: 'next-block' - }; - const entities = { - challenge: { - 'current-challenge': { block: 'current-block' }, - [firstChallenge.dashedName]: firstChallenge - }, - block: { - 'current-block': { superBlock: 'current-super-block' }, - 'next-block': { - superBlock: 'next-super-block', - challenges: [ 'first-challenge' ] - } - }, - superBlock: { - 'current-super-block': { dashedName: 'current-super-block' }, - 'next-super-block': { - dashedName: 'next-super-block', - blocks: [ 'next-block' ] - } - } - }; - const superBlocks = [ 'current-super-block', 'next-super-block' ]; - t.isEqual( - getFirstChallengeOfNextSuperBlock( - 'current-challenge', - entities, - superBlocks - ), - firstChallenge - ); - }); - t.test('should skip coming soon challenge', t => { - t.plan(1); - const firstChallenge = { - dashedName: 'first-challenge', - block: 'next-block' - }; - const entities = { - challenge: { - 'current-challenge': { block: 'current-block' }, - [firstChallenge.dashedName]: firstChallenge, - 'coming-soon': { - dashedName: 'coming-soon', - block: 'next-block', - isComingSoon: true - } - }, - block: { - 'current-block': { superBlock: 'current-super-block' }, - 'next-block': { - dashedName: 'next-block', - superBlock: 'next-super-block', - challenges: [ 'coming-soon', 'first-challenge' ] - } - }, - superBlock: { - 'current-super-block': { dashedName: 'current-super-block' }, - 'next-super-block': { - dashedName: 'next-super-block', - blocks: [ 'next-block' ] - } - } - }; - const superBlocks = [ - 'current-super-block', - 'next-super-block' - ]; - t.isEqual( - getFirstChallengeOfNextSuperBlock( - 'current-challenge', - entities, - superBlocks - ), - firstChallenge - ); - }); - t.test('should not skip coming soon in dev mode', t => { - t.plan(1); - const firstChallenge = { - dashedName: 'first-challenge', - block: 'next-block' - }; - const comingSoon = { - dashedName: 'coming-soon', - block: 'next-block', - isComingSoon: true - }; - const entities = { - challenge: { - 'current-challenge': { block: 'current-block' }, - [firstChallenge.dashedName]: firstChallenge, - 'coming-soon': comingSoon - }, - block: { - 'current-block': { superBlock: 'current-super-block' }, - 'next-block': { - dashedName: 'next-block', - superBlock: 'next-super-block', - challenges: [ 'coming-soon', 'first-challenge' ] - } - }, - superBlock: { - 'current-super-block': { dashedName: 'current-super-block' }, - 'next-super-block': { - dashedName: 'next-super-block', - blocks: [ 'next-block' ] - } - } - }; - const superBlocks = [ - 'current-super-block', - 'next-super-block' - ]; - t.isEqual( - getFirstChallengeOfNextSuperBlock( - 'current-challenge', - entities, - superBlocks, - { isDev: true } - ), - comingSoon - ); - }); - t.test('should skip coming soon block', t => { - t.plan(1); - const firstChallenge = { - dashedName: 'first-challenge', - block: 'next-block' - }; - const entities = { - challenge: { - 'current-challenge': { block: 'current-block' }, - [firstChallenge.dashedName]: firstChallenge, - 'coming-soon': { - dashedName: 'coming-soon', - block: 'coming-soon-block', - isComingSoon: true - } - }, - block: { - 'current-block': { superBlock: 'current-super-block' }, - 'coming-soon-block': { - dashedName: 'coming-soon-block', - superBlock: 'next-super-block', - challenges: [ - 'coming-soon' - ] - }, - 'next-block': { - dashedName: 'next-block', - superBlock: 'next-super-block', - challenges: [ 'first-challenge' ] - } - }, - superBlock: { - 'current-super-block': { dashedName: 'current-super-block' }, - 'next-super-block': { - dashedName: 'next-super-block', - blocks: [ 'coming-soon-block', 'next-block' ] - } - } - }; - const superBlocks = [ - 'current-super-block', - 'next-super-block' - ]; - t.isEqual( - getFirstChallengeOfNextSuperBlock( - 'current-challenge', - entities, - superBlocks - ), - firstChallenge - ); - }); - t.test('should skip coming soon super block', t => { - t.plan(1); - const firstChallenge = { - dashedName: 'first-challenge', - block: 'next-block' - }; - const entities = { - challenge: { - 'current-challenge': { block: 'current-block' }, - [firstChallenge.dashedName]: firstChallenge, - 'coming-soon': { - dashedName: 'coming-soon', - block: 'coming-soon-block', - isComingSoon: true - } - }, - block: { - 'current-block': { superBlock: 'current-super-block' }, - 'coming-soon-block': { - dashedName: 'coming-soon-block', - superBlock: 'coming-soon-super-block', - challenges: [ - 'coming-soon' - ] - }, - 'next-block': { - superBlock: 'next-super-block', - dashedName: 'next-block', - challenges: [ 'first-challenge' ] - } - }, - superBlock: { - 'current-super-block': { dashedName: 'current-super-block' }, - 'coming-soon-super-block': { - dashedName: 'coming-soon-super-block', - blocks: [ 'coming-soon-block' ] - }, - 'next-super-block': { - dashedName: 'next-super-block', - blocks: [ 'next-block' ] - } - } - }; - const superBlocks = [ - 'current-super-block', - 'coming-soon-super-block', - 'next-super-block' - ]; - t.isEqual( - getFirstChallengeOfNextSuperBlock( - 'current-challenge', - entities, - superBlocks - ), - firstChallenge - ); - }); - }); - t.test('filterComingSoonBetaChallenge', t => { - t.plan(4); - t.test('should return true when not coming-soon/beta', t => { - let isDev; - t.ok(filterComingSoonBetaChallenge(isDev, {})); - t.ok(filterComingSoonBetaChallenge(true, {})); - t.end(); - }); - t.test('should return false when isComingSoon', t => { - let isDev; - t.notOk(filterComingSoonBetaChallenge(isDev, { isComingSoon: true })); - t.end(); - }); - t.test('should return false when isBeta', t => { - let isDev; - t.notOk(filterComingSoonBetaChallenge(isDev, { isBeta: true })); - t.end(); - }); - t.test('should always return true when in dev', t => { - let isDev = true; - t.ok(filterComingSoonBetaChallenge(isDev, { isBeta: true })); - t.ok(filterComingSoonBetaChallenge(isDev, { isComingSoon: true })); - t.ok(filterComingSoonBetaChallenge( - isDev, - { isBeta: true, isCompleted: true } - )); - t.end(); - }); - }); - t.test('filterComingSoonBetaFromEntities', t => { - t.plan(2); - t.test('should filter isBeta|coming-soon by default', t => { - t.plan(2); - const normalChallenge = { dashedName: 'normal-challenge' }; - const entities = { - challenge: { - 'coming-soon': { - isComingSoon: true - }, - 'is-beta': { - isBeta: true - }, - [normalChallenge.dashedName]: normalChallenge - } - }; - const actual = filterComingSoonBetaFromEntities(entities); - t.isEqual( - Object.keys(actual.challenge).length, - 1, - 'did not filter the correct amount of challenges' - ); - t.isEqual( - actual.challenge[normalChallenge.dashedName], - normalChallenge, - 'did not return the correct challenge' - ); - }); - t.test('should not filter isBeta|coming-soon when isDev', t => { - t.plan(1); - const normalChallenge = { dashedName: 'normal-challenge' }; - const entities = { - challenge: { - 'coming-soon': { - dashedName: 'coming-soon', - isComingSoon: true - }, - 'is-beta': { - dashedName: 'is-beta', - isBeta: true - }, - 'is-both': { - dashedName: 'is-both', - isBeta: true - }, - [normalChallenge.dashedName]: normalChallenge - } - }; - const actual = filterComingSoonBetaFromEntities(entities, true); - t.isEqual( - Object.keys(actual.challenge).length, - 4, - 'filtered challenges' - ); - }); - }); - t.test('createMapUi', t => { - t.plan(3); - t.test('should return an `{}` when proper args not supplied', t => { - t.plan(3); - t.equal( - Object.keys(createMapUi()).length, - 0 - ); - t.equal( - Object.keys(createMapUi({}, [])).length, - 0 - ); - t.equal( - Object.keys(createMapUi({ superBlock: {} }, [])).length, - 0 - ); - }); - t.test('should return a map tree', t => { - const expected = { - children: [{ - name: 'superBlockA', - children: [{ - name: 'blockA', - children: [{ - name: 'challengeA' - }] - }] - }] - }; - const actual = createMapUi({ - superBlock: { - superBlockA: { - blocks: [ - 'blockA' - ] - } - }, - block: { - blockA: { - challenges: [ - 'challengeA' - ] - } - } - }, - ['superBlockA'], - { challengeA: 'ChallengeA title'} + } ); - t.plan(3); - t.equal(actual.children[0].name, expected.children[0].name); - t.equal( - actual.children[0].children[0].name, - expected.children[0].children[0].name - ); - t.equal( - actual.children[0].children[0].children[0].name, - expected.children[0].children[0].children[0].name - ); - }); - t.test('should protect against malformed data', t => { - t.plan(2); - t.equal( - createMapUi({ - superBlock: {}, - block: { - blockA: { - challenges: [ - 'challengeA' - ] - } - } - }, ['superBlockA']).children[0].children.length, - 0 - ); - t.equal( - createMapUi({ - superBlock: { - superBlockA: { - blocks: [ - 'blockA' - ] - } - }, - block: {} - }, ['superBlockA']).children[0].children[0].children.length, - 0 - ); - }); + t.isEqual(shouldBeNext, nextChallenge); }); - t.test('traverseMapUi', t => { - t.test('should return tree', t => { - t.plan(2); - const expectedTree = {}; - const actaulTree = traverseMapUi(expectedTree, tree => { - t.equal(tree, expectedTree); - return tree; - }); - t.equal(actaulTree, expectedTree); - }); - t.test('should hit every node', t => { - t.plan(4); - const expected = { children: [{ children: [{}] }] }; - const spy = sinon.spy(t => t); - spy.withArgs(expected); - spy.withArgs(expected.children[0]); - spy.withArgs(expected.children[0].children[0]); - traverseMapUi(expected, spy); - t.equal(spy.callCount, 3); - t.ok(spy.withArgs(expected).calledOnce, 'foo'); - t.ok(spy.withArgs(expected.children[0]).calledOnce, 'bar'); - t.ok(spy.withArgs(expected.children[0].children[0]).calledOnce, 'baz'); - }); - t.test('should create new object when children change', t => { - t.plan(9); - const expected = { children: [{ bar: true }, {}] }; - const actual = traverseMapUi(expected, node => ({ ...node, foo: true })); - t.notEqual(actual, expected); - t.notEqual(actual.children, expected.children); - t.notEqual(actual.children[0], expected.children[0]); - t.notEqual(actual.children[1], expected.children[1]); - t.equal(actual.children[0].bar, expected.children[0].bar); - t.notOk(expected.children[0].foo); - t.notOk(expected.children[1].foo); - t.true(actual.children[0].foo); - t.true(actual.children[1].foo); - }); - }); - t.test('getNode', t => { - t.test('should return node', t => { - t.plan(1); - const expected = { name: 'foo' }; - const tree = { children: [{ name: 'notfoo' }, expected ] }; - const actual = getNode(tree, 'foo'); - t.equal(expected, actual); - }); - t.test('should returned undefined if not found', t => { - t.plan(1); - const tree = { - children: [ { name: 'foo' }, { children: [ { name: 'bar' } ] } ] - }; - const actual = getNode(tree, 'baz'); - t.notOk(actual); - }); - }); - t.test('updateSingleNode', t => { - t.test('should update single node', t => { - const expected = { name: 'foo' }; - const untouched = { name: 'notFoo' }; - const actual = updateSingleNode( - { children: [ untouched, expected ] }, - 'foo', - node => ({ ...node, tag: true }) - ); - t.plan(4); - t.ok(actual.children[1].tag); - t.equal(actual.children[1].name, expected.name); - t.notEqual(actual.children[1], expected); - t.equal(actual.children[0], untouched); - }); - }); - t.test('toggleThisPanel', t => { - t.test('should update single node', t => { - const expected = { name: 'foo', isOpen: true }; - const actual = toggleThisPanel( - { children: [ { name: 'foo', isOpen: false }] }, - 'foo' - ); - t.plan(1); - t.deepLooseEqual(actual.children[0], expected); - }); - }); - t.test('toggleAllPanels', t => { - t.test('should add `isOpen: true` to every node without children', t => { - const expected = { - isOpen: true, - children: [{ - isOpen: true, - children: [{}, {}] - }] - }; - const actual = expandAllPanels({ children: [{ children: [{}, {}] }] }); - t.plan(1); - t.deepLooseEqual(actual, expected); - }); - t.test('should add `isOpen: false` to every node without children', t => { - const leaf = {}; - const expected = { - isOpen: false, - children: [{ - isOpen: false, - children: [{}, leaf] - }] - }; - const actual = collapseAllPanels( - { isOpen: true, children: [{ children: [{}, leaf]}]}, - ); - t.plan(2); - t.deepLooseEqual(actual, expected); - t.equal(actual.children[0].children[1], leaf); - }); - }); - t.test('applyFilterToMap', t => { - t.test('should not touch child that is already hidden', t => { - t.plan(1); - const expected = { name: 'bar', isHidden: true }; - const actual = applyFilterToMap( - expected, - /foo/ - ); - t.equal(actual, expected); - }); - t.test('should update child that is hidden', t => { - t.plan(1); - const expected = { title: 'bar', isHidden: false }; - const input = { title: 'bar', isHidden: true }; - const actual = applyFilterToMap(input, /bar/); - t.deepLooseEqual(actual, expected); - }); - t.test('should unhide child that matches filter regex', t => { - t.plan(1); - const expected = { title: 'foo' }; - const actual = applyFilterToMap({ title: 'foo' }, /foo/); - t.deepLooseEqual(actual, expected); - }); - t.test('should hide child that does not match filter', t => { - t.plan(1); - const expected = { title: 'bar', isHidden: true }; - const actual = applyFilterToMap({ title: 'bar' }, /foo/); - t.deepLooseEqual(actual, expected); - }); - t.test('should not touch node that is already hidden', t => { - t.plan(1); - const expected = { - name: 'bar', - isHidden: true, - children: [ - { name: 'baz', isHidden: true }, - { name: 'baz2', isHidden: true } - ] - }; - const actual = applyFilterToMap(expected, /foo/); - t.equal(actual, expected); - }); - t.test('should not touch node that is unhidden', t => { - t.plan(1); - const expected = { - name: 'bar', - isHidden: false, - children: [ - { title: 'baz', isHidden: true }, - { title: 'foo', isHidden: false } - ] - }; - const actual = applyFilterToMap(expected, /foo/); - t.equal(actual, expected); - }); - t.test('should hide node if all children are hidden', t => { - t.plan(1); - const input = { - name: 'bar', - isHidden: false, - children: [ - { name: 'baz' }, - { name: 'baz2', isHidden: false } - ] - }; - const expected = { - name: 'bar', - isHidden: true, - children: [ - { name: 'baz', isHidden: true }, - { name: 'baz2', isHidden: true } - ] - }; - const actual = applyFilterToMap(input, /foo/); - t.deepLooseEqual(actual, expected); - }); - t.test('should unhide node some children unhidden', t => { - t.plan(1); - const input = { - name: 'bar', - isHidden: true, - children: [ - { title: 'baz', isHidden: true }, - { title: 'foo', isHidden: false } - ] - }; - const expected = { - name: 'bar', - isHidden: false, - children: [ - { title: 'baz', isHidden: true }, - { title: 'foo', isHidden: false } - ] - }; - const actual = applyFilterToMap(input, /foo/); - t.deepLooseEqual(actual, expected); - }); - }); - t.test('unfilterMapUi', t => { - t.test('should not touch node that is already hidden', t => { - const expected = { isHidden: false }; - const actual = unfilterMapUi(expected); - t.plan(1); - t.equal(actual, expected); - }); - t.test('should update node that is not hidden', t => { - const expected = { isHidden: false }; - const input = { isHidden: true }; - const actual = unfilterMapUi(input); - t.plan(2); - t.notEqual(actual, input); - t.deepLooseEqual(actual, expected); - }); + t.test('should not skip isBeta challenge if in dev', t => { + t.plan(1); + const currentChallenge = { + dashedName: 'current-challenge', + block: 'current-block' + }; + const beta = { + dashedName: 'beta-challenge', + isBeta: true, + block: 'current-block' + }; + const nextChallenge = { + dashedName: 'next-challenge', + block: 'current-block' + }; + const entities = { + challenge: { + 'current-challenge': currentChallenge, + 'next-challenge': nextChallenge, + 'beta-challenge': beta + }, + block: { + 'current-block': { + challenges: [ + 'current-challenge', + 'beta-challenge', + 'next-challenge' + ] + } + } + }; + t.isEqual( + getNextChallenge('current-challenge', entities, { isDev: true }), + beta + ); + }); +}); + +test('getFirstChallengeOfNextBlock', t => { + t.plan(8); + t.test('should return falsey when current challenge is not found', t => { + t.plan(1); + const entities = { + challenge: {}, + block: {} + }; + t.notOk( + getFirstChallengeOfNextBlock('non-existent-challenge', entities), + ` + gitFirstChallengeOfNextBlock returned true value for non-existant + challenge + ` + ); + }); + t.test('should return falsey when current block is not found', t => { + t.plan(1); + const entities = { + challenge: { + 'current-challenge': { + block: 'non-existent-block' + } + }, + block: {} + }; + t.notOk( + getFirstChallengeOfNextBlock('current-challenge', entities), + ` + getFirstChallengeOfNextBlock did not returned true value block + did non exist + ` + ); + }); + t.test('should return falsey if no current superBlock found', t => { + t.plan(1); + const entities = { + challenge: { 'current-challenge': { block: 'current-block' } }, + block: { + 'current-block': { + dashedName: 'current-block', + superBlock: 'current-super-block' + } + }, + superBlock: {} + }; + t.notOk( + getFirstChallengeOfNextBlock('current-challenge', entities), + ` + getFirstChallengeOfNextBlock returned a true value + when superBlock is undefined + ` + ); + }); + t.test('should return falsey when no next block found', t => { + t.plan(1); + const entities = { + challenge: { 'current-challenge': { block: 'current-block' } }, + block: { + 'current-block': { + dashedName: 'current-block', + superBlock: 'current-super-block' + } + }, + superBlock: { + 'current-super-block': { + blocks: [ + 'current-block', + 'non-exitent-block' + ] + } + } + }; + t.notOk( + getFirstChallengeOfNextBlock('current-challenge', entities), + ` + getFirstChallengeOfNextBlock returned a value when next block + does not exist + ` + ); + }); + t.test('should return first challenge of next block', t => { + t.plan(1); + const currentChallenge = { + dashedName: 'current-challenge', + block: 'current-block' + }; + const firstChallenge = { + dashedName: 'first-challenge', + block: 'next-block' + }; + const entities = { + challenge: { + [currentChallenge.dashedName]: currentChallenge, + [firstChallenge.dashedName]: firstChallenge + }, + block: { + 'current-block': { + dashedName: 'current-block', + superBlock: 'current-super-block' + }, + 'next-block': { + dashedName: 'next-block', + superBlock: 'current-super-block', + challenges: [ 'first-challenge' ] + } + }, + superBlock: { + 'current-super-block': { + dashedName: 'current-super-block', + blocks: [ 'current-block', 'next-block' ] + } + } + }; + t.equal( + getFirstChallengeOfNextBlock(currentChallenge.dashedName, entities), + firstChallenge, + 'getFirstChallengeOfNextBlock did not return the correct challenge' + ); + }); + t.test('should skip coming soon challenge of next block', t => { + t.plan(2); + const currentChallenge = { + dashedName: 'current-challenge', + block: 'current-block' + }; + const firstChallenge = { + dashedName: 'first-challenge', + block: 'next-block' + }; + const comingSoon = { + dashedName: 'coming-soon', + block: 'next-block', + isComingSoon: true + }; + const comingSoon2 = { + dashedName: 'coming-soon2', + block: 'next-block', + isComingSoon: true + }; + const entities = { + challenge: { + [currentChallenge.dashedName]: currentChallenge, + [firstChallenge.dashedName]: firstChallenge, + 'coming-soon': comingSoon, + 'coming-soon2': comingSoon2 + }, + block: { + 'current-block': { + dashedName: 'current-block', + superBlock: 'current-super-block' + }, + 'next-block': { + dashedName: 'next-block', + superBlock: 'current-super-block', + challenges: [ + 'coming-soon', + 'coming-soon2', + 'first-challenge' + ] + } + }, + superBlock: { + 'current-super-block': { + dashedName: 'current-super-block', + blocks: [ 'current-block', 'next-block' ] + } + } + }; + t.notEqual( + getFirstChallengeOfNextBlock(currentChallenge.dashedName, entities), + comingSoon, + 'getFirstChallengeOfNextBlock returned isComingSoon challenge' + ); + t.equal( + getFirstChallengeOfNextBlock(currentChallenge.dashedName, entities), + firstChallenge, + 'getFirstChallengeOfNextBlock did not return the correct challenge' + ); + }); + t.test('should not skip coming soon in dev mode', t => { + t.plan(1); + const currentChallenge = { + dashedName: 'current-challenge', + block: 'current-block' + }; + const firstChallenge = { + dashedName: 'first-challenge', + block: 'next-block' + }; + const comingSoon = { + dashedName: 'coming-soon', + block: 'next-block', + isComingSoon: true + }; + const entities = { + challenge: { + [currentChallenge.dashedName]: currentChallenge, + [firstChallenge.dashedName]: firstChallenge, + 'coming-soon': comingSoon + }, + block: { + 'current-block': { + dashedName: 'current-block', + superBlock: 'current-super-block' + }, + 'next-block': { + dashedName: 'next-block', + superBlock: 'current-super-block', + challenges: [ + 'coming-soon', + 'first-challenge' + ] + } + }, + superBlock: { + 'current-super-block': { + dashedName: 'current-super-block', + blocks: [ 'current-block', 'next-block' ] + } + } + }; + t.equal( + getFirstChallengeOfNextBlock( + currentChallenge.dashedName, + entities, + { isDev: true } + ), + comingSoon, + 'getFirstChallengeOfNextBlock returned isComingSoon challenge' + ); + }); + t.test('should skip block if all challenges are coming soon', t => { + t.plan(2); + const currentChallenge = { + dashedName: 'current-challenge', + block: 'current-block' + }; + const firstChallenge = { + dashedName: 'first-challenge', + block: 'next-block' + }; + const comingSoon = { + dashedName: 'coming-soon', + block: 'coming-soon-block', + isComingSoon: true + }; + const comingSoon2 = { + dashedName: 'coming-soon2', + block: 'coming-soon-block', + isComingSoon: true + }; + const entities = { + challenge: { + [currentChallenge.dashedName]: currentChallenge, + [firstChallenge.dashedName]: firstChallenge, + [comingSoon.dashedName]: comingSoon, + [comingSoon2.dashedName]: comingSoon2 + }, + block: { + 'current-block': { + dashedName: 'current-block', + superBlock: 'current-super-block' + }, + 'coming-soon-block': { + dashedName: 'coming-soon-block', + superBlock: 'current-super-block', + challenges: [ + 'coming-soon', + 'coming-soon2' + ] + }, + 'next-block': { + dashedName: 'next-block', + superBlock: 'current-super-block', + challenges: [ + 'first-challenge' + ] + } + }, + superBlock: { + 'current-super-block': { + dashedName: 'current-super-block', + blocks: [ + 'current-block', + 'coming-soon-block', + 'next-block' + ] + } + } + }; + t.notEqual( + getFirstChallengeOfNextBlock(currentChallenge.dashedName, entities), + comingSoon, + 'getFirstChallengeOfNextBlock returned isComingSoon challenge' + ); + t.equal( + getFirstChallengeOfNextBlock(currentChallenge.dashedName, entities), + firstChallenge, + 'getFirstChallengeOfNextBlock did not return the correct challenge' + ); + }); +}); + +test('getFirstChallengeOfNextBlock', t => { + t.plan(10); + t.test('should return falsey if current challenge not found', t => { + t.plan(1); + const entities = { + challenge: {} + }; + t.notOk( + getFirstChallengeOfNextSuperBlock('current-challenge', entities), + ); + }); + t.test('should return falsey if current block not found', t => { + t.plan(1); + const entities = { + challenge: { 'current-challenge': { block: 'current-block' } }, + block: {} + }; + t.notOk( + getFirstChallengeOfNextSuperBlock('current-challenge', entities) + ); + }); + t.test('should return falsey if current superBlock is not found', t => { + t.plan(1); + const entities = { + challenge: { 'current-challenge': { block: 'current-block' } }, + block: { 'current-block': { superBlock: 'current-super-block' } }, + superBlock: {} + }; + t.notOk( + getFirstChallengeOfNextSuperBlock('current-challenge', entities) + ); + }); + t.test('should return falsey when last superBlock', t => { + t.plan(1); + const entities = { + challenge: { 'current-challenge': { block: 'current-block' } }, + block: { 'current-block': { superBlock: 'current-super-block' } }, + superBlock: { + 'current-super-block': { dashedName: 'current-super-block' } + } + }; + const superBlocks = [ 'current-super-block' ]; + t.notOk(getFirstChallengeOfNextSuperBlock( + 'current-challenge', + entities, + superBlocks + )); + }); + t.test('should return falsey when last block of new superblock', t => { + t.plan(1); + const entities = { + challenge: { 'current-challenge': { block: 'current-block' } }, + block: { + 'current-block': { + superBlock: 'current-super-block' + } + }, + superBlock: { + 'current-super-block': { dashedName: 'current-super-block' }, + 'next-super-block': { + dashedName: 'next-super-block', + blocks: [ + 'first-block' + ] + } + } + }; + const superBlocks = [ 'current-super-block', 'next-super-block' ]; + t.notOk(getFirstChallengeOfNextSuperBlock( + 'current-challenge', + entities, + superBlocks + )); + }); + t.test('should return first challenge of next superBlock', t => { + t.plan(1); + const firstChallenge = { + dashedName: 'first-challenge', + block: 'next-block' + }; + const entities = { + challenge: { + 'current-challenge': { block: 'current-block' }, + [firstChallenge.dashedName]: firstChallenge + }, + block: { + 'current-block': { superBlock: 'current-super-block' }, + 'next-block': { + superBlock: 'next-super-block', + challenges: [ 'first-challenge' ] + } + }, + superBlock: { + 'current-super-block': { dashedName: 'current-super-block' }, + 'next-super-block': { + dashedName: 'next-super-block', + blocks: [ 'next-block' ] + } + } + }; + const superBlocks = [ 'current-super-block', 'next-super-block' ]; + t.isEqual( + getFirstChallengeOfNextSuperBlock( + 'current-challenge', + entities, + superBlocks + ), + firstChallenge + ); + }); + t.test('should skip coming soon challenge', t => { + t.plan(1); + const firstChallenge = { + dashedName: 'first-challenge', + block: 'next-block' + }; + const entities = { + challenge: { + 'current-challenge': { block: 'current-block' }, + [firstChallenge.dashedName]: firstChallenge, + 'coming-soon': { + dashedName: 'coming-soon', + block: 'next-block', + isComingSoon: true + } + }, + block: { + 'current-block': { superBlock: 'current-super-block' }, + 'next-block': { + dashedName: 'next-block', + superBlock: 'next-super-block', + challenges: [ 'coming-soon', 'first-challenge' ] + } + }, + superBlock: { + 'current-super-block': { dashedName: 'current-super-block' }, + 'next-super-block': { + dashedName: 'next-super-block', + blocks: [ 'next-block' ] + } + } + }; + const superBlocks = [ + 'current-super-block', + 'next-super-block' + ]; + t.isEqual( + getFirstChallengeOfNextSuperBlock( + 'current-challenge', + entities, + superBlocks + ), + firstChallenge + ); + }); + t.test('should not skip coming soon in dev mode', t => { + t.plan(1); + const firstChallenge = { + dashedName: 'first-challenge', + block: 'next-block' + }; + const comingSoon = { + dashedName: 'coming-soon', + block: 'next-block', + isComingSoon: true + }; + const entities = { + challenge: { + 'current-challenge': { block: 'current-block' }, + [firstChallenge.dashedName]: firstChallenge, + 'coming-soon': comingSoon + }, + block: { + 'current-block': { superBlock: 'current-super-block' }, + 'next-block': { + dashedName: 'next-block', + superBlock: 'next-super-block', + challenges: [ 'coming-soon', 'first-challenge' ] + } + }, + superBlock: { + 'current-super-block': { dashedName: 'current-super-block' }, + 'next-super-block': { + dashedName: 'next-super-block', + blocks: [ 'next-block' ] + } + } + }; + const superBlocks = [ + 'current-super-block', + 'next-super-block' + ]; + t.isEqual( + getFirstChallengeOfNextSuperBlock( + 'current-challenge', + entities, + superBlocks, + { isDev: true } + ), + comingSoon + ); + }); + t.test('should skip coming soon block', t => { + t.plan(1); + const firstChallenge = { + dashedName: 'first-challenge', + block: 'next-block' + }; + const entities = { + challenge: { + 'current-challenge': { block: 'current-block' }, + [firstChallenge.dashedName]: firstChallenge, + 'coming-soon': { + dashedName: 'coming-soon', + block: 'coming-soon-block', + isComingSoon: true + } + }, + block: { + 'current-block': { superBlock: 'current-super-block' }, + 'coming-soon-block': { + dashedName: 'coming-soon-block', + superBlock: 'next-super-block', + challenges: [ + 'coming-soon' + ] + }, + 'next-block': { + dashedName: 'next-block', + superBlock: 'next-super-block', + challenges: [ 'first-challenge' ] + } + }, + superBlock: { + 'current-super-block': { dashedName: 'current-super-block' }, + 'next-super-block': { + dashedName: 'next-super-block', + blocks: [ 'coming-soon-block', 'next-block' ] + } + } + }; + const superBlocks = [ + 'current-super-block', + 'next-super-block' + ]; + t.isEqual( + getFirstChallengeOfNextSuperBlock( + 'current-challenge', + entities, + superBlocks + ), + firstChallenge + ); + }); + t.test('should skip coming soon super block', t => { + t.plan(1); + const firstChallenge = { + dashedName: 'first-challenge', + block: 'next-block' + }; + const entities = { + challenge: { + 'current-challenge': { block: 'current-block' }, + [firstChallenge.dashedName]: firstChallenge, + 'coming-soon': { + dashedName: 'coming-soon', + block: 'coming-soon-block', + isComingSoon: true + } + }, + block: { + 'current-block': { superBlock: 'current-super-block' }, + 'coming-soon-block': { + dashedName: 'coming-soon-block', + superBlock: 'coming-soon-super-block', + challenges: [ + 'coming-soon' + ] + }, + 'next-block': { + superBlock: 'next-super-block', + dashedName: 'next-block', + challenges: [ 'first-challenge' ] + } + }, + superBlock: { + 'current-super-block': { dashedName: 'current-super-block' }, + 'coming-soon-super-block': { + dashedName: 'coming-soon-super-block', + blocks: [ 'coming-soon-block' ] + }, + 'next-super-block': { + dashedName: 'next-super-block', + blocks: [ 'next-block' ] + } + } + }; + const superBlocks = [ + 'current-super-block', + 'coming-soon-super-block', + 'next-super-block' + ]; + t.isEqual( + getFirstChallengeOfNextSuperBlock( + 'current-challenge', + entities, + superBlocks + ), + firstChallenge + ); }); }); diff --git a/common/app/routes/challenges/views/backend/Back-End.jsx b/common/app/routes/challenges/views/backend/Back-End.jsx index fd062e4e14..41c32434f0 100644 --- a/common/app/routes/challenges/views/backend/Back-End.jsx +++ b/common/app/routes/challenges/views/backend/Back-End.jsx @@ -11,14 +11,20 @@ import ChallengeTitle from '../../Challenge-Title.jsx'; import SolutionInput from '../../Solution-Input.jsx'; import TestSuite from '../../Test-Suite.jsx'; import Output from '../../Output.jsx'; -import { submitChallenge, executeChallenge } from '../../redux/actions.js'; -import { challengeSelector } from '../../redux/selectors.js'; +import { + submitChallenge, + executeChallenge, + testsSelector, + outputSelector +} from '../../redux'; import { descriptionRegex } from '../../utils.js'; + import { createFormValidator, isValidURL, makeRequired } from '../../../../utils/form.js'; +import { challengeSelector } from '../../../../redux'; // provided by redux form const reduxFormPropTypes = { @@ -47,15 +53,13 @@ const fieldValidators = { const mapStateToProps = createSelector( challengeSelector, - state => state.challengesApp.output, - state => state.challengesApp.tests, + outputSelector, + testsSelector, ( { - challenge: { - id, - title, - description - } = {} + id, + title, + description }, output, tests @@ -74,7 +78,6 @@ const mapDispatchToActions = { }; export class BackEnd extends PureComponent { - renderDescription(description) { if (!Array.isArray(description)) { return null; diff --git a/common/app/routes/challenges/views/backend/Show.jsx b/common/app/routes/challenges/views/backend/Show.jsx new file mode 100644 index 0000000000..477083c7e1 --- /dev/null +++ b/common/app/routes/challenges/views/backend/Show.jsx @@ -0,0 +1,36 @@ +import React from 'react'; + +import BackEnd from './Back-End.jsx'; +import { types } from '../../redux'; +import Panes from '../../../../Panes'; +import _Map from '../../../../Map'; +import ChildContainer from '../../../../Child-Container.jsx'; + +const propTypes = {}; + +export const panesMap = { + [types.toggleMap]: 'Map', + [types.toggleMain]: 'Main' +}; + +const nameToComponentDef = { + Map: { + Component: _Map, + defaultSize: 25 + }, + Main: { + Component: BackEnd, + defaultSize: 50 + } +}; + +export default function ShowBackEnd() { + return ( + + + + ); +} + +ShowBackEnd.displayName = 'ShowBackEnd'; +ShowBackEnd.propTypes = propTypes; diff --git a/common/app/routes/challenges/views/backend/index.js b/common/app/routes/challenges/views/backend/index.js index 9da7aec1c3..f8a8115a06 100644 --- a/common/app/routes/challenges/views/backend/index.js +++ b/common/app/routes/challenges/views/backend/index.js @@ -1 +1 @@ -export { default } from './Back-End.jsx'; +export { default, panesMap } from './Show.jsx'; diff --git a/common/app/routes/challenges/views/classic/Classic-Modal.jsx b/common/app/routes/challenges/views/classic/Classic-Modal.jsx deleted file mode 100644 index 945d4161c5..0000000000 --- a/common/app/routes/challenges/views/classic/Classic-Modal.jsx +++ /dev/null @@ -1,83 +0,0 @@ -import React, { PropTypes } from 'react'; -import { Button, Modal } from 'react-bootstrap'; -import PureComponent from 'react-pure-render/component'; -import FontAwesome from 'react-fontawesome'; - -import ns from './ns.json'; - -const propTypes = { - close: PropTypes.func, - open: PropTypes.bool.isRequired, - submitChallenge: PropTypes.func.isRequired, - successMessage: PropTypes.string.isRequired -}; - -export default class ClassicModal extends PureComponent { - constructor(...props) { - super(...props); - this.handleKeyDown = this.handleKeyDown.bind(this); - } - - handleKeyDown(e) { - const { open, submitChallenge } = this.props; - if ( - e.keyCode === 13 && - (e.ctrlKey || e.meta) && - open - ) { - e.preventDefault(); - submitChallenge(); - } - } - - render() { - const { - close, - open, - submitChallenge, - successMessage - } = this.props; - return ( - - - { successMessage } - - -
    -
    -
    - -
    -
    -
    -
    - - - -
    - ); - } -} - -ClassicModal.displayName = 'ClassicModal'; -ClassicModal.propTypes = propTypes; diff --git a/common/app/routes/challenges/views/classic/Classic.jsx b/common/app/routes/challenges/views/classic/Classic.jsx deleted file mode 100644 index 4a1e7da1e0..0000000000 --- a/common/app/routes/challenges/views/classic/Classic.jsx +++ /dev/null @@ -1,154 +0,0 @@ -import React, { PropTypes } from 'react'; -import { connect } from 'react-redux'; -import { Row, Col } from 'react-bootstrap'; -import { createSelector } from 'reselect'; -import PureComponent from 'react-pure-render/component'; - -import Editor from './Editor.jsx'; -import SidePanel from './Side-Panel.jsx'; -import Preview from './Preview.jsx'; -import BugModal from '../../Bug-Modal.jsx'; -import ClassicModal from './Classic-Modal.jsx'; -import { challengeSelector } from '../../redux/selectors'; -import { - executeChallenge, - updateFile, - loadCode, - submitChallenge, - closeChallengeModal, - updateSuccessMessage -} from '../../redux/actions'; -import { randomCompliment } from '../../../../utils/get-words'; - -const mapStateToProps = createSelector( - challengeSelector, - state => state.challengesApp.id, - state => state.challengesApp.tests, - state => state.challengesApp.files, - state => state.challengesApp.key, - state => state.challengesApp.isChallengeModalOpen, - state => state.challengesApp.successMessage, - ( - { showPreview, mode }, - id, - tests, - files = {}, - key = '', - isChallengeModalOpen, - successMessage, - ) => ({ - id, - content: files[key] && files[key].contents || '', - file: files[key], - showPreview, - mode, - tests, - isChallengeModalOpen, - successMessage - }) -); - -const bindableActions = { - executeChallenge, - updateFile, - loadCode, - submitChallenge, - closeChallengeModal, - updateSuccessMessage -}; - -const propTypes = { - closeChallengeModal: PropTypes.func, - content: PropTypes.string, - executeChallenge: PropTypes.func, - file: PropTypes.object, - id: PropTypes.string, - isChallengeModalOpen: PropTypes.bool, - loadCode: PropTypes.func, - mode: PropTypes.string, - showPreview: PropTypes.bool, - submitChallenge: PropTypes.func, - successMessage: PropTypes.string, - updateFile: PropTypes.func, - updateSuccessMessage: PropTypes.func -}; - -export class Challenge extends PureComponent { - - componentDidMount() { - this.props.loadCode(); - this.props.updateSuccessMessage(randomCompliment()); - window.scrollTo(0, 0); - } - - componentWillReceiveProps(nextProps) { - if (this.props.id !== nextProps.id) { - this.props.loadCode(); - this.props.updateSuccessMessage(randomCompliment()); - } - } - - renderPreview(showPreview) { - if (!showPreview) { - return null; - } - return ( - - - - ); - } - - render() { - const { - content, - updateFile, - file, - mode, - showPreview, - executeChallenge, - submitChallenge, - successMessage, - isChallengeModalOpen, - closeChallengeModal - } = this.props; - - return ( - - - - - - updateFile(content, file) } - /> - - { this.renderPreview(showPreview) } - - - - ); - } -} - -Challenge.displayName = 'Challenge'; -Challenge.propTypes = propTypes; - -export default connect(mapStateToProps, bindableActions)(Challenge); diff --git a/common/app/routes/challenges/views/classic/Editor.jsx b/common/app/routes/challenges/views/classic/Editor.jsx index 3424d5a547..1484eca552 100644 --- a/common/app/routes/challenges/views/classic/Editor.jsx +++ b/common/app/routes/challenges/views/classic/Editor.jsx @@ -1,54 +1,67 @@ -import { Subject } from 'rx'; -import React, { PropTypes } from 'react'; +import React, { PureComponent, PropTypes } from 'react'; +import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import Codemirror from 'react-codemirror'; import NoSSR from 'react-no-ssr'; -import PureComponent from 'react-pure-render/component'; import MouseTrap from 'mousetrap'; import ns from './ns.json'; import CodeMirrorSkeleton from '../../Code-Mirror-Skeleton.jsx'; +import { + executeChallenge, + classicEditorUpdated, -const editorDebounceTimeout = 750; + challengeMetaSelector, + filesSelector, + keySelector +} from '../../redux'; const options = { - lint: {esversion: 6}, + lint: { esversion: 6 }, lineNumbers: true, mode: 'javascript', - theme: 'monokai', + theme: 'freecodecamp', runnable: true, matchBrackets: true, autoCloseBrackets: true, scrollbarStyle: 'null', lineWrapping: true, - gutters: ['CodeMirror-lint-markers'] + gutters: [ 'CodeMirror-lint-markers' ] }; -const defaultProps = { - content: '// Happy Coding!', - mode: 'javascript' +const mapStateToProps = createSelector( + filesSelector, + challengeMetaSelector, + keySelector, + ( + files = {}, + { mode = 'javascript'}, + key + ) => ({ + content: files[key] && files[key].contents || '// Happy Coding!', + file: files[key], + mode + }) +); + +const mapDispatchToProps = { + executeChallenge, + classicEditorUpdated }; const propTypes = { + classicEditorUpdated: PropTypes.func.isRequired, content: PropTypes.string, - executeChallenge: PropTypes.func, - mode: PropTypes.string, - updateFile: PropTypes.func + executeChallenge: PropTypes.func.isRequired, + mode: PropTypes.string }; -export default class Editor extends PureComponent { - constructor(...args) { - super(...args); - this._editorContent$ = new Subject(); - this.handleChange = this.handleChange.bind(this); - } - +export class Editor extends PureComponent { createOptions = createSelector( - state => state.options, state => state.executeChallenge, state => state.mode, - (options, executeChallenge, mode) => ({ + (executeChallenge, mode) => ({ ...options, mode, extraKeys: { @@ -88,46 +101,28 @@ export default class Editor extends PureComponent { ); componentDidMount() { - const { updateFile = (() => {}) } = this.props; - this._subscription = this._editorContent$ - .debounce(editorDebounceTimeout) - .distinctUntilChanged() - .subscribe( - updateFile, - err => { throw err; } - ); - MouseTrap.bind('e', () => { this.refs.editor.focus(); }, 'keyup'); } componentWillUnmount() { - if (this._subscription) { - this._subscription.dispose(); - this._subscription = null; - } MouseTrap.unbind('e', 'keyup'); } - handleChange(value) { - if (this._subscription) { - this._editorContent$.onNext(value); - } - } - render() { const { content, executeChallenge, + classicEditorUpdated, mode } = this.props; return (
    }> @@ -137,6 +132,10 @@ export default class Editor extends PureComponent { } } -Editor.defaultProps = defaultProps; Editor.displayName = 'Editor'; Editor.propTypes = propTypes; + +export default connect( + mapStateToProps, + mapDispatchToProps +)(Editor); diff --git a/common/app/routes/challenges/views/classic/Show.jsx b/common/app/routes/challenges/views/classic/Show.jsx new file mode 100644 index 0000000000..09b9bfc409 --- /dev/null +++ b/common/app/routes/challenges/views/classic/Show.jsx @@ -0,0 +1,44 @@ +import React from 'react'; + +import SidePanel from './Side-Panel.jsx'; +import Editor from './Editor.jsx'; +import Preview from './Preview.jsx'; +import { types } from '../../redux'; +import Panes from '../../../../Panes'; +import _Map from '../../../../Map'; +import ChildContainer from '../../../../Child-Container.jsx'; + +const propTypes = {}; + +export const panesMap = { + [types.toggleMap]: 'Map', + [types.toggleSidePanel]: 'Side Panel', + [types.toggleClassicEditor]: 'Editor', + [types.togglePreview]: 'Preview' +}; + +const nameToComponent = { + Map: { + Component: _Map + }, + 'Side Panel': { + Component: SidePanel + }, + Editor: { + Component: Editor + }, + Preview: { + Component: Preview + } +}; + +export default function ShowClassic() { + return ( + + + + ); +} + +ShowClassic.displayName = 'ShowClassic'; +ShowClassic.propTypes = propTypes; diff --git a/common/app/routes/challenges/views/classic/Side-Panel.jsx b/common/app/routes/challenges/views/classic/Side-Panel.jsx index 3d928a85b2..34532be1ca 100644 --- a/common/app/routes/challenges/views/classic/Side-Panel.jsx +++ b/common/app/routes/challenges/views/classic/Side-Panel.jsx @@ -7,19 +7,27 @@ import { Col, Row } from 'react-bootstrap'; import ns from './ns.json'; +import ToolPanel from './Tool-Panel.jsx'; import ChallengeTitle from '../../Challenge-Title.jsx'; import TestSuite from '../../Test-Suite.jsx'; import Output from '../../Output.jsx'; -import ToolPanel from './Tool-Panel.jsx'; -import { challengeSelector } from '../../redux/selectors'; import { openBugModal, updateHint, executeChallenge, - unlockUntrustedCode -} from '../../redux/actions'; + unlockUntrustedCode, + + challengeMetaSelector, + testsSelector, + outputSelector, + hintIndexSelector, + codeLockedSelector, + chatRoomSelector +} from '../../redux'; + import { descriptionRegex } from '../../utils'; -import { makeToast } from '../../../../toasts/redux/actions'; +import { challengeSelector } from '../../../../redux'; +import { makeToast } from '../../../../Toasts/redux'; const mapDispatchToProps = { makeToast, @@ -30,19 +38,18 @@ const mapDispatchToProps = { }; const mapStateToProps = createSelector( challengeSelector, - state => state.challengesApp.tests, - state => state.challengesApp.output, - state => state.challengesApp.hintIndex, - state => state.challengesApp.isCodeLocked, - state => state.challengesApp.helpChatRoom, + challengeMetaSelector, + testsSelector, + outputSelector, + hintIndexSelector, + codeLockedSelector, + chatRoomSelector, ( { - challenge: { - description, - hints = [] - } = {}, - title + description, + hints = [] }, + { title }, tests, output, hintIndex, diff --git a/common/app/routes/challenges/views/classic/classic.less b/common/app/routes/challenges/views/classic/classic.less index de511f9f51..272c1a42ec 100644 --- a/common/app/routes/challenges/views/classic/classic.less +++ b/common/app/routes/challenges/views/classic/classic.less @@ -1,21 +1,16 @@ // should match filename and ./ns.json @ns: classic; -// make the height no larger than (window - navbar) -.max-element-height(up-to) { - max-height: e(%('calc(100vh - %s)', @navbar-total-height)); - overflow-x: hidden; - overflow-y: auto; -} - -.max-element-height(always) { - height: e(%('calc(100vh - %s)', @navbar-total-height)); +// 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}-instructions-panel { - .max-element-height(always); + .max-element-height(); padding-bottom: 10px; padding-left: 5px; padding-right: 5px; @@ -90,56 +85,17 @@ } .@{ns}-editor { - .max-element-height(always); + .max-element-height(); width: 100%; } .@{ns}-preview { - .max-element-height(always); + .max-element-height(); width: 100%; } .@{ns}-preview-frame { - border: 1px solid gray; - border-radius: 5px; - color: @gray-lighter; - height: 99%; - overflow: hidden; + .max-element-height(); + border: none; width: 100%; } - -.@{ns}-success-modal { - display: flex; - flex-direction: column; - justify-content: center; - height: 50vh; - - .modal-header { - background-color: @brand-primary; - margin-bottom: 0; - - .close { - color: #eee; - font-size: 4rem; - opacity: 0.6; - transition: all 300ms ease-out; - margin-top: 0; - padding-left: 0; - - &:hover { - opacity: 1; - } - } - } - - .modal-body { - padding: 35px; - display: flex; - flex-direction: column; - justify-content: center; - - .fa { - margin-right: 0; - } - } -} diff --git a/common/app/routes/challenges/views/classic/index.js b/common/app/routes/challenges/views/classic/index.js index 479ed82803..f8a8115a06 100644 --- a/common/app/routes/challenges/views/classic/index.js +++ b/common/app/routes/challenges/views/classic/index.js @@ -1 +1 @@ -export default from './Classic.jsx'; +export { default, panesMap } from './Show.jsx'; diff --git a/common/app/routes/challenges/views/map/Header.jsx b/common/app/routes/challenges/views/map/Header.jsx deleted file mode 100644 index 712189bdda..0000000000 --- a/common/app/routes/challenges/views/map/Header.jsx +++ /dev/null @@ -1,120 +0,0 @@ -import React, { PropTypes } from 'react'; -import { connect } from 'react-redux'; -import PureComponent from 'react-pure-render/component'; -import { InputGroup, FormControl, Button, Row } from 'react-bootstrap'; -import classnames from 'classnames'; -import { - clearFilter, - updateFilter, - collapseAll, - expandAll -} from '../../redux/actions'; - -const ESC = 27; -const clearIcon = ; -const searchIcon = ; -const bindableActions = { - clearFilter, - updateFilter, - collapseAll, - expandAll -}; -const mapStateToProps = state => ({ - isAllCollapsed: state.challengesApp.mapUi.isAllCollapsed, - filter: state.challengesApp.filter -}); -const propTypes = { - clearFilter: PropTypes.func, - collapseAll: PropTypes.func, - expandAll: PropTypes.func, - filter: PropTypes.string, - isAllCollapsed: PropTypes.bool, - updateFilter: PropTypes.func -}; - -export class Header extends PureComponent { - constructor(...props) { - super(...props); - this.handleKeyDown = this.handleKeyDown.bind(this); - this.handleClearButton = this.handleClearButton.bind(this); - } - - handleKeyDown(e) { - if (e.keyCode === ESC) { - e.preventDefault(); - this.props.clearFilter(); - } - } - - handleClearButton(e) { - e.preventDefault(); - this.props.clearFilter(); - } - - renderSearchAddon(filter) { - if (!filter) { - return searchIcon; - } - return { clearIcon }; - } - - render() { - const { - filter, - updateFilter, - collapseAll, - expandAll, - isAllCollapsed - } = this.props; - const inputClass = classnames({ - 'map-filter': true, - filled: !!filter - }); - const buttonClass = classnames({ - 'center-block': true, - active: isAllCollapsed - }); - const buttonCopy = isAllCollapsed ? - 'Expand all challenges' : - 'Hide all challenges'; - return ( -
    -
    -

    Challenges required for certifications are marked with a *

    - - - - - - - - { this.renderSearchAddon(filter) } - - - -
    -
    -
    - ); - } -} - -Header.displayName = 'MapHeader'; -Header.propTypes = propTypes; - -export default connect(mapStateToProps, bindableActions)(Header); diff --git a/common/app/routes/challenges/views/map/Map.jsx b/common/app/routes/challenges/views/map/Map.jsx deleted file mode 100644 index e40701e45c..0000000000 --- a/common/app/routes/challenges/views/map/Map.jsx +++ /dev/null @@ -1,76 +0,0 @@ -import React, { PropTypes } from 'react'; -import { compose } from 'redux'; -import { contain } from 'redux-epic'; -import { connect } from 'react-redux'; -import PureComponent from 'react-pure-render/component'; -import { Col, Row } from 'react-bootstrap'; - -import MapHeader from './Header.jsx'; -import SuperBlock from './Super-Block.jsx'; -import { fetchChallenges } from '../../redux/actions'; -import { updateTitle } from '../../../../redux/actions'; - -const mapStateToProps = state => ({ - superBlocks: state.challengesApp.superBlocks -}); -const mapDispatchToProps = { fetchChallenges, updateTitle }; -const fetchOptions = { - fetchAction: 'fetchChallenges', - isPrimed({ superBlocks }) { - return Array.isArray(superBlocks) && superBlocks.length > 1; - } -}; -const propTypes = { - fetchChallenges: PropTypes.func.isRequired, - params: PropTypes.object, - superBlocks: PropTypes.array, - updateTitle: PropTypes.func.isRequired -}; - -export class ShowMap extends PureComponent { - componentWillMount() { - // if no params then map is open in drawer - // do not update title - if (!this.props.params) { - return; - } - this.props.updateTitle( - 'A Map to Learn to Code and Become a Software Engineer' - ); - } - - renderSuperBlocks(superBlocks) { - if (!Array.isArray(superBlocks) || !superBlocks.length) { - return
    No Super Blocks
    ; - } - return superBlocks.map(dashedName => ( - - )); - } - - render() { - const { superBlocks } = this.props; - return ( - - - -
    - { this.renderSuperBlocks(superBlocks) } -
    -
    - - - ); - } -} - -ShowMap.displayName = 'Map'; -ShowMap.propTypes = propTypes; - -export default compose( - connect(mapStateToProps, mapDispatchToProps), - contain(fetchOptions) -)(ShowMap); diff --git a/common/app/routes/challenges/views/project/Forms.jsx b/common/app/routes/challenges/views/project/Forms.jsx index f0505bfe34..5138fdccaa 100644 --- a/common/app/routes/challenges/views/project/Forms.jsx +++ b/common/app/routes/challenges/views/project/Forms.jsx @@ -6,14 +6,15 @@ import { FormControl } from 'react-bootstrap'; +import { showProjectSubmit } from './redux'; import SolutionInput from '../../Solution-Input.jsx'; +import { submitChallenge } from '../../redux'; import { isValidURL, makeRequired, createFormValidator, getValidationState } from '../../../../utils/form'; -import { submitChallenge, showProjectSubmit } from '../../redux/actions'; const propTypes = { fields: PropTypes.object, diff --git a/common/app/routes/challenges/views/project/Project.jsx b/common/app/routes/challenges/views/project/Project.jsx index e37071ec11..1278903c6c 100644 --- a/common/app/routes/challenges/views/project/Project.jsx +++ b/common/app/routes/challenges/views/project/Project.jsx @@ -2,25 +2,25 @@ import React, { PropTypes } from 'react'; import { createSelector } from 'reselect'; import { connect } from 'react-redux'; import PureComponent from 'react-pure-render/component'; -import { Col, Row, Image } from 'react-bootstrap'; +import { Col, Image } from 'react-bootstrap'; import SidePanel from './Side-Panel.jsx'; import ToolPanel from './Tool-Panel.jsx'; import BugModal from '../../Bug-Modal.jsx'; -import { challengeSelector } from '../../redux/selectors'; +import { challengeMetaSelector } from '../../redux'; +import { challengeSelector } from '../../../../redux'; const mapStateToProps = createSelector( challengeSelector, + challengeMetaSelector, ( { - challenge: { - id, - description, - image - } = {}, - title - } + id, + description, + image + }, + { title } ) => ({ id, image, @@ -47,29 +47,25 @@ export class Project extends PureComponent { } = this.props; const imageURL = '//i.imgur.com/' + image + '.png'; return ( - - - - - - -
    - -
    - - -
    + + + +
    + +
    + + ); } } diff --git a/common/app/routes/challenges/views/project/Show.jsx b/common/app/routes/challenges/views/project/Show.jsx new file mode 100644 index 0000000000..79fc4c204d --- /dev/null +++ b/common/app/routes/challenges/views/project/Show.jsx @@ -0,0 +1,34 @@ +import React from 'react'; + +import Main from './Project.jsx'; +import { types } from '../../redux'; +import _Map from '../../../../Map'; +import ChildContainer from '../../../../Child-Container.jsx'; +import Panes from '../../../../Panes'; + +const propTypes = {}; + +export const panesMap = { + [types.toggleMap]: 'Map', + [types.toggleMain]: 'Main' +}; + +const nameToComponent = { + Map: { + Component: _Map + }, + Main: { + Component: Main + } +}; + +export default function ShowProject() { + return ( + + + + ); +} + +ShowProject.displayName = 'ShowProject'; +ShowProject.propTypes = propTypes; diff --git a/common/app/routes/challenges/views/project/Tool-Panel.jsx b/common/app/routes/challenges/views/project/Tool-Panel.jsx index cf0c941a15..68b25fb556 100644 --- a/common/app/routes/challenges/views/project/Tool-Panel.jsx +++ b/common/app/routes/challenges/views/project/Tool-Panel.jsx @@ -9,8 +9,19 @@ import { BackEndForm } from './Forms.jsx'; -import { submitChallenge, openBugModal } from '../../redux/actions'; -import { challengeSelector } from '../../redux/selectors'; +import { submittingSelector } from './redux'; + +import { + submitChallenge, + openBugModal, + + chatRoomSelector +} from '../../redux'; + +import { + signInLoadingSelector, + challengeSelector +} from '../../../../redux'; import { simpleProject, frontEndProject @@ -31,16 +42,16 @@ const mapDispatchToProps = { }; const mapStateToProps = createSelector( challengeSelector, - state => state.app.isSignedIn, - state => state.challengesApp.isSubmitting, - state => state.challengesApp.helpChatRoom, + signInLoadingSelector, + submittingSelector, + chatRoomSelector, ( - { challenge: { challengeType = simpleProject } }, - isSignedIn, + { challengeType = simpleProject }, + showLoading, isSubmitting, helpChatRoom, ) => ({ - isSignedIn, + isSignedIn: !showLoading, isSubmitting, helpChatRoom, isSimple: challengeType === simpleProject, diff --git a/common/app/routes/challenges/views/project/index.js b/common/app/routes/challenges/views/project/index.js index 6784471995..f8a8115a06 100644 --- a/common/app/routes/challenges/views/project/index.js +++ b/common/app/routes/challenges/views/project/index.js @@ -1 +1 @@ -export default from './Project.jsx'; +export { default, panesMap } from './Show.jsx'; diff --git a/common/app/routes/challenges/views/project/ns.json b/common/app/routes/challenges/views/project/ns.json new file mode 100644 index 0000000000..a8138abf72 --- /dev/null +++ b/common/app/routes/challenges/views/project/ns.json @@ -0,0 +1 @@ +"project" diff --git a/common/app/routes/challenges/views/project/redux/index.js b/common/app/routes/challenges/views/project/redux/index.js new file mode 100644 index 0000000000..67bde420a6 --- /dev/null +++ b/common/app/routes/challenges/views/project/redux/index.js @@ -0,0 +1,27 @@ +import { createTypes } from 'redux-create-types'; +import { createAction, handleActions } from 'redux-actions'; +import ns from '../ns.json'; + +export const types = createTypes([ + 'showProjectSubmit' +], ns); + +export const showProjectSubmit = createAction(types.showProjectSubmit); + +const initialState = { + // project is ready to submit + isSubmitting: false +}; +export const submittingSelector = state => state[ns].isSubmitting; + +export default function createReducer() { + const reducer = handleActions({ + [types.showProjectSubmit]: state => ({ + ...state, + isSubmitting: true + }) + }, initialState); + + reducer.toString = () => ns; + return [ reducer ]; +} diff --git a/common/app/routes/challenges/redux/project-normalizer.js b/common/app/routes/challenges/views/project/redux/project-normalizer.js similarity index 73% rename from common/app/routes/challenges/redux/project-normalizer.js rename to common/app/routes/challenges/views/project/redux/project-normalizer.js index a7c69981dc..4cd8b1ae31 100644 --- a/common/app/routes/challenges/redux/project-normalizer.js +++ b/common/app/routes/challenges/views/project/redux/project-normalizer.js @@ -1,4 +1,4 @@ -import { callIfDefined, formatUrl } from '../../../utils/form'; +import { callIfDefined, formatUrl } from '../../../../../utils/form'; export default { NewFrontEndProject: { diff --git a/common/app/routes/challenges/views/step/Show.jsx b/common/app/routes/challenges/views/step/Show.jsx new file mode 100644 index 0000000000..81425940e2 --- /dev/null +++ b/common/app/routes/challenges/views/step/Show.jsx @@ -0,0 +1,33 @@ +import React from 'react'; + +import Step from './Step.jsx'; +import { types } from '../../redux'; +import Panes from '../../../../Panes'; +import _Map from '../../../../Map'; +import ChildContainer from '../../../../Child-Container.jsx'; + +const propTypes = {}; +export const panesMap = { + [types.toggleMap]: 'Map', + [types.toggleStep]: 'Step' +}; + +const nameToComponent = { + Map: { + Component: _Map + }, + Step: { + Component: Step + } +}; + +export default function ShowStep() { + return ( + + + + ); +} + +ShowStep.displayName = 'ShowStep'; +ShowStep.propTypes = propTypes; diff --git a/common/app/routes/challenges/views/step/Step.jsx b/common/app/routes/challenges/views/step/Step.jsx index a507bf800e..44959679f7 100644 --- a/common/app/routes/challenges/views/step/Step.jsx +++ b/common/app/routes/challenges/views/step/Step.jsx @@ -1,33 +1,36 @@ -import React, { PropTypes } from 'react'; -import classnames from 'classnames'; +import React, { PropTypes, PureComponent } from 'react'; +import { bindActionCreators } from 'redux'; import { connect } from 'react-redux'; +import classnames from 'classnames'; import { createSelector } from 'reselect'; -import PureComponent from 'react-pure-render/component'; import LightBox from 'react-images'; +import { Button, Col, Image, Row } from 'react-bootstrap'; import ns from './ns.json'; import { closeLightBoxImage, completeAction, - openLightBoxImage, + clickOnImage, stepBackward, stepForward, - submitChallenge, - updateUnlockedSteps -} from '../../redux/actions'; -import { challengeSelector } from '../../redux/selectors'; -import { Button, Col, Image, Row } from 'react-bootstrap'; + updateUnlockedSteps, + + currentIndexSelector, + actionCompletedSelector, + previousIndexSelector, + lightBoxSelector +} from './redux'; +import { submitChallenge } from '../../redux'; +import { challengeSelector } from '../../../../redux'; const mapStateToProps = createSelector( challengeSelector, - state => state.challengesApp.currentIndex, - state => state.challengesApp.previousIndex, - state => state.challengesApp.isActionCompleted, - state => state.challengesApp.isLightBoxOpen, + currentIndexSelector, + previousIndexSelector, + actionCompletedSelector, + lightBoxSelector, ( - { - challenge: { description = [] } - }, + { description = [] }, currentIndex, previousIndex, isActionCompleted, @@ -43,17 +46,27 @@ const mapStateToProps = createSelector( }) ); -const dispatchActions = { - closeLightBoxImage, - completeAction, - openLightBoxImage, - stepBackward, - stepForward, - submitChallenge, - updateUnlockedSteps -}; +function mapDispatchToProps(dispatch) { + const dispatchers = bindActionCreators({ + closeLightBoxImage, + completeAction, + stepBackward, + stepForward, + submitChallenge, + updateUnlockedSteps + }, dispatch); + dispatchers.clickOnImage = e => { + if (!(e.ctrlKey || e.metaKey)) { + e.preventDefault(); + return dispatch(clickOnImage()); + } + return null; + }; + return () => dispatchers; +} const propTypes = { + clickOnImage: PropTypes.func.isRequired, closeLightBoxImage: PropTypes.func.isRequired, completeAction: PropTypes.func.isRequired, currentIndex: PropTypes.number, @@ -61,7 +74,6 @@ const propTypes = { isLastStep: PropTypes.bool, isLightBoxOpen: PropTypes.bool, numOfSteps: PropTypes.number, - openLightBoxImage: PropTypes.func.isRequired, step: PropTypes.array, stepBackward: PropTypes.func, stepForward: PropTypes.func, @@ -71,18 +83,6 @@ const propTypes = { }; export class StepChallenge extends PureComponent { - constructor(...args) { - super(...args); - this.handleLightBoxOpen = this.handleLightBoxOpen.bind(this); - } - - handleLightBoxOpen(e) { - if (!(e.ctrlKey || e.metaKey)) { - e.preventDefault(); - this.props.openLightBoxImage(); - } - } - componentWillMount() { const { updateUnlockedSteps } = this.props; updateUnlockedSteps([]); @@ -167,14 +167,15 @@ export class StepChallenge extends PureComponent { } renderStep({ - step, - currentIndex, - numOfSteps, - isActionCompleted, + clickOnImage, completeAction, + currentIndex, + isActionCompleted, isLastStep, - stepForward, - stepBackward + numOfSteps, + step, + stepBackward, + stepForward }) { if (!Array.isArray(step)) { return null; @@ -184,7 +185,7 @@ export class StepChallenge extends PureComponent {
    ( + return steps.map(([ imgUrl, imgAlt ]) => (
    {
    @@ -284,4 +285,7 @@ export class StepChallenge extends PureComponent { StepChallenge.displayName = 'StepChallenge'; StepChallenge.propTypes = propTypes; -export default connect(mapStateToProps, dispatchActions)(StepChallenge); +export default connect( + mapStateToProps, + mapDispatchToProps +)(StepChallenge); diff --git a/common/app/routes/challenges/views/step/index.js b/common/app/routes/challenges/views/step/index.js index 5fc4e2aca9..f8a8115a06 100644 --- a/common/app/routes/challenges/views/step/index.js +++ b/common/app/routes/challenges/views/step/index.js @@ -1 +1 @@ -export default from './Step.jsx'; +export { default, panesMap } from './Show.jsx'; diff --git a/common/app/routes/challenges/views/step/redux/index.js b/common/app/routes/challenges/views/step/redux/index.js new file mode 100644 index 0000000000..b744c9d22d --- /dev/null +++ b/common/app/routes/challenges/views/step/redux/index.js @@ -0,0 +1,92 @@ +import { createTypes } from 'redux-create-types'; +import { createAction, handleActions } from 'redux-actions'; +import noop from 'lodash/noop'; + +import stepChallengeEpic from './step-challenge-epic.js'; +import ns from '../ns.json'; +import { types as challenges } from '../../../redux'; + +export const epics = [ + stepChallengeEpic +]; + +export const types = createTypes([ + 'stepForward', + 'stepBackward', + 'goToStep', + 'completeAction', + 'clickOnImage', + 'closeLightBoxImage', + 'updateUnlockedSteps' +], ns); + +export const stepForward = createAction( + types.stepForward, + noop +); +export const stepBackward = createAction( + types.stepBackward, + noop +); +export const goToStep = createAction( + types.goToStep, + (step, isUnlocked) => ({ step, isUnlocked }) +); +export const completeAction = createAction( + types.completeAction, + noop +); +export const updateUnlockedSteps = createAction(types.updateUnlockedSteps); +export const clickOnImage = createAction(types.clickOnImage); +export const closeLightBoxImage = createAction(types.closeLightBoxImage); + +const initialState = { + // step index tracing + currentIndex: 0, + previousIndex: -1, + // step action + isActionCompleted: false, + isLightBoxOpen: false, + unlockedSteps: [] +}; + +export const getNS = state => state[ns]; +export const currentIndexSelector = state => getNS(state).currentIndex; +export const previousIndexSelector = state => getNS(state).previousIndex; +export const unlockedStepsSelector = state => getNS(state).unlockedSteps; +export const lightBoxSelector = state => getNS(state).isLightBoxOpen; +export const actionCompletedSelector = state => getNS(state).isActionCompleted; + +export default function createReducers() { + const reducer = handleActions({ + [challenges.challengeUpdated]: () => { + console.log('updating step ui'); + return initialState; + }, + [types.goToStep]: (state, { payload: { step = 0, isUnlocked }}) => ({ + ...state, + currentIndex: step, + previousIndex: state.currentIndex, + isActionCompleted: isUnlocked + }), + [types.completeAction]: state => ({ + ...state, + isActionCompleted: true + }), + [types.updateUnlockedSteps]: (state, { payload }) => ({ + ...state, + unlockedSteps: payload + }), + [types.clickOnImage]: state => ({ + ...state, + isLightBoxOpen: true + }), + [types.closeLightBoxImage]: state => ({ + ...state, + isLightBoxOpen: false + }) + }, initialState); + + reducer.toString = () => ns; + return [ reducer ]; +} diff --git a/common/app/routes/challenges/redux/step-challenge-epic.js b/common/app/routes/challenges/views/step/redux/step-challenge-epic.js similarity index 62% rename from common/app/routes/challenges/redux/step-challenge-epic.js rename to common/app/routes/challenges/views/step/redux/step-challenge-epic.js index 5d94b720d1..af5cb9e71d 100644 --- a/common/app/routes/challenges/redux/step-challenge-epic.js +++ b/common/app/routes/challenges/views/step/redux/step-challenge-epic.js @@ -1,7 +1,14 @@ -import types from './types'; -import { goToStep, submitChallenge, updateUnlockedSteps } from './actions'; -import { challengeSelector } from './selectors'; -import getActionsOfType from '../../../../utils/get-actions-of-type'; +import { ofType } from 'redux-epic'; +import { + types, + goToStep, + updateUnlockedSteps, + + unlockedStepsSelector, + currentIndexSelector +} from './'; +import { submitChallenge } from '../../../redux'; +import { challengeSelector } from '../../../../../redux'; function unlockStep(step, unlockedSteps) { if (!step) { @@ -12,17 +19,17 @@ function unlockStep(step, unlockedSteps) { return updateUnlockedSteps(updatedSteps); } -export default function stepChallengeEpic(actions, getState) { - return getActionsOfType( - actions, +export default function stepChallengeEpic(actions, { getState }) { + return actions::ofType( types.stepForward, types.stepBackward, types.completeAction ) .map(({ type }) => { const state = getState(); - const { challenge: { description = [] } } = challengeSelector(state); - const { challengesApp: { currentIndex, unlockedSteps } } = state; + const { description = [] } = challengeSelector(state); + const currentIndex = currentIndexSelector(state); + const unlockedSteps = unlockedStepsSelector(state); const numOfSteps = description.length; const stepFwd = currentIndex + 1; const stepBwd = currentIndex - 1; @@ -40,5 +47,6 @@ export default function stepChallengeEpic(actions, getState) { return goToStep(stepBwd, !!unlockedSteps[stepBwd]); } return null; - }); + }) + .filter(Boolean); } diff --git a/common/app/routes/challenges/redux/step-challenge-epic.test.js b/common/app/routes/challenges/views/step/redux/step-challenge-epic.test.js similarity index 85% rename from common/app/routes/challenges/redux/step-challenge-epic.test.js rename to common/app/routes/challenges/views/step/redux/step-challenge-epic.test.js index cbc9dcc0d8..3aaf04aee2 100644 --- a/common/app/routes/challenges/redux/step-challenge-epic.test.js +++ b/common/app/routes/challenges/views/step/redux/step-challenge-epic.test.js @@ -2,13 +2,18 @@ import { Observable, config } from 'rx'; import test from 'tape'; import proxy from 'proxyquire'; import sinon from 'sinon'; -import types from './types'; + +import ns from '../ns.json'; +// import challenges.redux to get around +// circular dependency +import { types as app } from '../../../redux'; +import { types } from './'; config.longStackSupport = true; const challengeSelectorStub = {}; const stepChallengeEpic = proxy( './step-challenge-epic', - { './selectors': challengeSelectorStub } + { '../../../../../redux': challengeSelectorStub } ); const file = 'common/app/routes/challenges/redux/step-challenge-epic'; @@ -33,7 +38,7 @@ test(file, function(t) { t.test('steps back', t => { const actions = Observable.of({ type: types.stepBackward }); const state = { - challengesApp: { + [ns]: { currentIndex: 1, unlockedSteps: [ true, undefined ] // eslint-disable-line no-undefined } @@ -42,12 +47,10 @@ test(file, function(t) { challengeSelectorStub.challengeSelector = sinon.spy(_state => { t.assert(_state === state, 'challenge selector not called with state'); return { - challenge: { - description: new Array(2) - } + description: new Array(2) }; }); - stepChallengeEpic(actions, () => state) + stepChallengeEpic(actions, { getState: () => state }) .subscribe( onNextSpy, e => { @@ -73,7 +76,7 @@ test(file, function(t) { t.test('steps forward', t => { const actions = Observable.of({ type: types.stepForward }); const state = { - challengesApp: { + [ns]: { currentIndex: 0, unlockedSteps: [] } @@ -82,12 +85,10 @@ test(file, function(t) { challengeSelectorStub.challengeSelector = sinon.spy(_state => { t.assert(_state === state, 'challenge selector not called with state'); return { - challenge: { - description: new Array(2) - } + description: new Array(2) }; }); - stepChallengeEpic(actions, () => state) + stepChallengeEpic(actions, { getState: () => state }) .subscribe( onNextSpy, e => { @@ -112,17 +113,15 @@ test(file, function(t) { }); t.test('submits on last step forward', t => { const actions = Observable.of({ type: types.stepForward }); - const state = { challengesApp: { currentIndex: 1 } }; + const state = { [ns]: { currentIndex: 1 } }; const onNextSpy = sinon.spy(); challengeSelectorStub.challengeSelector = sinon.spy(_state => { t.assert(_state === state, 'challenge selector not called with state'); return { - challenge: { - description: new Array(2) - } + description: new Array(2) }; }); - stepChallengeEpic(actions, () => state) + stepChallengeEpic(actions, { getState: () => state }) .subscribe( onNextSpy, e => { @@ -135,7 +134,7 @@ test(file, function(t) { ); t.assert( onNextSpy.calledWithMatch({ - type: types.submitChallenge + type: app.submitChallenge }), 'Epic did not return the expected action' ); diff --git a/common/app/routes/challenges/views/video/Lecture.jsx b/common/app/routes/challenges/views/video/Lecture.jsx deleted file mode 100644 index 388f4d9b5c..0000000000 --- a/common/app/routes/challenges/views/video/Lecture.jsx +++ /dev/null @@ -1,112 +0,0 @@ -import React, { PropTypes } from 'react'; -import { connect } from 'react-redux'; -import { Button, Col, Row } from 'react-bootstrap'; -import Youtube from 'react-youtube'; -import { createSelector } from 'reselect'; -import debug from 'debug'; - -import { toggleQuestionView } from '../../redux/actions'; -import { challengeSelector } from '../../redux/selectors'; - -const log = debug('fcc:videos'); - -const mapStateToProps = createSelector( - challengeSelector, - ({ - challenge: { - id = 'foo', - dashedName, - description, - challengeSeed: [ videoId ] = [ '1' ] - } - }) => ({ - id, - videoId, - dashedName, - description - }) -); - -const embedOpts = { - width: '853', - height: '480' -}; -const propTypes = { - dashedName: PropTypes.string, - description: PropTypes.array, - id: PropTypes.string, - toggleQuestionView: PropTypes.func, - videoId: PropTypes.string -}; - -export class Lecture extends React.Component { - shouldComponentUpdate(nextProps) { - const { props } = this; - return nextProps.id !== props.id; - } - - handleError: log; - - renderTranscript(transcript, dashedName) { - return transcript.map((line, index) => ( -

    - )); - } - - render() { - const { - id, - videoId, - description = [], - toggleQuestionView - } = this.props; - - const dashedName = 'foo'; - - return ( - - - - - - -

    -
    - { this.renderTranscript(description, dashedName) } -
    - -
    - - - - ); - } -} - -Lecture.displayName = 'Lecture'; -Lecture.propTypes = propTypes; - -export default connect( - mapStateToProps, - { toggleQuestionView } -)(Lecture); diff --git a/common/app/routes/challenges/views/video/Questions.jsx b/common/app/routes/challenges/views/video/Questions.jsx deleted file mode 100644 index 3062095426..0000000000 --- a/common/app/routes/challenges/views/video/Questions.jsx +++ /dev/null @@ -1,192 +0,0 @@ -import React, { PropTypes } from 'react'; -import { spring, Motion } from 'react-motion'; -import { connect } from 'react-redux'; -import { Button, Col, Row } from 'react-bootstrap'; -import { createSelector } from 'reselect'; - -import { - answerQuestion, - moveQuestion, - releaseQuestion, - grabQuestion -} from '../../redux/actions'; -import { challengeSelector } from '../../redux/selectors'; - -const answerThreshold = 100; -const springProperties = { stiffness: 120, damping: 10 }; -const actionsToBind = { - answerQuestion, - moveQuestion, - releaseQuestion, - grabQuestion -}; - -const mapStateToProps = createSelector( - challengeSelector, - state => state.challengesApp, - state => state.app.isSignedIn, - ( - { challenge: { tests = [ ] }}, - { - currentQuestion = 1, - mouse = [ 0, 0 ], - delta = [ 0, 0 ], - isCorrect = false, - isPressed = false, - shouldShakeQuestion = false - }, - isSignedIn - ) => ({ - tests, - currentQuestion, - isCorrect, - mouse, - delta, - isPressed, - shouldShakeQuestion, - isSignedIn - }) -); -const propTypes = { - answerQuestion: PropTypes.func, - currentQuestion: PropTypes.number, - delta: PropTypes.array, - grabQuestion: PropTypes.func, - isCorrect: PropTypes.bool, - isPressed: PropTypes.bool, - isSignedIn: PropTypes.bool, - mouse: PropTypes.array, - moveQuestion: PropTypes.func, - releaseQuestion: PropTypes.func, - shouldShakeQuestion: PropTypes.bool, - tests: PropTypes.array -}; - -class Question extends React.Component { - handleMouseUp(e, answer, info) { - e.stopPropagation(); - if (!this.props.isPressed) { - return null; - } - - const { - releaseQuestion, - answerQuestion - } = this.props; - - releaseQuestion(); - return answerQuestion({ - e, - answer, - info, - threshold: answerThreshold - }); - } - - handleMouseMove(isPressed, { delta, moveQuestion }) { - if (!isPressed) { - return null; - } - return e => moveQuestion({ e, delta }); - } - - onAnswer(answer, userAnswer, info) { - const { isSignedIn, answerQuestion } = this.props; - return e => { - if (e && e.preventDefault) { - e.preventDefault(); - } - - answerQuestion({ - answer, - userAnswer, - info, - isSignedIn - }); - }; - } - - renderQuestion(number, question, answer, shouldShakeQuestion, info) { - const { grabQuestion, isPressed } = this.props; - const mouseUp = e => this.handleMouseUp(e, answer, info); - return ({ x }) => { - const style = { - WebkitTransform: `translate3d(${ x }px, 0, 0)`, - transform: `translate3d(${ x }px, 0, 0)` - }; - return ( -
    -

    Question { number }

    -

    { question }

    -
    - ); - }; - } - - render() { - const { - tests = [], - mouse: [xPosition], - currentQuestion, - shouldShakeQuestion - } = this.props; - - const [ question, answer, info ] = tests[currentQuestion - 1] || []; - const questionElement = this.renderQuestion( - currentQuestion, - question, - answer, - shouldShakeQuestion, - info - ); - - return ( - this.handleMouseUp(e, answer, info) } - xs={ 8 } - xsOffset={ 2 } - > - - - { questionElement } - -
    -
    -
    - - -
    - - - ); - } -} - -Question.displayName = 'Question'; -Question.propTypes = propTypes; - -export default connect(mapStateToProps, actionsToBind)(Question); diff --git a/common/app/routes/challenges/views/video/Video.jsx b/common/app/routes/challenges/views/video/Video.jsx deleted file mode 100644 index 9c7c9b3203..0000000000 --- a/common/app/routes/challenges/views/video/Video.jsx +++ /dev/null @@ -1,83 +0,0 @@ -import React, { PropTypes } from 'react'; -import { connect } from 'react-redux'; -import { Col, Row } from 'react-bootstrap'; -import { createSelector } from 'reselect'; - -import Lecture from './Lecture.jsx'; -import Questions from './Questions.jsx'; -import { resetUi } from '../../redux/actions'; -import { updateTitle } from '../../../../redux/actions'; -import { challengeSelector } from '../../redux/selectors'; - -const bindableActions = { resetUi, updateTitle }; -const mapStateToProps = createSelector( - challengeSelector, - state => state.challengesApp.shouldShowQuestions, - ({ title }, shouldShowQuestions) => ({ - title, - shouldShowQuestions - }) -); -const propTypes = { - params: PropTypes.object, - resetUi: PropTypes.func, - shouldShowQuestions: PropTypes.bool, - title: PropTypes.string, - updateTitle: PropTypes.func -}; - -export class Video extends React.Component { - componentWillMount() { - const { updateTitle, title } = this.props; - updateTitle(title); - } - - componentWillUnmount() { - this.props.resetUi(); - } - - componentWillReceiveProps({ title }) { - if (this.props.title !== title) { - this.props.resetUi(); - } - } - - renderBody(showQuestions) { - if (showQuestions) { - return ; - } - return ; - } - - render() { - const { - title, - shouldShowQuestions - } = this.props; - return ( - - -
    -

    { title }

    -
    -
    -
    -
    - { this.renderBody(shouldShowQuestions) } -
    - - - ); - } -} - -Video.displayName = 'Video'; -Video.propTypes = propTypes; - -export default connect( - mapStateToProps, - bindableActions -)(Video); diff --git a/common/app/routes/challenges/views/video/index.js b/common/app/routes/challenges/views/video/index.js deleted file mode 100644 index a46de403e7..0000000000 --- a/common/app/routes/challenges/views/video/index.js +++ /dev/null @@ -1 +0,0 @@ -export default from './Video.jsx'; diff --git a/common/app/routes/index.js b/common/app/routes/index.js index c006b3b21e..ba2e0f7234 100644 --- a/common/app/routes/index.js +++ b/common/app/routes/index.js @@ -1,11 +1,11 @@ -import { - modernChallengesRoute, - mapRoute, - challengesRoute -} from './challenges'; -import NotFound from '../components/NotFound/index.jsx'; +import challenges from './challenges'; +import map from './map'; +import settings from './settings'; + +import NotFound from '../NotFound'; import { addLang } from '../utils/lang'; -import settingsRoute from './settings'; + +export { createPanesMap } from './challenges'; export default function createChildRoute(deps) { return { @@ -18,14 +18,10 @@ export default function createChildRoute(deps) { } }, childRoutes: [ - challengesRoute(deps), - modernChallengesRoute(deps), - mapRoute(deps), - settingsRoute(deps), - { - path: '*', - component: NotFound - } + ...challenges(deps), + ...map(deps), + ...settings(deps), + { path: '*', component: NotFound } ] }; } diff --git a/common/app/routes/map/index.js b/common/app/routes/map/index.js new file mode 100644 index 0000000000..40326d564b --- /dev/null +++ b/common/app/routes/map/index.js @@ -0,0 +1,8 @@ +import ShowMap from '../../Map'; + +export default function mapRoute() { + return [{ + path: 'map', + component: ShowMap + }]; +} diff --git a/common/app/routes/redux.js b/common/app/routes/redux.js new file mode 100644 index 0000000000..ecd8b12c00 --- /dev/null +++ b/common/app/routes/redux.js @@ -0,0 +1,7 @@ +import createChallengesReducer from './challenges/redux'; + +export default function createReducers() { + return [ + ...createChallengesReducer() + ]; +} diff --git a/common/app/routes/settings/components/Email-Setting.jsx b/common/app/routes/settings/Email-Setting.jsx similarity index 100% rename from common/app/routes/settings/components/Email-Setting.jsx rename to common/app/routes/settings/Email-Setting.jsx diff --git a/common/app/routes/settings/components/Job-Settings.jsx b/common/app/routes/settings/Job-Settings.jsx similarity index 100% rename from common/app/routes/settings/components/Job-Settings.jsx rename to common/app/routes/settings/Job-Settings.jsx diff --git a/common/app/routes/settings/components/Language-Settings.jsx b/common/app/routes/settings/Language-Settings.jsx similarity index 92% rename from common/app/routes/settings/components/Language-Settings.jsx rename to common/app/routes/settings/Language-Settings.jsx index e68a9c54b3..3938f8b7cb 100644 --- a/common/app/routes/settings/components/Language-Settings.jsx +++ b/common/app/routes/settings/Language-Settings.jsx @@ -3,9 +3,9 @@ import { createSelector } from 'reselect'; import { reduxForm } from 'redux-form'; import { FormControl, FormGroup } from 'react-bootstrap'; -import { updateMyLang } from '../redux/actions'; -import { userSelector } from '../../../redux/selectors'; -import langs from '../../../../utils/supported-languages'; +import { updateMyLang } from './redux'; +import { userSelector } from '../../redux'; +import langs from '../../../utils/supported-languages'; const propTypes = { fields: PropTypes.object, @@ -15,7 +15,7 @@ const propTypes = { const mapStateToProps = createSelector( userSelector, - ({ user: { languageTag } }) => ({ + ({ languageTag }) => ({ // send null to prevent redux-form from initialize empty initialValues: languageTag ? { lang: languageTag } : null }) diff --git a/common/app/routes/settings/components/Locked-Settings.jsx b/common/app/routes/settings/Locked-Settings.jsx similarity index 100% rename from common/app/routes/settings/components/Locked-Settings.jsx rename to common/app/routes/settings/Locked-Settings.jsx diff --git a/common/app/routes/settings/components/SettingsSkeleton.jsx b/common/app/routes/settings/Settings-Skeleton.jsx similarity index 98% rename from common/app/routes/settings/components/SettingsSkeleton.jsx rename to common/app/routes/settings/Settings-Skeleton.jsx index 3224f16f93..f05797a826 100644 --- a/common/app/routes/settings/components/SettingsSkeleton.jsx +++ b/common/app/routes/settings/Settings-Skeleton.jsx @@ -1,7 +1,7 @@ import React from 'react'; import { Button, Row, Col } from 'react-bootstrap'; -import ns from '../ns.json'; +import ns from './ns.json'; // actual chars required to give buttons some height // whitespace alone is no good diff --git a/common/app/routes/settings/components/Settings.jsx b/common/app/routes/settings/Settings.jsx similarity index 91% rename from common/app/routes/settings/components/Settings.jsx rename to common/app/routes/settings/Settings.jsx index aa8a2f9204..31e224ea0c 100644 --- a/common/app/routes/settings/components/Settings.jsx +++ b/common/app/routes/settings/Settings.jsx @@ -10,12 +10,18 @@ import JobSettings from './Job-Settings.jsx'; import SocialSettings from './Social-Settings.jsx'; import EmailSettings from './Email-Setting.jsx'; import LanguageSettings from './Language-Settings.jsx'; -import SettingsSkeleton from './SettingsSkeleton.jsx'; +import SettingsSkeleton from './Settings-Skeleton.jsx'; +import { toggleUserFlag } from './redux'; +import { + toggleNightMode, + updateTitle, + + signInLoadingSelector, + userSelector +} from '../../redux'; +import ChildContainer from '../../Child-Container.jsx'; -import { toggleUserFlag } from '../redux/actions.js'; -import { userSelector } from '../../../redux/selectors.js'; -import { toggleNightMode, updateTitle } from '../../../redux/actions.js'; const mapDispatchToProps = { updateTitle, @@ -29,25 +35,23 @@ const mapDispatchToProps = { const mapStateToProps = createSelector( userSelector, - state => state.app.isSignInAttempted, + signInLoadingSelector, ( { - user: { - username, - email, - isAvailableForHire, - isLocked, - isGithubCool, - isTwitter, - isLinkedIn, - sendMonthlyEmail, - sendNotificationEmail, - sendQuincyEmail - } + username, + email, + isAvailableForHire, + isLocked, + isGithubCool, + isTwitter, + isLinkedIn, + sendMonthlyEmail, + sendNotificationEmail, + sendQuincyEmail }, - isSignInAttempted + showLoading, ) => ({ - showLoading: isSignInAttempted, + showLoading, username, email, isAvailableForHire, @@ -127,18 +131,13 @@ export class Settings extends React.Component { } if (children) { return ( - - - { children } - - + + { children } + ); } return ( -
    +
    + ); } } diff --git a/common/app/routes/settings/components/Social-Settings.jsx b/common/app/routes/settings/Social-Settings.jsx similarity index 100% rename from common/app/routes/settings/components/Social-Settings.jsx rename to common/app/routes/settings/Social-Settings.jsx diff --git a/common/app/routes/settings/index.js b/common/app/routes/settings/index.js index 839ba9a0d3..366f1de25c 100644 --- a/common/app/routes/settings/index.js +++ b/common/app/routes/settings/index.js @@ -1,12 +1,10 @@ -import Settings from './components/Settings.jsx'; +import Settings from './Settings.jsx'; import updateEmailRoute from './routes/update-email'; export default function settingsRoute(deps) { - return { + return [{ path: 'settings', component: Settings, - childRoutes: [ - updateEmailRoute(deps) - ] - }; + childRoutes: updateEmailRoute(deps) + }]; } diff --git a/common/app/routes/settings/redux/actions.js b/common/app/routes/settings/redux/actions.js deleted file mode 100644 index ae71a60289..0000000000 --- a/common/app/routes/settings/redux/actions.js +++ /dev/null @@ -1,16 +0,0 @@ -import { createAction } from 'redux-actions'; - -import createTypes from '../../../utils/create-types'; - -export const types = createTypes([ - 'toggleUserFlag', - 'updateMyEmail', - 'updateMyLang' -], 'settings'); - -export const toggleUserFlag = createAction(types.toggleUserFlag); -export const updateMyEmail = createAction(types.updateMyEmail); -export const updateMyLang = createAction( - types.updateMyLang, - (values) => values.lang -); diff --git a/common/app/routes/settings/redux/index.js b/common/app/routes/settings/redux/index.js index 6e070b31de..085cca21ed 100644 --- a/common/app/routes/settings/redux/index.js +++ b/common/app/routes/settings/redux/index.js @@ -1,8 +1,21 @@ -import userUpdateSaga from './update-user-saga'; +import { createTypes } from 'redux-create-types'; +import { createAction } from 'redux-actions'; -export { types } from './actions'; -export * as actions from './actions'; +import userUpdateEpic from './update-user-epic.js'; -export const sagas = [ - userUpdateSaga +export const epics = [ + userUpdateEpic ]; + +export const types = createTypes([ + 'toggleUserFlag', + 'updateMyEmail', + 'updateMyLang' +], 'settings'); + +export const toggleUserFlag = createAction(types.toggleUserFlag); +export const updateMyEmail = createAction(types.updateMyEmail); +export const updateMyLang = createAction( + types.updateMyLang, + (values) => values.lang +); diff --git a/common/app/routes/settings/redux/selectors.js b/common/app/routes/settings/redux/selectors.js deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/common/app/routes/settings/redux/update-user-saga.js b/common/app/routes/settings/redux/update-user-epic.js similarity index 69% rename from common/app/routes/settings/redux/update-user-saga.js rename to common/app/routes/settings/redux/update-user-epic.js index 8d250aa609..69c01b5799 100644 --- a/common/app/routes/settings/redux/update-user-saga.js +++ b/common/app/routes/settings/redux/update-user-epic.js @@ -1,19 +1,23 @@ import { Observable } from 'rx'; +import { combineEpics, ofType } from 'redux-epic'; import { push } from 'react-router-redux'; -import { types } from './actions'; -import { makeToast } from '../../../toasts/redux/actions'; -import { fetchChallenges } from '../../challenges/redux/actions'; +import { types } from './'; +import { makeToast } from '../../../Toasts/redux'; +import { + fetchChallenges, + doActionOnError, + + userSelector +} from '../../../redux'; import { updateUserFlag, updateUserEmail, - updateUserLang, - doActionOnError -} from '../../../redux/actions'; -import { userSelector } from '../../../redux/selectors'; + updateUserLang +} from '../../../entities'; + import { postJSON$ } from '../../../../utils/ajax-stream'; import langs from '../../../../utils/supported-languages'; -import combineSagas from '../../../../utils/combine-sagas'; const urlMap = { isLocked: 'lockdown', @@ -23,9 +27,8 @@ const urlMap = { sendMonthlyEmail: 'announcement-email' }; -export function updateUserEmailSaga(actions$, getState) { - return actions$ - .filter(({ type }) => type === types.updateMyEmail) +export function updateUserEmailEpic(actions, { getState }) { + return actions::ofType(types.updateMyEmail) .flatMap(({ payload: email }) => { const { app: { user: username, csrfToken: _csrf }, @@ -33,30 +36,30 @@ export function updateUserEmailSaga(actions$, getState) { } = getState(); const { email: oldEmail } = userMap[username] || {}; const body = { _csrf, email }; - const optimisticUpdate$ = Observable.just( + const optimisticUpdate = Observable.just( updateUserEmail(username, email) ); - const ajaxUpdate$ = postJSON$('/update-my-email', body) + const ajaxUpdate = postJSON$('/update-my-email', body) .map(({ message }) => makeToast({ message })) .catch(doActionOnError(() => oldEmail ? updateUserFlag(username, oldEmail) : null )); - return Observable.merge(optimisticUpdate$, ajaxUpdate$); + return Observable.merge(optimisticUpdate, ajaxUpdate); }); } -export function updateUserLangSaga(actions$, getState) { - const updateLang$ = actions$ +export function updateUserLangEpic(actions, { getState }) { + const updateLang = actions .filter(({ type, payload }) => ( type === types.updateMyLang && !!langs[payload] )) .map(({ payload }) => { const state = getState(); - const { user: { languageTag } } = userSelector(state); + const { languageTag } = userSelector(state); return { lang: payload, oldLang: languageTag }; }); - const ajaxUpdate$ = updateLang$ + const ajaxUpdate = updateLang .debounce(250) .flatMap(({ lang, oldLang }) => { const { app: { user: username, csrfToken: _csrf } } = getState(); @@ -76,22 +79,22 @@ export function updateUserLangSaga(actions$, getState) { return updateUserLang(username, oldLang); })); }); - const optimistic$ = updateLang$ + const optimistic = updateLang .map(({ lang }) => { const { app: { user: username } } = getState(); return updateUserLang(username, lang); }); - return Observable.merge(ajaxUpdate$, optimistic$); + return Observable.merge(ajaxUpdate, optimistic); } -export function updateUserFlagSaga(actions$, getState) { - const toggleFlag$ = actions$ +export function updateUserFlagEpic(actions, { getState }) { + const toggleFlag = actions .filter(({ type, payload }) => type === types.toggleUserFlag && payload) .map(({ payload }) => payload); - const optimistic$ = toggleFlag$.map(flag => { + const optimistic = toggleFlag.map(flag => { const { app: { user: username } } = getState(); return updateUserFlag(username, flag); }); - const serverUpdate$ = toggleFlag$ + const serverUpdate = toggleFlag .debounce(500) .flatMap(flag => { const url = `/toggle-${urlMap[ flag ]}`; @@ -112,11 +115,11 @@ export function updateUserFlagSaga(actions$, getState) { return updateUserFlag(username, currentValue); })); }); - return Observable.merge(optimistic$, serverUpdate$); + return Observable.merge(optimistic, serverUpdate); } -export default combineSagas( - updateUserFlagSaga, - updateUserEmailSaga, - updateUserLangSaga +export default combineEpics( + updateUserFlagEpic, + updateUserEmailEpic, + updateUserLangEpic ); diff --git a/common/app/routes/settings/routes/update-email/Update-Email.jsx b/common/app/routes/settings/routes/update-email/Update-Email.jsx index b6c5cf87da..a34997ebb2 100644 --- a/common/app/routes/settings/routes/update-email/Update-Email.jsx +++ b/common/app/routes/settings/routes/update-email/Update-Email.jsx @@ -1,10 +1,17 @@ import React, { PropTypes } from 'react'; -import { Button, HelpBlock, FormControl, FormGroup } from 'react-bootstrap'; +import { + Button, + Col, + FormControl, + FormGroup, + HelpBlock, + Row +} from 'react-bootstrap'; import { LinkContainer } from 'react-router-bootstrap'; import { reduxForm } from 'redux-form'; import { isEmail } from 'validator'; import { getValidationState } from '../../../../utils/form'; -import { updateMyEmail } from '../../redux/actions'; +import { updateMyEmail } from '../../redux'; const actions = { updateMyEmail @@ -65,68 +72,73 @@ export class UpdateEmail extends React.Component { 'Update my Email' : 'Verify Email'; return ( -
    -

    Update your email address here:

    -
    + - Update your email address here: + - - { - !email.error ? - null : - { email.error } - } - - - - { - !duplicate.error ? - null : - { duplicate.error } - } - - - -
    - + + { + !email.error ? + null : + { email.error } + } + + + + { + !duplicate.error ? + null : + { duplicate.error } + } + + - - - -
    +
    + + + + + + + ); } } diff --git a/common/app/routes/settings/routes/update-email/index.js b/common/app/routes/settings/routes/update-email/index.js index 1ab52bf222..b56a6288c1 100644 --- a/common/app/routes/settings/routes/update-email/index.js +++ b/common/app/routes/settings/routes/update-email/index.js @@ -1,8 +1,8 @@ import UpdateEmail from './Update-Email.jsx'; export default function updateEmailRoute() { - return { + return [{ path: 'update-email', component: UpdateEmail - }; + }]; } diff --git a/common/app/sagas.js b/common/app/sagas.js deleted file mode 100644 index f5e1cbf011..0000000000 --- a/common/app/sagas.js +++ /dev/null @@ -1,9 +0,0 @@ -import { sagas as appSagas } from './redux'; -import { sagas as challengeSagas } from './routes/challenges/redux'; -import { sagas as settingsSagas } from './routes/settings/redux'; - -export default [ - ...appSagas, - ...challengeSagas, - ...settingsSagas -]; diff --git a/common/app/toasts/redux/actions.js b/common/app/toasts/redux/actions.js deleted file mode 100644 index 667a4f7e26..0000000000 --- a/common/app/toasts/redux/actions.js +++ /dev/null @@ -1,20 +0,0 @@ -import { createAction } from 'redux-actions'; -import types from './types'; - -let key = 0; -export const makeToast = createAction( - types.makeToast, - ({ timeout, ...rest }) => ({ - ...rest, - // assign current value of key to new toast - // and then increment key value - key: key++, - dismissAfter: timeout || 6000, - position: rest.position === 'left' ? 'left' : 'right' - }) -); - -export const removeToast = createAction( - types.removeToast, - ({ key }) => key -); diff --git a/common/app/toasts/redux/index.js b/common/app/toasts/redux/index.js deleted file mode 100644 index 3a828d6dcd..0000000000 --- a/common/app/toasts/redux/index.js +++ /dev/null @@ -1,3 +0,0 @@ -export { default as types } from './types'; -export { default as reducer } from './reducer'; -export * as actions from './actions'; diff --git a/common/app/toasts/redux/reducer.js b/common/app/toasts/redux/reducer.js deleted file mode 100644 index ac1312041c..0000000000 --- a/common/app/toasts/redux/reducer.js +++ /dev/null @@ -1,13 +0,0 @@ -import { handleActions } from 'redux-actions'; -import types from './types'; - -const initialState = []; -export default handleActions({ - [types.makeToast]: (state, { payload: toast }) => [ - ...state, - toast - ], - [types.removeToast]: (state, { payload: key }) => state.filter( - toast => toast.key !== key - ) -}, initialState); diff --git a/common/app/toasts/redux/types.js b/common/app/toasts/redux/types.js deleted file mode 100644 index 5472d76070..0000000000 --- a/common/app/toasts/redux/types.js +++ /dev/null @@ -1,6 +0,0 @@ -import createTypes from '../../utils/create-types'; - -export default createTypes([ - 'makeToast', - 'removeToast' -], 'toast'); diff --git a/common/utils/combine-sagas.js b/common/utils/combine-sagas.js deleted file mode 100644 index 1788b777fb..0000000000 --- a/common/utils/combine-sagas.js +++ /dev/null @@ -1,9 +0,0 @@ -import { Observable } from 'rx'; - -export default function combineSagas(...sagas) { - return (actions$, getState, deps) => { - return Observable.merge( - sagas.map(saga => saga(actions$, getState, deps)) - ); - }; -} diff --git a/common/utils/get-actions-of-type.js b/common/utils/get-actions-of-type.js deleted file mode 100644 index 2a32d1617c..0000000000 --- a/common/utils/get-actions-of-type.js +++ /dev/null @@ -1,27 +0,0 @@ -// redux-observable compatible operator -export function ofType(...keys) { - return this.filter(({ type }) => { - const len = keys.length; - if (len === 1) { - return type === keys[0]; - } else { - for (let i = 0; i < len; i++) { - if (keys[i] === type) { - return true; - } - } - } - return false; - }); -} - -export default function getActionsOfType(actions, ...types) { - const length = types.length; - return actions - .filter(({ type }) => { - if (length === 1) { - return type === types[0]; - } - return types.some(_type => _type === type); - }); -} diff --git a/common/utils/get-first-challenge.js b/common/utils/get-first-challenge.js index 0ce74e8a02..2c33d9c4ea 100644 --- a/common/utils/get-first-challenge.js +++ b/common/utils/get-first-challenge.js @@ -8,7 +8,7 @@ export function checkMapData( superBlock, challengeIdToName }, - result + result: { superBlocks } } ) { if ( @@ -16,11 +16,11 @@ export function checkMapData( !block || !superBlock || !challengeIdToName || - !result || - !result.length + !superBlocks || + !superBlocks.length ) { throw new Error( - 'entities not found, db may not be properly seeded. Crashing hard' + 'entities not found, db may not be properly seeded' ); } } diff --git a/gulpfile.js b/gulpfile.js index 8fdc688c09..17c7621c86 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -188,7 +188,7 @@ function delRev(dest, manifestName) { gulp.task('serve', function(cb) { let called = false; - nodemon({ + const monitor = nodemon({ script: paths.server, ext: '.jsx .js .json', ignore: paths.serverIgnore, @@ -210,6 +210,14 @@ gulp.task('serve', function(cb) { debug('Nodemon will restart due to changes in: ', files); } }); + + process.once('SIGINT', () => { + monitor.once('exit', () => { + /* eslint-disable no-process-exit */ + process.exit(0); + /* eslint-enable no-process-exit */ + }); + }); }); const syncDepenedents = [ diff --git a/package-lock.json b/package-lock.json index 3e196ecab5..59f7e602bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "integrity": "sha1-z6I7xYQPkQTOMqZedNt+epdLvuE=", "dev": true, "requires": { - "acorn": "5.0.3", + "acorn": "5.1.1", "css": "2.2.1", "normalize-path": "2.1.1", "source-map": "0.5.6", @@ -18,9 +18,9 @@ }, "dependencies": { "acorn": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-5.0.3.tgz", - "integrity": "sha1-xGDfCEkUY/AozLguqzcwvwEIez0=", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-5.1.1.tgz", + "integrity": "sha512-vOk6uEMctu0vQrvuSqFdJyqj1Q0S5VTDL79qtjo+DhRr+1mmaD+tluFSCZqhvi/JUhXSzoZN2BhtstaPEeE8cw==", "dev": true }, "css": { @@ -59,25 +59,25 @@ } }, "@types/bluebird": { - "version": "3.0.37", - "resolved": "https://registry.npmjs.org/@types/bluebird/-/bluebird-3.0.37.tgz", - "integrity": "sha1-LnazlKqb6kDQQkGjHAiHomAoM4g=" + "version": "3.5.8", + "resolved": "https://registry.npmjs.org/@types/bluebird/-/bluebird-3.5.8.tgz", + "integrity": "sha512-rBfrD56OxaqVjghtVqp2EEX0ieHkRk6IefDVrQXIVGvlhDOEBTvZff4Q02uo84ukVkH4k5eB1cPKGDM2NlFL8A==" }, "@types/express": { "version": "4.0.36", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.0.36.tgz", "integrity": "sha512-bT9q2eqH/E72AGBQKT50dh6AXzheTqigGZ1GwDiwmx7vfHff0bZOrvUWjvGpNWPNkRmX1vDF6wonG6rlpBHb1A==", "requires": { - "@types/express-serve-static-core": "4.0.48", + "@types/express-serve-static-core": "4.0.49", "@types/serve-static": "1.7.31" } }, "@types/express-serve-static-core": { - "version": "4.0.48", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.0.48.tgz", - "integrity": "sha512-+W+fHO/hUI6JX36H8FlgdMHU3Dk4a/Fn08fW5qdd7MjPP/wJlzq9fkCrgaH0gES8vohVeqwefHwPa4ylVKyYIg==", + "version": "4.0.49", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.0.49.tgz", + "integrity": "sha512-b7mVHoURu1xaP/V6xw1sYwyv9V0EZ7euyi+sdnbnTZxEkAh4/hzPsI6Eflq+ZzHQ/Tgl7l16Jz+0oz8F46MLnA==", "requires": { - "@types/node": "8.0.7" + "@types/node": "6.0.81" } }, "@types/mime": { @@ -86,16 +86,16 @@ "integrity": "sha512-rek8twk9C58gHYqIrUlJsx8NQMhlxqHzln9Z9ODqiNgv3/s+ZwIrfr+djqzsnVM12xe9hL98iJ20lj2RvCBv6A==" }, "@types/node": { - "version": "8.0.7", - "resolved": "https://registry.npmjs.org/@types/node/-/node-8.0.7.tgz", - "integrity": "sha512-fuCPLPe4yY0nv6Z1rTLFCEC452jl0k7i3gF/c8hdEKpYtEpt6Sk67hTGbxx8C0wmifFGPvKYd/O8CvS6dpgxMQ==" + "version": "6.0.81", + "resolved": "https://registry.npmjs.org/@types/node/-/node-6.0.81.tgz", + "integrity": "sha512-KdtXOH8l9O2wwOOX+swjbFx+YW/RJFfI14o6S50+Zy79FK1WFGkzFdDsiuNjrG5L6FaBSKpKzSpWgTvXurbbYg==" }, "@types/serve-static": { "version": "1.7.31", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.7.31.tgz", "integrity": "sha1-FUVt6NmNa0z/Mb5savdJKuY/Uho=", "requires": { - "@types/express-serve-static-core": "4.0.48", + "@types/express-serve-static-core": "4.0.49", "@types/mime": "1.3.1" } }, @@ -196,12 +196,6 @@ "json-stable-stringify": "1.0.1" } }, - "ajv-keywords": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-1.5.1.tgz", - "integrity": "sha1-MU3QpLM2j609/NxU7eYXG4htrzw=", - "dev": true - }, "align-text": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/align-text/-/align-text-0.1.4.tgz", @@ -258,14 +252,14 @@ "resolved": "https://registry.npmjs.org/aphrodite/-/aphrodite-0.5.0.tgz", "integrity": "sha1-pLmokCZiOV0nAucKx6K0ymbyVwM=", "requires": { - "asap": "2.0.5", + "asap": "2.0.6", "inline-style-prefixer": "2.0.5" }, "dependencies": { "asap": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.5.tgz", - "integrity": "sha1-UidltQw1EEkOUtfc/ghe+bqWlY8=" + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY=" } } }, @@ -288,13 +282,13 @@ "integrity": "sha1-jzuCf5Vai9ZpaX5KQlasPOrjVs8=", "dev": true, "requires": { - "arr-flatten": "1.0.3" + "arr-flatten": "1.1.0" } }, "arr-flatten": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/arr-flatten/-/arr-flatten-1.0.3.tgz", - "integrity": "sha1-onTthawIhJtr14R8RYB0XcUa37E=", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/arr-flatten/-/arr-flatten-1.1.0.tgz", + "integrity": "sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg==", "dev": true }, "array-differ": { @@ -443,12 +437,13 @@ "dev": true }, "aws-sdk": { - "version": "2.80.0", - "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.80.0.tgz", - "integrity": "sha1-Yc7XR+uYFglIOuxT6NZU08ydFDU=", + "version": "2.83.0", + "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.83.0.tgz", + "integrity": "sha1-8BBxg1dtLmCTY2q4nM2bVGpTG+c=", "requires": { "buffer": "4.9.1", "crypto-browserify": "1.0.9", + "events": "1.1.1", "jmespath": "0.15.0", "querystring": "0.2.0", "sax": "1.2.1", @@ -491,7 +486,7 @@ "babel-register": "6.24.1", "babel-runtime": "6.23.0", "chokidar": "1.7.0", - "commander": "2.10.0", + "commander": "2.11.0", "convert-source-map": "1.5.0", "fs-readdir-recursive": "1.0.0", "glob": "7.1.2", @@ -504,13 +499,10 @@ }, "dependencies": { "commander": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.10.0.tgz", - "integrity": "sha512-q/r9trjmuikWDRJNTBHAVnWhuU6w+z80KgBq7j9YDclik5E7X4xi0KnlZBNFA1zOQ+SH/vHMWd2mC9QTOz7GpA==", - "dev": true, - "requires": { - "graceful-readlink": "1.0.1" - } + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.11.0.tgz", + "integrity": "sha512-b0553uYA5YAEGgyYIGYROzKQ7X5RAqedkfjiZxwi0kL1g3bOaBNNZfYkzt/CL0umgD5wc9Jec2FbB98CjkMRvQ==", + "dev": true } } }, @@ -749,7 +741,7 @@ "escodegen": "1.8.1", "esprima": "2.7.3", "handlebars": "4.0.10", - "js-yaml": "3.8.4", + "js-yaml": "3.9.0", "mkdirp": "0.5.1", "multi-glob": "1.0.1", "nopt": "3.0.6", @@ -2070,9 +2062,9 @@ "integrity": "sha1-FkpUg+Yw+kMh5a8HAg5TGDGyYJs=" }, "caniuse-db": { - "version": "1.0.30000696", - "resolved": "https://registry.npmjs.org/caniuse-db/-/caniuse-db-1.0.30000696.tgz", - "integrity": "sha1-5x9cYeH5bHo69OeRrF21XhFzdgQ=" + "version": "1.0.30000700", + "resolved": "https://registry.npmjs.org/caniuse-db/-/caniuse-db-1.0.30000700.tgz", + "integrity": "sha1-l8/Eg4Ze6oV33Ho2dJKbmr9VMJU=" }, "canonical-json": { "version": "0.0.4", @@ -2408,7 +2400,7 @@ "integrity": "sha1-NIxhrpzb4O3+BT2R/0zFIdeQ7eg=", "requires": { "for-own": "1.0.0", - "is-plain-object": "2.0.3", + "is-plain-object": "2.0.4", "kind-of": "3.2.2", "shallow-clone": "0.1.2" } @@ -2617,35 +2609,23 @@ } }, "compression": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.6.2.tgz", - "integrity": "sha1-zOsSHsydCcUtetDDNQ6pPd1AK8M=", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.0.tgz", + "integrity": "sha1-AwyfGY8WQ6BX13anOOki2kNzAS0=", "requires": { "accepts": "1.3.3", - "bytes": "2.3.0", + "bytes": "2.5.0", "compressible": "2.0.10", - "debug": "2.2.0", + "debug": "2.6.8", "on-headers": "1.0.1", + "safe-buffer": "5.1.1", "vary": "1.1.1" }, "dependencies": { "bytes": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-2.3.0.tgz", - "integrity": "sha1-1baAoWW2IBc5rLYRVCqrwtjOsHA=" - }, - "debug": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz", - "integrity": "sha1-+HBX6ZWxofauaklgZkE3vFbwOdo=", - "requires": { - "ms": "0.7.1" - } - }, - "ms": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz", - "integrity": "sha1-nNE8A62/8ltl7/3nzoZO6VIBcJg=" + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-2.5.0.tgz", + "integrity": "sha1-TJQj6i0lLCcMQbK97+/5u2tiwGo=" } } }, @@ -2734,7 +2714,7 @@ "integrity": "sha1-fL9Y3/8mdg5eAOAX0KhbS8kLnTc=", "requires": { "bluebird": "3.5.0", - "mongodb": "2.2.29" + "mongodb": "2.2.30" } }, "console-browserify": { @@ -2798,9 +2778,9 @@ } }, "conventional-commit-types": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/conventional-commit-types/-/conventional-commit-types-2.1.0.tgz", - "integrity": "sha1-RdhgOGyaLmU37pHYobYb0EEbPQQ=", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/conventional-commit-types/-/conventional-commit-types-2.2.0.tgz", + "integrity": "sha1-XblXOdbCEqy+e29lahG5QLqmiUY=", "dev": true }, "convert-source-map": { @@ -2871,13 +2851,10 @@ "dev": true }, "commander": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.10.0.tgz", - "integrity": "sha512-q/r9trjmuikWDRJNTBHAVnWhuU6w+z80KgBq7j9YDclik5E7X4xi0KnlZBNFA1zOQ+SH/vHMWd2mC9QTOz7GpA==", - "dev": true, - "requires": { - "graceful-readlink": "1.0.1" - } + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.11.0.tgz", + "integrity": "sha512-b0553uYA5YAEGgyYIGYROzKQ7X5RAqedkfjiZxwi0kL1g3bOaBNNZfYkzt/CL0umgD5wc9Jec2FbB98CjkMRvQ==", + "dev": true }, "esprima": { "version": "2.7.3", @@ -2892,7 +2869,7 @@ "dev": true, "requires": { "chalk": "1.1.3", - "commander": "2.10.0", + "commander": "2.11.0", "is-my-json-valid": "2.16.0", "pinkie-promise": "2.0.1" } @@ -3067,7 +3044,7 @@ "integrity": "sha1-K8oElkyJGbI/P9aonvXmAIsxs/g=", "dev": true, "requires": { - "conventional-commit-types": "2.1.0", + "conventional-commit-types": "2.2.0", "lodash.map": "4.6.0", "longest": "1.0.1", "pad-right": "0.2.2", @@ -3100,7 +3077,7 @@ "resolved": "https://registry.npmjs.org/d/-/d-1.0.0.tgz", "integrity": "sha1-dUu1v+VUUdpppYuU1F9MWwRi1Y8=", "requires": { - "es5-ext": "0.10.23" + "es5-ext": "0.10.24" } }, "d3": { @@ -3538,7 +3515,7 @@ "resolved": "https://registry.npmjs.org/emmet/-/emmet-1.6.3.tgz", "integrity": "sha1-/hPXdO7jMv5L9sCsjLLWhhbqUME=", "requires": { - "caniuse-db": "1.0.30000696" + "caniuse-db": "1.0.30000700" } }, "emmet-codemirror": { @@ -3764,9 +3741,9 @@ } }, "es5-ext": { - "version": "0.10.23", - "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.23.tgz", - "integrity": "sha1-dXi1G+l0IHpUh4IbVlOMIk5Oezg=", + "version": "0.10.24", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.24.tgz", + "integrity": "sha1-pVh3yZJLwMjZvTwsvhdJWsFwmxQ=", "requires": { "es6-iterator": "2.0.1", "es6-symbol": "3.1.1" @@ -3778,7 +3755,7 @@ "integrity": "sha1-jjGcnwRTv1ddN0lAplWSDlnKVRI=", "requires": { "d": "1.0.0", - "es5-ext": "0.10.23", + "es5-ext": "0.10.24", "es6-symbol": "3.1.1" } }, @@ -3788,7 +3765,7 @@ "integrity": "sha1-kTbgUD3MBqMBaQ8LsU/042TpSfA=", "requires": { "d": "1.0.0", - "es5-ext": "0.10.23", + "es5-ext": "0.10.24", "es6-iterator": "2.0.1", "es6-set": "0.1.5", "es6-symbol": "3.1.1", @@ -3806,7 +3783,7 @@ "integrity": "sha1-0rPsXU2ADO2BjbU40ol02wpzzLE=", "requires": { "d": "1.0.0", - "es5-ext": "0.10.23", + "es5-ext": "0.10.24", "es6-iterator": "2.0.1", "es6-symbol": "3.1.1", "event-emitter": "0.3.5" @@ -3818,7 +3795,7 @@ "integrity": "sha1-vwDvT9q2uhtG7Le2KbTH7VcVzHc=", "requires": { "d": "1.0.0", - "es5-ext": "0.10.23" + "es5-ext": "0.10.24" } }, "es6-weak-map": { @@ -3828,7 +3805,7 @@ "dev": true, "requires": { "d": "1.0.0", - "es5-ext": "0.10.23", + "es5-ext": "0.10.24", "es6-iterator": "2.0.1", "es6-symbol": "3.1.1" } @@ -3916,7 +3893,7 @@ "inquirer": "0.12.0", "is-my-json-valid": "2.16.0", "is-resolvable": "1.0.0", - "js-yaml": "3.8.4", + "js-yaml": "3.9.0", "json-stable-stringify": "1.0.1", "levn": "0.3.0", "lodash": "4.17.4", @@ -4020,9 +3997,9 @@ } }, "eslint-plugin-import": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.6.1.tgz", - "integrity": "sha512-aAMb32eHCQaQmgdb1MOG1hfu/rPiNgGur2IF71VJeDfTXdLpPiKALKWlzxMdcxQOZZ2CmYVKabAxCvjACxH1uQ==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.7.0.tgz", + "integrity": "sha512-HGYmpU9f/zJaQiKNQOVfHUh2oLWW3STBrCgH0sHTX1xtsxYlH1zjLh8FlQGEIdZSdTbUMaV36WaZ6ImXkenGxQ==", "dev": true, "requires": { "builtin-modules": "1.1.1", @@ -4143,14 +4120,14 @@ "integrity": "sha1-KRC1zNSc6JPC//+qtP2LOjG4I3Q=", "dev": true, "requires": { - "acorn": "5.0.3", + "acorn": "5.1.1", "acorn-jsx": "3.0.1" }, "dependencies": { "acorn": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-5.0.3.tgz", - "integrity": "sha1-xGDfCEkUY/AozLguqzcwvwEIez0=", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-5.1.1.tgz", + "integrity": "sha512-vOk6uEMctu0vQrvuSqFdJyqj1Q0S5VTDL79qtjo+DhRr+1mmaD+tluFSCZqhvi/JUhXSzoZN2BhtstaPEeE8cw==", "dev": true } } @@ -4200,7 +4177,7 @@ "integrity": "sha1-34xp7vFkeSPHFXuc6DhAYQsCzDk=", "requires": { "d": "1.0.0", - "es5-ext": "0.10.23" + "es5-ext": "0.10.24" } }, "event-stream": { @@ -4249,8 +4226,7 @@ "events": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz", - "integrity": "sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ=", - "dev": true + "integrity": "sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ=" }, "exit": { "version": "0.1.2", @@ -4382,11 +4358,11 @@ } }, "express-validator": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-3.2.0.tgz", - "integrity": "sha1-lTer6w9m5Dn54wtO0WxMbCMTGOI=", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-3.2.1.tgz", + "integrity": "sha1-RWA+fu5pMYXCGY+969QUkl/9NSQ=", "requires": { - "@types/bluebird": "3.0.37", + "@types/bluebird": "3.5.8", "@types/express": "4.0.36", "bluebird": "3.5.0", "lodash": "4.17.4", @@ -4471,9 +4447,9 @@ }, "dependencies": { "asap": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.5.tgz", - "integrity": "sha1-UidltQw1EEkOUtfc/ghe+bqWlY8=" + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY=" }, "core-js": { "version": "1.2.7", @@ -4485,7 +4461,7 @@ "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", "requires": { - "asap": "2.0.5" + "asap": "2.0.6" } } } @@ -4631,20 +4607,6 @@ "requires": { "findup-sync": "0.4.2", "merge": "1.2.0" - }, - "dependencies": { - "findup-sync": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-0.4.2.tgz", - "integrity": "sha1-qBF9D3MST1pFRoOVef5S1xKfteU=", - "dev": true, - "requires": { - "detect-file": "0.1.0", - "is-glob": "2.0.1", - "micromatch": "2.3.11", - "resolve-dir": "0.1.1" - } - } } }, "find-parent-dir": { @@ -4693,9 +4655,9 @@ } }, "findup-sync": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-0.4.3.tgz", - "integrity": "sha1-QAQ5Kee8YK3wt/SCfExudaDeyhI=", + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-0.4.2.tgz", + "integrity": "sha1-qBF9D3MST1pFRoOVef5S1xKfteU=", "dev": true, "requires": { "detect-file": "0.1.0", @@ -4711,7 +4673,7 @@ "dev": true, "requires": { "expand-tilde": "2.0.2", - "is-plain-object": "2.0.3", + "is-plain-object": "2.0.4", "object.defaults": "1.1.0", "object.pick": "1.2.0", "parse-filepath": "1.0.1" @@ -4852,7 +4814,7 @@ "dev": true, "requires": { "graceful-fs": "4.1.11", - "jsonfile": "3.0.0", + "jsonfile": "3.0.1", "universalify": "0.1.0" } }, @@ -7470,9 +7432,9 @@ "integrity": "sha1-caUMhCnfync8kqOQpKA7OfzVHT4=" }, "is-plain-object": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.3.tgz", - "integrity": "sha1-wVvz5LZrYtcu+vKSWEhmPsvGGbY=", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", "requires": { "isobject": "3.0.1" } @@ -7707,12 +7669,19 @@ "integrity": "sha1-mGbfOVECEw449/mWvOtlRDIJwls=" }, "js-yaml": { - "version": "3.8.4", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.8.4.tgz", - "integrity": "sha1-UgtFZPhlc7qWZir4Woyvp7S1pvY=", + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.9.0.tgz", + "integrity": "sha512-0LoUNELX4S+iofCT8f4uEHIiRBR+c2AINyC8qRWfC6QNruLtxVZRJaPcu/xwMgFIgDxF25tGHaDjvxzJCNE9yw==", "requires": { "argparse": "1.0.9", - "esprima": "3.1.3" + "esprima": "4.0.0" + }, + "dependencies": { + "esprima": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.0.tgz", + "integrity": "sha512-oftTcaMu/EGrEIu904mWteKIv8vMuOgGYo7EhVJJN00R/EED9DCua/xxHRdYnKtcECzVg7xOWhflvJMnqcFZjw==" + } } }, "js2xmlparser": { @@ -7794,9 +7763,9 @@ "integrity": "sha1-Hq3nrMASA0rYTiOWdn6tn6VJWCE=" }, "jsonfile": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-3.0.0.tgz", - "integrity": "sha1-kufHRE5f/V+jLmqa6LhQNN+DR9A=", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-3.0.1.tgz", + "integrity": "sha1-pezG9l9T9mLEQVx2daAzHQmS7GY=", "dev": true, "requires": { "graceful-fs": "4.1.11" @@ -7854,6 +7823,14 @@ "requires": { "brace-expansion": "1.1.8" } + }, + "omni-fetch": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/omni-fetch/-/omni-fetch-0.1.0.tgz", + "integrity": "sha1-Och1UMG7jdLMH7pUj0L1Jnpa7jk=", + "requires": { + "caw": "1.2.0" + } } } }, @@ -8007,9 +7984,9 @@ }, "dependencies": { "asap": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.5.tgz", - "integrity": "sha1-UidltQw1EEkOUtfc/ghe+bqWlY8=", + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY=", "dev": true, "optional": true }, @@ -8020,7 +7997,7 @@ "dev": true, "optional": true, "requires": { - "asap": "2.0.5" + "asap": "2.0.6" } } } @@ -8062,7 +8039,7 @@ "dev": true, "requires": { "extend": "3.0.1", - "findup-sync": "0.4.3", + "findup-sync": "0.4.2", "fined": "1.1.0", "flagged-respawn": "0.3.2", "lodash.isplainobject": "4.0.6", @@ -8158,13 +8135,10 @@ } }, "commander": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.10.0.tgz", - "integrity": "sha512-q/r9trjmuikWDRJNTBHAVnWhuU6w+z80KgBq7j9YDclik5E7X4xi0KnlZBNFA1zOQ+SH/vHMWd2mC9QTOz7GpA==", - "dev": true, - "requires": { - "graceful-readlink": "1.0.1" - } + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.11.0.tgz", + "integrity": "sha512-b0553uYA5YAEGgyYIGYROzKQ7X5RAqedkfjiZxwi0kL1g3bOaBNNZfYkzt/CL0umgD5wc9Jec2FbB98CjkMRvQ==", + "dev": true }, "debug": { "version": "2.2.0", @@ -8182,7 +8156,7 @@ "dev": true, "requires": { "chalk": "1.1.3", - "commander": "2.10.0", + "commander": "2.11.0", "is-my-json-valid": "2.16.0", "pinkie-promise": "2.0.1" } @@ -8557,6 +8531,11 @@ "lodash.escape": "3.2.0" } }, + "lodash.toarray": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.toarray/-/lodash.toarray-4.4.0.tgz", + "integrity": "sha1-JMS/zWsvuji/0FlNsRedjptlZWE=" + }, "lodash.uniq": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", @@ -8783,7 +8762,7 @@ "resolved": "https://registry.npmjs.org/loopback-connector-remote/-/loopback-connector-remote-1.3.3.tgz", "integrity": "sha1-ePpyTk4ptNeqXcpVybNKC819Y+A=", "requires": { - "loopback-datasource-juggler": "2.54.2", + "loopback-datasource-juggler": "2.55.0", "strong-remoting": "2.33.0" } }, @@ -8796,9 +8775,9 @@ } }, "loopback-datasource-juggler": { - "version": "2.54.2", - "resolved": "https://registry.npmjs.org/loopback-datasource-juggler/-/loopback-datasource-juggler-2.54.2.tgz", - "integrity": "sha512-c1YDcKalIF3YFR8PekqGej6HtfQylxOAPAXgHLcJjCt9SgfaWGhgOL/vIBTzheH0GbygC4B/DgM4y0RZWxw60A==", + "version": "2.55.0", + "resolved": "https://registry.npmjs.org/loopback-datasource-juggler/-/loopback-datasource-juggler-2.55.0.tgz", + "integrity": "sha1-eupPKwZW6G1Sufd69uECTW/shXo=", "requires": { "async": "1.0.0", "debug": "2.6.8", @@ -9259,19 +9238,19 @@ } }, "mongodb": { - "version": "2.2.29", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-2.2.29.tgz", - "integrity": "sha512-MrQvIsN6zN80I4hdFo8w46w51cIqD2FJBGsUfApX9GmjXA1aCclEAJbOHaQWjCtabeWq57S3ECzqEKg/9bdBhA==", + "version": "2.2.30", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-2.2.30.tgz", + "integrity": "sha1-jM2AH2dsgXIEDC8rR+lgKg1WNKs=", "requires": { "es6-promise": "3.2.1", - "mongodb-core": "2.1.13", + "mongodb-core": "2.1.14", "readable-stream": "2.2.7" } }, "mongodb-core": { - "version": "2.1.13", - "resolved": "https://registry.npmjs.org/mongodb-core/-/mongodb-core-2.1.13.tgz", - "integrity": "sha512-mbcvqLLZwVcpTrsfBDY3hRNk2SDNJWOvKKxFJSc0pnUBhYojymBc/L0THfQsWwKJrkb2nIXSjfFll1mG/I5OqQ==", + "version": "2.1.14", + "resolved": "https://registry.npmjs.org/mongodb-core/-/mongodb-core-2.1.14.tgz", + "integrity": "sha1-E8uidkImtb49GJkq8Mljzl6g8P0=", "requires": { "bson": "1.0.4", "require_optional": "1.0.1" @@ -9524,10 +9503,11 @@ "integrity": "sha1-ICtIAhoMTL3i34DeFaF0Q8i0OYA=" }, "node-emoji": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.5.1.tgz", - "integrity": "sha1-/ZGOQSdpv4xEgFEjgjOECyr/FqE=", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.7.0.tgz", + "integrity": "sha512-dYx345sjhPJUpWaVQKjP0/43y+nTcfBRTZfSciM3ZEbRGaU/9AKaHBPf7AJ9vOKcK0W3v67AgI4m4oo02NLHhQ==", "requires": { + "lodash.toarray": "4.4.0", "string.prototype.codepointat": "0.2.0" } }, @@ -9679,7 +9659,7 @@ "resolved": "https://registry.npmjs.org/nodemailer-ses-transport/-/nodemailer-ses-transport-1.5.1.tgz", "integrity": "sha1-3AWYwb9T6GUuYy6PMWks4CLX3qk=", "requires": { - "aws-sdk": "2.80.0" + "aws-sdk": "2.83.0" } }, "nodemailer-shared": { @@ -9951,14 +9931,6 @@ } } }, - "omni-fetch": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/omni-fetch/-/omni-fetch-0.1.0.tgz", - "integrity": "sha1-Och1UMG7jdLMH7pUj0L1Jnpa7jk=", - "requires": { - "caw": "1.2.0" - } - }, "on-finished": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", @@ -9986,9 +9958,9 @@ "integrity": "sha1-ofeDj4MUxRbwXs78vEzP4EtO14k=" }, "opbeat": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/opbeat/-/opbeat-4.14.0.tgz", - "integrity": "sha1-rpB3qvqRS3KkSAGQWjK8tT1+dd8=", + "version": "4.14.1", + "resolved": "https://registry.npmjs.org/opbeat/-/opbeat-4.14.1.tgz", + "integrity": "sha512-8kcSCTNIXy5oW9lW+QFOL/r2iOs395JC5uU8BEtDYAGBTfPv+9nbPriUXc41duACtnIRbzW5qK0lG0vrOo28nw==", "requires": { "after-all-results": "2.0.0", "console-log-level": "1.4.0", @@ -10098,8 +10070,7 @@ "options": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/options/-/options-0.0.6.tgz", - "integrity": "sha1-7CLTEoBrtT5zF3Pnza788cZDEo8=", - "dev": true + "integrity": "sha1-7CLTEoBrtT5zF3Pnza788cZDEo8=" }, "orchestrator": { "version": "0.3.8", @@ -10902,6 +10873,35 @@ "react-prop-types": "0.4.0", "uncontrollable": "4.1.0", "warning": "3.0.0" + }, + "dependencies": { + "react-overlays": { + "version": "0.6.12", + "resolved": "https://registry.npmjs.org/react-overlays/-/react-overlays-0.6.12.tgz", + "integrity": "sha1-oHnHUMxCnX20x0dKlbS1QDPiVcM=", + "requires": { + "classnames": "2.2.5", + "dom-helpers": "3.2.1", + "react-prop-types": "0.4.0", + "warning": "3.0.0" + } + }, + "react-prop-types": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/react-prop-types/-/react-prop-types-0.4.0.tgz", + "integrity": "sha1-+ZsL+0AGkpya8gUefBQUpcdbk9A=", + "requires": { + "warning": "3.0.0" + } + }, + "uncontrollable": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-4.1.0.tgz", + "integrity": "sha1-4DWCkSUuGGUiLZCTmxny9J+Bwak=", + "requires": { + "invariant": "2.2.2" + } + } } }, "react-codemirror": { @@ -10939,12 +10939,6 @@ "prop-types": "15.5.10" } }, - "react-hot-api": { - "version": "0.4.7", - "resolved": "https://registry.npmjs.org/react-hot-api/-/react-hot-api-0.4.7.tgz", - "integrity": "sha1-p+IqVtJS4Rq9k2a2EmTPRJLFgXE=", - "dev": true - }, "react-hot-loader": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/react-hot-loader/-/react-hot-loader-1.3.1.tgz", @@ -10955,6 +10949,12 @@ "source-map": "0.4.4" }, "dependencies": { + "react-hot-api": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/react-hot-api/-/react-hot-api-0.4.7.tgz", + "integrity": "sha1-p+IqVtJS4Rq9k2a2EmTPRJLFgXE=", + "dev": true + }, "source-map": { "version": "0.4.4", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.4.4.tgz", @@ -10973,8 +10973,31 @@ "requires": { "aphrodite": "0.5.0", "prop-types": "15.5.10", - "react-scrolllock": "1.0.6", + "react-scrolllock": "1.0.8", "react-transition-group": "1.2.0" + }, + "dependencies": { + "react-scrolllock": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/react-scrolllock/-/react-scrolllock-1.0.8.tgz", + "integrity": "sha1-Su6FgWeDJ7lss4pyt6IOJhBmTlo=", + "requires": { + "create-react-class": "15.6.0", + "prop-types": "15.5.10" + } + }, + "react-transition-group": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-1.2.0.tgz", + "integrity": "sha1-tR/JIbDDg1p+98Vxx5/ILHPpIE8=", + "requires": { + "chain-function": "1.0.0", + "dom-helpers": "3.2.1", + "loose-envify": "1.3.1", + "prop-types": "15.5.10", + "warning": "3.0.0" + } + } } }, "react-lazy-cache": { @@ -11007,25 +11030,6 @@ "react-notification": { "version": "git+https://github.com/BerkeleyTrue/react-notification.git#0c503b92a92cc1db843e6f8802d6d8b292546b5e" }, - "react-overlays": { - "version": "0.6.12", - "resolved": "https://registry.npmjs.org/react-overlays/-/react-overlays-0.6.12.tgz", - "integrity": "sha1-oHnHUMxCnX20x0dKlbS1QDPiVcM=", - "requires": { - "classnames": "2.2.5", - "dom-helpers": "3.2.1", - "react-prop-types": "0.4.0", - "warning": "3.0.0" - } - }, - "react-prop-types": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/react-prop-types/-/react-prop-types-0.4.0.tgz", - "integrity": "sha1-+ZsL+0AGkpya8gUefBQUpcdbk9A=", - "requires": { - "warning": "3.0.0" - } - }, "react-pure-render": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/react-pure-render/-/react-pure-render-1.0.2.tgz", @@ -11071,26 +11075,6 @@ "resolved": "https://registry.npmjs.org/react-router-redux/-/react-router-redux-4.0.8.tgz", "integrity": "sha1-InQDWWtRUeGCN32rg1tdRfD4BU4=" }, - "react-scrolllock": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/react-scrolllock/-/react-scrolllock-1.0.6.tgz", - "integrity": "sha1-A2Gaq+xyRZbtyx4iTrJaY83fF2w=", - "requires": { - "prop-types": "15.5.10" - } - }, - "react-transition-group": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-1.2.0.tgz", - "integrity": "sha1-tR/JIbDDg1p+98Vxx5/ILHPpIE8=", - "requires": { - "chain-function": "1.0.0", - "dom-helpers": "3.2.1", - "loose-envify": "1.3.1", - "prop-types": "15.5.10", - "warning": "3.0.0" - } - }, "react-youtube": { "version": "7.4.0", "resolved": "https://registry.npmjs.org/react-youtube/-/react-youtube-7.4.0.tgz", @@ -11218,9 +11202,9 @@ } }, "redux-actions": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/redux-actions/-/redux-actions-2.0.3.tgz", - "integrity": "sha1-FVCrqd7xeRZszSNNB2chBKc22Ik=", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/redux-actions/-/redux-actions-2.2.1.tgz", + "integrity": "sha1-1kGGslZJoTwFR4VH1811N7iSQQ0=", "requires": { "invariant": "2.2.2", "lodash": "4.17.4", @@ -11238,9 +11222,9 @@ } }, "redux-epic": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/redux-epic/-/redux-epic-0.2.0.tgz", - "integrity": "sha1-HQJ8ZgG2nJ+oqSfHQdug4O22dJQ=", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/redux-epic/-/redux-epic-0.3.0.tgz", + "integrity": "sha1-g/SioK4fowK/QC397KNi1jB9xr8=", "requires": { "debug": "2.6.8", "invariant": "2.2.2", @@ -11696,6 +11680,14 @@ "resolved": "https://registry.npmjs.org/rx/-/rx-4.0.8.tgz", "integrity": "sha1-23Lz6ZRiQhatq63uI/1u4K7ApKE=" }, + "rx-dom": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/rx-dom/-/rx-dom-7.0.3.tgz", + "integrity": "sha1-+HbzmEU//DRqxlGH7dbnF+0R/gk=", + "requires": { + "rx": "4.0.8" + } + }, "rx-lite": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/rx-lite/-/rx-lite-3.1.2.tgz", @@ -12014,12 +12006,12 @@ "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=" }, "sinon": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/sinon/-/sinon-2.3.6.tgz", - "integrity": "sha1-lTeOfg+XapcS6bRZH/WznnPcPd4=", + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-2.3.7.tgz", + "integrity": "sha1-FFFhSi6qsFu02HbBM1zUATLsUSc=", "dev": true, "requires": { - "diff": "3.2.0", + "diff": "3.3.0", "formatio": "1.2.0", "lolex": "1.6.0", "native-promise-only": "0.8.1", @@ -12030,9 +12022,9 @@ }, "dependencies": { "diff": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-3.2.0.tgz", - "integrity": "sha1-yc45Okt8vQsFinJck98pkCeGj/k=", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-3.3.0.tgz", + "integrity": "sha512-w0XZubFWn0Adlsapj9EAWX0FqWdO4tz8kc3RiYdWLh4k/V8PTb6i0SMgXt0vRM3zyKnT8tKO7mUlieRQHIjMNg==", "dev": true }, "isarray": { @@ -12193,7 +12185,7 @@ "requires": { "debug": "2.6.8", "es6-promise": "3.2.1", - "js-yaml": "3.8.4", + "js-yaml": "3.9.0", "lodash.clonedeep": "4.5.0", "semver": "5.3.0", "snyk-module": "1.8.1", @@ -12542,13 +12534,6 @@ "integrity": "sha1-MZJGHfo4x4Qk3Zv46gJWGaElqhA=", "requires": { "options": "0.0.6" - }, - "dependencies": { - "options": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/options/-/options-0.0.6.tgz", - "integrity": "sha1-7CLTEoBrtT5zF3Pnza788cZDEo8=" - } } }, "sshpk": { @@ -12899,7 +12884,7 @@ "requires": { "btoa": "1.1.2", "cookiejar": "2.1.1", - "js-yaml": "3.8.4", + "js-yaml": "3.9.0", "lodash-compat": "3.10.2", "q": "1.5.0", "superagent": "2.3.0" @@ -12924,6 +12909,12 @@ "string-width": "2.1.0" }, "dependencies": { + "ajv-keywords": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-1.5.1.tgz", + "integrity": "sha1-MU3QpLM2j609/NxU7eYXG4htrzw=", + "dev": true + }, "ansi-regex": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", @@ -13416,14 +13407,6 @@ "integrity": "sha1-5z3T17DXxe2G+6xrCufYxqadUPo=", "dev": true }, - "uncontrollable": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-4.1.0.tgz", - "integrity": "sha1-4DWCkSUuGGUiLZCTmxny9J+Bwak=", - "requires": { - "invariant": "2.2.2" - } - }, "undefsafe": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-0.0.3.tgz", @@ -13630,7 +13613,7 @@ "integrity": "sha1-bVAVMxvxlsIq+4gNPzO87x3q/qY=", "dev": true, "requires": { - "conventional-commit-types": "2.1.0", + "conventional-commit-types": "2.2.0", "find-parent-dir": "0.3.0", "findup": "0.1.5", "semver-regex": "1.0.0" @@ -14002,9 +13985,9 @@ } }, "webpack-hot-middleware": { - "version": "2.18.0", - "resolved": "https://registry.npmjs.org/webpack-hot-middleware/-/webpack-hot-middleware-2.18.0.tgz", - "integrity": "sha1-oWu1Nbg6aslKeKxevOTzBZ6CdNM=", + "version": "2.18.2", + "resolved": "https://registry.npmjs.org/webpack-hot-middleware/-/webpack-hot-middleware-2.18.2.tgz", + "integrity": "sha512-dB7uOnUWsojZIAC6Nwi5v3tuaQNd2i7p4vF5LsJRyoTOgr2fRYQdMKQxRZIZZaz0cTPBX8rvcWU1A6/n7JTITg==", "dev": true, "requires": { "ansi-html": "0.0.7", @@ -14014,9 +13997,9 @@ } }, "webpack-manifest-plugin": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/webpack-manifest-plugin/-/webpack-manifest-plugin-1.1.0.tgz", - "integrity": "sha1-a2xxiq3oolN5lXhLRr0umDYFfKo=", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/webpack-manifest-plugin/-/webpack-manifest-plugin-1.1.2.tgz", + "integrity": "sha512-IdeSoftCEzeVrkTy4XFsYBLQk7fWiKPUKUvSdb68/alpoKHP9X9MhMcYdkCIdAXclUhBIcyL03wFLr76wZG45A==", "dev": true, "requires": { "fs-extra": "0.30.0", diff --git a/package.json b/package.json index ebee9c8fd3..b1ace4c3cb 100644 --- a/package.json +++ b/package.json @@ -118,13 +118,14 @@ "react-router-redux": "^4.0.7", "react-youtube": "^7.0.0", "redux": "^3.0.5", - "redux-actions": "^2.0.2", + "redux-actions": "^2.0.3", "redux-create-types": "0.0.1", - "redux-epic": "^0.2.0", + "redux-epic": "^0.3.0", "redux-form": "^5.2.3", "request": "^2.65.0", "reselect": "^3.0.0", "rx": "~4.0.8", + "rx-dom": "^7.0.3", "sanitize-html": "^1.11.1", "snyk": "^1.30.1", "store": "https://github.com/berkeleytrue/store.js.git#feature/noop-server", diff --git a/server/boot/settings.js b/server/boot/settings.js index 1c4111e33a..5a407b080b 100644 --- a/server/boot/settings.js +++ b/server/boot/settings.js @@ -1,5 +1,6 @@ -import { ifNoUser401 } from '../utils/middleware'; import { isMongoId } from 'validator'; + +import { ifNoUser401 } from '../utils/middleware'; import supportedLanguages from '../../common/utils/supported-languages.js'; export default function settingsController(app) { diff --git a/server/services/map.js b/server/services/map.js index ba83430efd..8b534d903a 100644 --- a/server/services/map.js +++ b/server/services/map.js @@ -34,45 +34,45 @@ function getFirstChallenge(challengeMap$) { // this is a hard search // falls back to soft search -function getChallengeAndBlock( +function getChallenge( challengeDashedName, blockDashedName, challengeMap$, lang ) { return challengeMap$ - .flatMap(({ entities }) => { + .flatMap(({ entities, result: { superBlocks } }) => { const block = entities.block[blockDashedName]; const challenge = entities.challenge[challengeDashedName]; - if ( - !block || - !challenge || - !loadComingSoonOrBetaChallenge(challenge) - ) { - return getChallengeByDashedName( - challengeDashedName, - challengeMap$, - lang - ); - } - return Observable.just({ - redirect: block.dashedName !== blockDashedName ? - `/challenges/${block.dashedName}/${challenge.dashedName}` : - false, - entities: { - challenge: { - [challenge.dashedName]: mapChallengeToLang(challenge, lang) + return Observable.if( + () => ( + !blockDashedName || + !block || + !challenge || + !loadComingSoonOrBetaChallenge(challenge) + ), + getChallengeByDashedName(challengeDashedName, challengeMap$), + Observable.just(challenge) + ) + .map(challenge => ({ + redirect: challenge.block !== blockDashedName ? + `/challenges/${block.dashedName}/${challenge.dashedName}` : + false, + entities: { + challenge: { + [challenge.dashedName]: mapChallengeToLang(challenge, lang) + } + }, + result: { + block: block.dashedName, + challenge: challenge.dashedName, + superBlocks } - }, - result: { - block: block.dashedName, - challenge: challenge.dashedName - } - }); + })); }); } -function getChallengeByDashedName(dashedName, challengeMap$, lang) { +function getChallengeByDashedName(dashedName, challengeMap$) { const challengeName = unDasherize(dashedName) .replace(challengesRegex, ''); const testChallengeName = new RegExp(challengeName, 'i'); @@ -94,40 +94,22 @@ function getChallengeByDashedName(dashedName, challengeMap$, lang) { return Observable.just(challengeOrNull); } return getFirstChallenge(challengeMap$); - }) - .map(challenge => ({ - redirect: - `/challenges/${challenge.block}/${challenge.dashedName}`, - entities: { - challenge: { - [challenge.dashedName]: mapChallengeToLang(challenge, lang) - } - }, - result: { - challenge: challenge.dashedName, - block: challenge.block - } - })); + }); } export default function mapService(app) { const Block = app.models.Block; - const challengeMap$ = cachedMap(Block); + const challengeMap = cachedMap(Block); return { name: 'map', read: (req, resource, { lang, block, dashedName } = {}, config, cb) => { log(`${lang} language requested`); - if (block && dashedName) { - return getChallengeAndBlock(dashedName, block, challengeMap$, lang) - .subscribe(challenge => cb(null, challenge), cb); - } - if (dashedName) { - return getChallengeByDashedName(dashedName, challengeMap$, lang) - .subscribe(challenge => cb(null, challenge), cb); - } - return challengeMap$ - .map(getMapForLang(lang)) - .subscribe(map => cb(null, map), cb); + return Observable.if( + () => !!dashedName, + getChallenge(dashedName, block, challengeMap, lang), + challengeMap.map(getMapForLang(lang)) + ) + .subscribe(results => cb(null, results), cb); } }; } diff --git a/server/utils/map.js b/server/utils/map.js index e530ff1890..d5a4a3ff78 100644 --- a/server/utils/map.js +++ b/server/utils/map.js @@ -21,20 +21,17 @@ const mapSchema = valuesOf(superBlock); let mapObservableCache; /* * interface ChallengeMap { - * result: [superBlockDashedName: String] + * result: { + * superBlocks: [ ...superBlockDashedName: String ] +* }, * entities: { * superBlock: { - * [superBlockDashedName: String]: { - * blocks: [blockDashedName: String] - * } + * [ ...superBlockDashedName: String ]: SuperBlock * }, * block: { - * [blockDashedName: String]: { - * challenges: [challengeDashedName: String] - * } - * }, + * [ ...blockDashedName: String ]: Block, * challenge: { - * [challengeDashedName: String]: Challenge + * [ ...challengeDashedName: String ]: Challenge * } * } * } @@ -88,14 +85,16 @@ export function cachedMap(Block) { }) .map(map => { // re-order superBlocks result - const result = Object.keys(map.result).reduce((result, supName) => { + const superBlocks = Object.keys(map.result).reduce((result, supName) => { const index = map.entities.superBlock[supName].order; result[index] = supName; return result; }, []); return { ...map, - result + result: { + superBlocks + } }; }) .shareReplay(); diff --git a/server/views/partials/react-stylesheets.jade b/server/views/partials/react-stylesheets.jade index 0517a76730..98686bc05d 100644 --- a/server/views/partials/react-stylesheets.jade +++ b/server/views/partials/react-stylesheets.jade @@ -1,9 +1,8 @@ link(rel='stylesheet', type='text/css' href='/css/lato.css') link(rel='stylesheet', type='text/css' href='/css/ubuntu.css') link(rel='stylesheet', href='https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.5.0/css/font-awesome.min.css') -link(rel='stylesheet', href='https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.15.2/codemirror.min.css') -link(rel='stylesheet', href='https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.15.2/addon/lint/lint.min.css') -link(rel='stylesheet', href='https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.15.2/theme/monokai.min.css') +link(rel='stylesheet', href='https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.28.0/codemirror.min.css') +link(rel='stylesheet', href='https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.28.0/addon/lint/lint.min.css') link(rel='stylesheet', href=rev('/css', 'main.css')) include meta