From 42bfa2e64ddddbf81a700a7a8ed2d3e7bc8b57db Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Mon, 31 Jul 2017 20:04:01 -0700 Subject: [PATCH] feat(app): new layout (#14707) * feat(app): Restructure app to be more flexible and redux idiomatic BREAKING CHANGE: Lots of breaking changes * refactor(challenges): Redux to started file structure * fix(app): lint issues due to refactor * fix(settings): Refactor settings to use folder structure * refactor(challenges): Move step redux stuff into step folder * fix(challenges): Remove fetchchallenges actions * refactor(challenges): Move project redux logic into project view subdirectory * refactor(app): %s/sagas/epics/g * refactor(redux): Use new redux-epic with combineEpic and ofType * refactor(app): Move challenge selector to app level * fix(app): Move loading challenge info into challenge route This moves a lot of the logic needed to load challenge info into the challenge app. This decouples the main app from the challenge route * refactor(map): Map is now decoupled from challenges * refactor(challenges): Use selectors everywhere instead of guessing state shape * refactor(client): refactor client epics to use selectors * refactor(app): Refactor userSelector to return user object instead of object.user * refactor(entities): Move entities logic into it's own file * fix(redux): combineTypes should be combineActions * fix(app): reducer namespacing and import * fix(Map): Fix undefined type and update redux-action * fix(redux): Refactor fetchUser to be more declarative Use rxjs methods instead of imperative if/else. Also prevent non-actions from being emitted * fix(redux): toString multi phase action types * fix(redux): typecast multiphase type, fix typo in reducer toString multiphase types in fetch challenge epic. Add epic to epics lists. Fix type in fetch challenge complete handler * fix(redux): updateCurrentChallengelogic should be centerlized Move route changes to one location. * fix(Nav): Prevent event object from hanging around closeDropDown/openDropDown where handing on to the event object. This was causing issues with react since event objects are recycled in React. * fix(Map.Challenge): decouple map selector * fix(Map): Decouple panel selectors from props Panel Selectors no longer need to know the shape of a components props. Refactored component selectors to decouple them entities state shape * fix(Map.redux): Add select challenge epic and connect map epics * fix(redux.analytics): Fix meta creator and nav/map events * fix(redux): Update current challenge ajax * fix(challenges): ssr fetch challenge should update challenge ui Was using an epic to update challenge ui on fetch complete, but this was not working on ssr due to the way ssr disables epics to wait for completion. This commit fixes this by causing the complete to directly update state in the challenge ui * fix(challenges): wrong import of types, refactor epic name * fix(redux): Prevent fetch challenge epic from emitting null to dispatch * fix(redux): prevent executechallenge from emitting null * fix(challenges.redux): testsSelector returns just tests * fix(challenges.redux): Prevent completion challenge from emitting null * refactor(Challenges.Step): Refactor step challenge to release event object * fix(redux): wrap reducers in factories 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. * fix(Map): createUi not working correctly map utils should receive just map ui state, createMapUi needs to add title to challenge * feat(Challenges): Adds Panes and panes backend challenge * fix: Create child container to wrap children Create a ChildContainer comp' to wrap all children that represent the view for the current route. This let's the child route define if they want a full width view or if they want the standard max-width view. * feat(Panes): panes now render dividers * feat(Panes): Get divider to move currectly * fix(Nav): Add top margin to contained childs Move margin-bottom from nav to child container as margin top. This let's the jsbin style views fit snug with navbar * fix(Panes): Should be contained within their borders * feat(Panes): Update navbar height of pane on app mount * feat(Panes): Toggle map on map nav btn click * fix(gulpfile): Ensure nodemon exits on restart On process exit, wait for nodemon to shutdown before process.exit * feat(Panes): Make Panes redux first * fix(Panes): Fix divider positioning * fix(Panes): Update divider moved handler dividerMoved action now uses new panesByName structure * feat(Panes): Pane nav button will hide panes * chore(package-lock): Update package lock * feat(Panes.redux): Recaculate dividers on pane toggle * fix(Challenges): Update challenge on dashedName change This fixes backwards navigation not updating the redux state current challenge * feat(Panes.redux): Clear panes on unmount Clearing panes on unmount will clear bin buttons in nav * refactor(Map): Colocate styles * feat(Map): New map layout * fix(Map): No longer has it's own page * fix: FetchChallenges on appMounted * feat: Normalize fetchChallenge(s) results This allows superblocks to be sent with both fetchChallenge and fetchChallenges so the map is always populated on first load * feat(Map): Show blocks on first load * fix(less): Remove old css * feat(Nav): Reduce nav height * fix(Nav): Render nav after content Render nav after content and use css to reverse again on screen. We do this so the panes can render first and update redux panes state which will then update the nav ui state before nav has a chance to render * fix(Panes): Add container This adds a Panes Container that will allow it to udpate redux state so Panes Component will have redux state ready to actually render panes * feat(Challenges.Classic): Add panes * fix(Challenge.Classic): Editor onchange should not need to know about file * fix(Panes): Index on panes hide should account for hidden pane * fix(Challanges.Classic): Fix panes types * fix(Challenges): Add completion modal to all challenges Change classic modal to completion modal * fix(Panes): Dividers live on top of planes * fix(Challenges): Remove codemirror theme Remove codemirror theme and remove borders from preview frame * fix(Challenges.Classic): Remove old component * feat(Challenges.Step): Add panes to step challenge * feat(Challenges.Project): Add panes to projects * fix(Challenges.Projects): Remove row * fix(Modals): Move modal text color to challenge less This text color is dependent on the actual header color * fix(Map): Use Superblock title for ui * fix(Map): Reduce panel header height * fix(app): Capitalize Toasts folder Feature folders should be campitalized * chore(Map): Remove unused epic file * fix(Step): Fix tests * test(Map): Update createMapUi tests input --- client/{sagas => epics}/README.md | 0 .../analytics-epic.js} | 5 +- .../code-storage-epic.js} | 74 +- .../{sagas/err-saga.js => epics/err-epic.js} | 7 +- .../execute-challenge-epic.js} | 34 +- client/{sagas => epics}/frame-epic.js | 21 +- client/epics/hard-go-to-epic.js | 10 + client/epics/index.js | 21 + .../mouse-trap-epic.js} | 8 +- .../night-mode-epic.js} | 18 +- client/epics/title-epic.js | 10 + client/index.js | 10 +- client/less/code-mirror.less | 16 +- client/less/lib/bootstrap/modals.less | 1 - client/less/lib/bootstrap/navbar.less | 6 +- client/less/lib/bootstrap/variables.less | 3 +- client/less/main.less | 285 +-- client/less/map.less | 238 -- client/less/sk-wave.less | 36 - client/less/skeleton-shimmer.less | 41 - client/sagas/hard-go-to-saga.js | 11 - client/sagas/index.js | 21 - client/sagas/title-saga.js | 10 - client/utils/flash-to-toast.js | 2 +- common/app/App.jsx | 108 +- common/app/Child-Container.jsx | 24 + .../challenges/views/map => Map}/Block.jsx | 64 +- .../views/map => Map}/Challenge.jsx | 117 +- common/app/Map/Map.jsx | 54 + .../views/map => Map}/Super-Block.jsx | 43 +- .../challenges/views/map => Map}/index.js | 0 common/app/Map/map.less | 118 + common/app/Map/ns.json | 1 + common/app/Map/redux/index.js | 129 ++ common/app/Map/redux/select-challenge-epic.js | 10 + common/app/Map/redux/utils.js | 159 ++ common/app/Map/redux/utils.test.js | 215 ++ common/app/Nav/Bin-Button.jsx | 19 + common/app/Nav/Nav.jsx | 249 ++ common/app/Nav/Sign-Up.jsx | 54 + common/app/{components => }/Nav/index.js | 0 common/app/{components => }/Nav/links.json | 7 +- common/app/Nav/nav.less | 196 ++ common/app/Nav/ns.json | 1 + common/app/Nav/redux/bin-epic.js | 10 + common/app/Nav/redux/index.js | 79 + .../Nav/redux/load-current-challenge-epic.js | 61 + .../index.jsx => NotFound/Not-Found.jsx} | 4 +- common/app/NotFound/index.js | 1 + common/app/Panes/Divider.jsx | 50 + common/app/Panes/Pane.jsx | 38 + common/app/Panes/Panes-Container.jsx | 58 + common/app/Panes/Panes.jsx | 111 + common/app/Panes/index.js | 1 + common/app/Panes/ns.json | 1 + common/app/Panes/redux/divider-epic.js | 40 + common/app/Panes/redux/index.js | 236 ++ .../app/Panes/redux/utils.js | 0 common/app/Panes/redux/window-epic.js | 21 + common/app/{toasts => Toasts}/Toasts.jsx | 4 +- common/app/Toasts/index.js | 1 + common/app/Toasts/ns.json | 1 + common/app/Toasts/redux/index.js | 45 + common/app/app.less | 13 +- .../components/Nav/Avatar-Points-Nav-Item.jsx | 36 - common/app/components/Nav/Nav.jsx | 205 -- common/app/components/README.md | 1 - common/app/create-app.jsx | 61 +- common/app/create-panes-map.js | 7 + common/app/create-reducer.js | 51 +- common/app/create-routes.js | 9 + common/app/entities/index.js | 163 ++ common/app/epics.js | 15 + common/app/index.less | 2 + common/app/redux/actions.js | 147 -- common/app/redux/entities-reducer.js | 98 - common/app/redux/fetch-challenges-epic.js | 86 + ...{fetch-user-saga.js => fetch-user-epic.js} | 22 +- common/app/redux/index.js | 285 ++- .../app/redux/load-current-challenge-saga.js | 92 - common/app/redux/nav-size-epic.js | 13 + common/app/redux/reducer.js | 56 - common/app/redux/selectors.js | 38 - common/app/redux/types.js | 37 - common/app/redux/update-my-challenge-epic.js | 48 + common/app/redux/utils.js | 33 + common/app/redux/utils.test.js | 92 + common/app/routes/challenges/Bug-Modal.jsx | 9 +- .../challenges/Code-Mirror-Skeleton.jsx | 4 +- .../routes/challenges/Completion-Modal.jsx | 104 + common/app/routes/challenges/Show.jsx | 73 +- common/app/routes/challenges/challenges.less | 82 + common/app/routes/challenges/index.js | 31 +- common/app/routes/challenges/redux/actions.js | 146 -- .../routes/challenges/redux/answer-saga.js | 86 - .../redux/{bug-saga.js => bug-epic.js} | 28 +- ...xt-challenge-saga.js => challenge-epic.js} | 72 +- ...{completion-saga.js => completion-epic.js} | 92 +- .../routes/challenges/redux/editor-epic.js | 17 + .../challenges/redux/fetch-challenges-saga.js | 89 - common/app/routes/challenges/redux/index.js | 383 +++- .../routes/challenges/redux/map-ui-saga.js | 34 - common/app/routes/challenges/redux/reducer.js | 333 --- .../challenges/redux/reset-challenge-saga.js | 22 - .../app/routes/challenges/redux/selectors.js | 54 - common/app/routes/challenges/redux/types.js | 82 - common/app/routes/challenges/utils.js | 263 --- common/app/routes/challenges/utils.test.js | 2031 +++++++---------- .../challenges/views/backend/Back-End.jsx | 23 +- .../routes/challenges/views/backend/Show.jsx | 36 + .../routes/challenges/views/backend/index.js | 2 +- .../views/classic/Classic-Modal.jsx | 83 - .../challenges/views/classic/Classic.jsx | 154 -- .../challenges/views/classic/Editor.jsx | 87 +- .../routes/challenges/views/classic/Show.jsx | 44 + .../challenges/views/classic/Side-Panel.jsx | 37 +- .../challenges/views/classic/classic.less | 62 +- .../routes/challenges/views/classic/index.js | 2 +- .../routes/challenges/views/map/Header.jsx | 120 - .../app/routes/challenges/views/map/Map.jsx | 76 - .../routes/challenges/views/project/Forms.jsx | 3 +- .../challenges/views/project/Project.jsx | 60 +- .../routes/challenges/views/project/Show.jsx | 34 + .../challenges/views/project/Tool-Panel.jsx | 27 +- .../routes/challenges/views/project/index.js | 2 +- .../routes/challenges/views/project/ns.json | 1 + .../challenges/views/project/redux/index.js | 27 + .../project}/redux/project-normalizer.js | 2 +- .../app/routes/challenges/views/step/Show.jsx | 33 + .../app/routes/challenges/views/step/Step.jsx | 108 +- .../app/routes/challenges/views/step/index.js | 2 +- .../challenges/views/step/redux/index.js | 92 + .../step}/redux/step-challenge-epic.js | 28 +- .../step}/redux/step-challenge-epic.test.js | 35 +- .../routes/challenges/views/video/Lecture.jsx | 112 - .../challenges/views/video/Questions.jsx | 192 -- .../routes/challenges/views/video/Video.jsx | 83 - .../routes/challenges/views/video/index.js | 1 - common/app/routes/index.js | 26 +- common/app/routes/map/index.js | 8 + common/app/routes/redux.js | 7 + .../{components => }/Email-Setting.jsx | 0 .../{components => }/Job-Settings.jsx | 0 .../{components => }/Language-Settings.jsx | 8 +- .../{components => }/Locked-Settings.jsx | 0 ...ingsSkeleton.jsx => Settings-Skeleton.jsx} | 2 +- .../settings/{components => }/Settings.jsx | 57 +- .../{components => }/Social-Settings.jsx | 0 common/app/routes/settings/index.js | 10 +- common/app/routes/settings/redux/actions.js | 16 - common/app/routes/settings/redux/index.js | 23 +- common/app/routes/settings/redux/selectors.js | 0 ...pdate-user-saga.js => update-user-epic.js} | 61 +- .../routes/update-email/Update-Email.jsx | 120 +- .../settings/routes/update-email/index.js | 4 +- common/app/sagas.js | 9 - common/app/toasts/redux/actions.js | 20 - common/app/toasts/redux/index.js | 3 - common/app/toasts/redux/reducer.js | 13 - common/app/toasts/redux/types.js | 6 - common/utils/combine-sagas.js | 9 - common/utils/get-actions-of-type.js | 27 - common/utils/get-first-challenge.js | 8 +- gulpfile.js | 10 +- package-lock.json | 507 ++-- package.json | 5 +- server/boot/settings.js | 3 +- server/services/map.js | 88 +- server/utils/map.js | 21 +- server/views/partials/react-stylesheets.jade | 5 +- 170 files changed, 5649 insertions(+), 5858 deletions(-) rename client/{sagas => epics}/README.md (100%) rename client/{sagas/analytics-saga.js => epics/analytics-epic.js} (87%) rename client/{sagas/code-storage-saga.js => epics/code-storage-epic.js} (67%) rename client/{sagas/err-saga.js => epics/err-epic.js} (51%) rename client/{sagas/build-challenge-epic.js => epics/execute-challenge-epic.js} (63%) rename client/{sagas => epics}/frame-epic.js (88%) create mode 100644 client/epics/hard-go-to-epic.js create mode 100644 client/epics/index.js rename client/{sagas/mouse-trap-saga.js => epics/mouse-trap-epic.js} (83%) rename client/{sagas/night-mode-saga.js => epics/night-mode-epic.js} (82%) create mode 100644 client/epics/title-epic.js delete mode 100644 client/less/map.less delete mode 100644 client/less/sk-wave.less delete mode 100644 client/less/skeleton-shimmer.less delete mode 100644 client/sagas/hard-go-to-saga.js delete mode 100644 client/sagas/index.js delete mode 100644 client/sagas/title-saga.js create mode 100644 common/app/Child-Container.jsx rename common/app/{routes/challenges/views/map => Map}/Block.jsx (68%) rename common/app/{routes/challenges/views/map => Map}/Challenge.jsx (65%) create mode 100644 common/app/Map/Map.jsx rename common/app/{routes/challenges/views/map => Map}/Super-Block.jsx (74%) rename common/app/{routes/challenges/views/map => Map}/index.js (100%) create mode 100644 common/app/Map/map.less create mode 100644 common/app/Map/ns.json create mode 100644 common/app/Map/redux/index.js create mode 100644 common/app/Map/redux/select-challenge-epic.js create mode 100644 common/app/Map/redux/utils.js create mode 100644 common/app/Map/redux/utils.test.js create mode 100644 common/app/Nav/Bin-Button.jsx create mode 100644 common/app/Nav/Nav.jsx create mode 100644 common/app/Nav/Sign-Up.jsx rename common/app/{components => }/Nav/index.js (100%) rename common/app/{components => }/Nav/links.json (92%) create mode 100644 common/app/Nav/nav.less create mode 100644 common/app/Nav/ns.json create mode 100644 common/app/Nav/redux/bin-epic.js create mode 100644 common/app/Nav/redux/index.js create mode 100644 common/app/Nav/redux/load-current-challenge-epic.js rename common/app/{components/NotFound/index.jsx => NotFound/Not-Found.jsx} (90%) create mode 100644 common/app/NotFound/index.js create mode 100644 common/app/Panes/Divider.jsx create mode 100644 common/app/Panes/Pane.jsx create mode 100644 common/app/Panes/Panes-Container.jsx create mode 100644 common/app/Panes/Panes.jsx create mode 100644 common/app/Panes/index.js create mode 100644 common/app/Panes/ns.json create mode 100644 common/app/Panes/redux/divider-epic.js create mode 100644 common/app/Panes/redux/index.js rename client/less/challenge.less => common/app/Panes/redux/utils.js (100%) create mode 100644 common/app/Panes/redux/window-epic.js rename common/app/{toasts => Toasts}/Toasts.jsx (96%) create mode 100644 common/app/Toasts/index.js create mode 100644 common/app/Toasts/ns.json create mode 100644 common/app/Toasts/redux/index.js delete mode 100644 common/app/components/Nav/Avatar-Points-Nav-Item.jsx delete mode 100644 common/app/components/Nav/Nav.jsx delete mode 100644 common/app/components/README.md create mode 100644 common/app/create-panes-map.js create mode 100644 common/app/create-routes.js create mode 100644 common/app/entities/index.js create mode 100644 common/app/epics.js delete mode 100644 common/app/redux/actions.js delete mode 100644 common/app/redux/entities-reducer.js create mode 100644 common/app/redux/fetch-challenges-epic.js rename common/app/redux/{fetch-user-saga.js => fetch-user-epic.js} (61%) delete mode 100644 common/app/redux/load-current-challenge-saga.js create mode 100644 common/app/redux/nav-size-epic.js delete mode 100644 common/app/redux/reducer.js delete mode 100644 common/app/redux/selectors.js delete mode 100644 common/app/redux/types.js create mode 100644 common/app/redux/update-my-challenge-epic.js create mode 100644 common/app/redux/utils.js create mode 100644 common/app/redux/utils.test.js create mode 100644 common/app/routes/challenges/Completion-Modal.jsx delete mode 100644 common/app/routes/challenges/redux/actions.js delete mode 100644 common/app/routes/challenges/redux/answer-saga.js rename common/app/routes/challenges/redux/{bug-saga.js => bug-epic.js} (81%) rename common/app/routes/challenges/redux/{next-challenge-saga.js => challenge-epic.js} (58%) rename common/app/routes/challenges/redux/{completion-saga.js => completion-epic.js} (63%) create mode 100644 common/app/routes/challenges/redux/editor-epic.js delete mode 100644 common/app/routes/challenges/redux/fetch-challenges-saga.js delete mode 100644 common/app/routes/challenges/redux/map-ui-saga.js delete mode 100644 common/app/routes/challenges/redux/reducer.js delete mode 100644 common/app/routes/challenges/redux/reset-challenge-saga.js delete mode 100644 common/app/routes/challenges/redux/selectors.js delete mode 100644 common/app/routes/challenges/redux/types.js create mode 100644 common/app/routes/challenges/views/backend/Show.jsx delete mode 100644 common/app/routes/challenges/views/classic/Classic-Modal.jsx delete mode 100644 common/app/routes/challenges/views/classic/Classic.jsx create mode 100644 common/app/routes/challenges/views/classic/Show.jsx delete mode 100644 common/app/routes/challenges/views/map/Header.jsx delete mode 100644 common/app/routes/challenges/views/map/Map.jsx create mode 100644 common/app/routes/challenges/views/project/Show.jsx create mode 100644 common/app/routes/challenges/views/project/ns.json create mode 100644 common/app/routes/challenges/views/project/redux/index.js rename common/app/routes/challenges/{ => views/project}/redux/project-normalizer.js (73%) create mode 100644 common/app/routes/challenges/views/step/Show.jsx create mode 100644 common/app/routes/challenges/views/step/redux/index.js rename common/app/routes/challenges/{ => views/step}/redux/step-challenge-epic.js (62%) rename common/app/routes/challenges/{ => views/step}/redux/step-challenge-epic.test.js (85%) delete mode 100644 common/app/routes/challenges/views/video/Lecture.jsx delete mode 100644 common/app/routes/challenges/views/video/Questions.jsx delete mode 100644 common/app/routes/challenges/views/video/Video.jsx delete mode 100644 common/app/routes/challenges/views/video/index.js create mode 100644 common/app/routes/map/index.js create mode 100644 common/app/routes/redux.js rename common/app/routes/settings/{components => }/Email-Setting.jsx (100%) rename common/app/routes/settings/{components => }/Job-Settings.jsx (100%) rename common/app/routes/settings/{components => }/Language-Settings.jsx (92%) rename common/app/routes/settings/{components => }/Locked-Settings.jsx (100%) rename common/app/routes/settings/{components/SettingsSkeleton.jsx => Settings-Skeleton.jsx} (98%) rename common/app/routes/settings/{components => }/Settings.jsx (91%) rename common/app/routes/settings/{components => }/Social-Settings.jsx (100%) delete mode 100644 common/app/routes/settings/redux/actions.js delete mode 100644 common/app/routes/settings/redux/selectors.js rename common/app/routes/settings/redux/{update-user-saga.js => update-user-epic.js} (69%) delete mode 100644 common/app/sagas.js delete mode 100644 common/app/toasts/redux/actions.js delete mode 100644 common/app/toasts/redux/index.js delete mode 100644 common/app/toasts/redux/reducer.js delete mode 100644 common/app/toasts/redux/types.js delete mode 100644 common/utils/combine-sagas.js delete mode 100644 common/utils/get-actions-of-type.js 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