From 1d420b835c69132812176a08c2610a9ed84f5646 Mon Sep 17 00:00:00 2001 From: Stuart Taylor Date: Tue, 20 Feb 2018 13:07:32 +0000 Subject: [PATCH 1/7] feat(mapUi): Create mapUi specific service --- common/app/Map/redux/fetch-map-ui-epic.js | 46 ++++++++++++++ common/app/Map/redux/index.js | 19 ++++-- common/app/entities/index.js | 7 +-- common/app/redux/fetch-challenges-epic.js | 3 +- server/boot/a-services.js | 3 + server/services/mapUi.js | 56 +++++++++++++++++ server/utils/map.js | 74 +++++++++++------------ 7 files changed, 158 insertions(+), 50 deletions(-) create mode 100644 common/app/Map/redux/fetch-map-ui-epic.js create mode 100644 server/services/mapUi.js diff --git a/common/app/Map/redux/fetch-map-ui-epic.js b/common/app/Map/redux/fetch-map-ui-epic.js new file mode 100644 index 0000000000..875f617683 --- /dev/null +++ b/common/app/Map/redux/fetch-map-ui-epic.js @@ -0,0 +1,46 @@ +import { ofType } from 'redux-epic'; +import debug from 'debug'; + +import { + types as appTypes, + createErrorObservable +} from '../../redux'; +import { types, fetchMapUiComplete } from './'; +import { langSelector } from '../../Router/redux'; +import { shapeChallenges } from '../../redux/utils'; + +const isDev = debug.enabled('fcc:*'); + +export default function fetchMapUiEpic( + actions, + { getState }, + { services } +) { + return actions::ofType( + appTypes.appMounted + ) + .flatMapLatest(() => { + const lang = langSelector(getState()); + const options = { + params: { lang }, + service: 'map-ui' + }; + return services.readService$(options) + .retry(3) + .map(({ entities, ...res }) => ({ + entities: shapeChallenges( + entities, + isDev + ), + ...res + })) + .map(({ entities, result } = {}) => { + return fetchMapUiComplete( + entities, + result + ); + }) + .startWith({ type: types.fetchMapUi.start }) + .catch(createErrorObservable); + }); + } diff --git a/common/app/Map/redux/index.js b/common/app/Map/redux/index.js index 62b9d15b95..adff2aa4b1 100644 --- a/common/app/Map/redux/index.js +++ b/common/app/Map/redux/index.js @@ -1,25 +1,26 @@ import { createAction, + createAsyncTypes, createTypes, handleActions } from 'berkeleys-redux-utils'; import { createSelector } from 'reselect'; -import noop from 'lodash/noop'; -import capitalize from 'lodash/capitalize'; +import { capitalize, noop} from 'lodash'; import * as utils from './utils.js'; import ns from '../ns.json'; import { - types as app, createEventMetaCreator } from '../../redux'; -export const epics = []; +import fewtchMapUiEpic from './fetch-map-ui-epic'; + +export const epics = [ fewtchMapUiEpic ]; export const types = createTypes([ 'onRouteMap', 'initMap', - + createAsyncTypes('fetchMapUi'), 'toggleThisPanel', 'isAllCollapsed', @@ -31,6 +32,12 @@ export const types = createTypes([ export const initMap = createAction(types.initMap); +export const fetchMapUiComplete = createAction( + types.fetchMapUi.complete, + (entities, result) => ({ entities, result }), + entities => ({ entities }) +); + export const toggleThisPanel = createAction(types.toggleThisPanel); export const collapseAll = createAction(types.collapseAll); @@ -100,7 +107,7 @@ export default handleActions( mapUi }; }, - [app.fetchChallenges.complete]: (state, { payload }) => { + [types.fetchMapUi.complete]: (state, { payload }) => { const { entities, result } = payload; return { ...state, diff --git a/common/app/entities/index.js b/common/app/entities/index.js index d8dae89a97..b58d8eeb89 100644 --- a/common/app/entities/index.js +++ b/common/app/entities/index.js @@ -1,4 +1,4 @@ -import { findIndex, invert, pick, property } from 'lodash'; +import { findIndex, invert, pick, property, merge } from 'lodash'; import uuid from 'uuid/v4'; import { composeReducers, @@ -162,10 +162,7 @@ export default composeReducers( } }; } - return { - ...state, - ...action.meta.entities - }; + return merge(state, action.meta.entities); } return state; }, diff --git a/common/app/redux/fetch-challenges-epic.js b/common/app/redux/fetch-challenges-epic.js index 6873507cc6..733223b4ee 100644 --- a/common/app/redux/fetch-challenges-epic.js +++ b/common/app/redux/fetch-challenges-epic.js @@ -19,7 +19,7 @@ import { langSelector } from '../Router/redux'; const isDev = debug.enabled('fcc:*'); -export function fetchChallengeEpic(actions, { getState }, { services }) { +export default function fetchChallengeEpic(actions, { getState }, { services }) { return actions::ofType(challenge.onRouteChallenges) .filter(({ payload }) => !isChallengeLoaded(getState(), payload)) .flatMapLatest(({ payload: params }) => { @@ -84,4 +84,3 @@ export function fetchChallengesEpic( }); } -export default combineEpics(fetchChallengeEpic, fetchChallengesEpic); diff --git a/server/boot/a-services.js b/server/boot/a-services.js index f9bdeb60c1..777b367fce 100644 --- a/server/boot/a-services.js +++ b/server/boot/a-services.js @@ -1,12 +1,15 @@ import Fetchr from 'fetchr'; import getUserServices from '../services/user'; import getMapServices from '../services/map'; +import getMapUiServices from '../services/mapUi'; export default function bootServices(app) { const userServices = getUserServices(app); const mapServices = getMapServices(app); + const mapUiServices = getMapUiServices(app); Fetchr.registerFetcher(userServices); Fetchr.registerFetcher(mapServices); + Fetchr.registerFetcher(mapUiServices); app.use('/services', Fetchr.middleware()); } diff --git a/server/services/mapUi.js b/server/services/mapUi.js new file mode 100644 index 0000000000..b4cf0d46ac --- /dev/null +++ b/server/services/mapUi.js @@ -0,0 +1,56 @@ +import debug from 'debug'; +import { Observable } from 'rx'; + +import { cachedMap, getMapForLang } from '../utils/map'; + +const log = debug('fcc:services:mapUi'); + +export default function mapUiService(app) { + const challengeMap = cachedMap(app.models); + return { + name: 'map-ui', + read: function readMapUi(req, resource, { lang = 'en' } = {}, config, cb) { + log(`generating mapUi for ${lang}`); + return challengeMap.map(getMapForLang(lang)) + .flatMap(({ + result: { superBlocks }, + entities: { + superBlock: fullSuperBlockMap, + block: fullBlockMap, + challenge: fullChallengeMap + } + }) => { + const superBlockMap = superBlocks + .map(superBlock => fullSuperBlockMap[superBlock]) + .reduce((map, { dashedName, blocks, title }) => { + map[dashedName] = { blocks, title, dashedName}; + return map; + }, {}); + const blockMap = Object.keys(fullBlockMap) + .map(block => fullBlockMap[block]) + .reduce((map, { dashedName, title, time, challenges }) => { + map[dashedName] = { dashedName, title, time, challenges }; + return map; + }, {}); + const challengeMap = Object.keys(fullChallengeMap) + .map(challenge => fullChallengeMap[challenge]) + .reduce((map, { dashedName, name, id}) => { + map[dashedName] = {name, dashedName, id}; + return map; + }, {}); + + return Observable.of({ + result: { superBlocks }, + entities: { + superBlock: superBlockMap, + block: blockMap, + challenge: challengeMap + } + }); + }).subscribe( + mapUi => cb(null, mapUi ), + err => { log(err); return cb(err); } + ); + } + }; +} diff --git a/server/utils/map.js b/server/utils/map.js index 99b67d159a..b7483ee961 100644 --- a/server/utils/map.js +++ b/server/utils/map.js @@ -74,43 +74,43 @@ export function _cachedMap({ Block, Challenge }) { }, blocksMap); }); const superBlockMap = blocks.map(blocks => blocks.reduce((map, block) => { - if ( - map[block.superBlock] && - map[block.superBlock].blocks - ) { - map[block.superBlock].blocks.push(block.dashedName); - } else { - map[block.superBlock] = { - title: _.startCase(block.superBlock), - order: block.superOrder, - name: nameify(_.startCase(block.superBlock)), - dashedName: block.superBlock, - blocks: [block.dashedName], - message: block.superBlockMessage - }; - } - return map; - }, {})); - const superBlocks = superBlockMap.map(superBlockMap => { - return Object.keys(superBlockMap) - .map(key => superBlockMap[key]) - .map(({ dashedName }) => dashedName); - }); - return Observable.combineLatest( - superBlockMap, - blockMap, - challengeMap, - superBlocks, - (superBlock, block, challenge, superBlocks) => ({ - entities: { - superBlock, - block, - challenge - }, - result: { - superBlocks - } - }) + if ( + map[block.superBlock] && + map[block.superBlock].blocks + ) { + map[block.superBlock].blocks.push(block.dashedName); + } else { + map[block.superBlock] = { + title: _.startCase(block.superBlock), + order: block.superOrder, + name: nameify(_.startCase(block.superBlock)), + dashedName: block.superBlock, + blocks: [block.dashedName], + message: block.superBlockMessage + }; + } + return map; + }, {})); + const superBlocks = superBlockMap.map(superBlockMap => { + return Object.keys(superBlockMap) + .map(key => superBlockMap[key]) + .map(({ dashedName }) => dashedName); + }); + return Observable.combineLatest( + superBlockMap, + blockMap, + challengeMap, + superBlocks, + (superBlock, block, challenge, superBlocks) => ({ + entities: { + superBlock, + block, + challenge + }, + result: { + superBlocks + } + }) ) .do(checkMapData) .shareReplay(); From d17c2d33ebd8cd80462e95106e80de8256a67679 Mon Sep 17 00:00:00 2001 From: Stuart Taylor Date: Tue, 20 Feb 2018 13:45:43 +0000 Subject: [PATCH 2/7] chore(superBlock): Remove superBlock.message Ui elements --- common/app/Map/Super-Block.jsx | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/common/app/Map/Super-Block.jsx b/common/app/Map/Super-Block.jsx index 6d7038d2a3..c5ce006263 100644 --- a/common/app/Map/Super-Block.jsx +++ b/common/app/Map/Super-Block.jsx @@ -27,8 +27,7 @@ function mapStateToProps(_, { dashedName }) { isOpen, dashedName, title: superBlock.title || dashedName, - blocks: superBlock.blocks || [], - message: superBlock.message + blocks: superBlock.blocks || [] }) ); } @@ -37,7 +36,6 @@ const propTypes = { blocks: PropTypes.array, dashedName: PropTypes.string, isOpen: PropTypes.bool, - message: PropTypes.string, title: PropTypes.string, toggleThisPanel: PropTypes.func }; @@ -52,17 +50,6 @@ export class SuperBlock extends PureComponent { this.props.toggleThisPanel(eventKey); } - renderMessage(message) { - if (!message) { - return null; - } - return ( -
- { message } -
- ); - } - renderHeader(isOpen, title, isCompleted) { return (
@@ -81,7 +68,6 @@ export class SuperBlock extends PureComponent { title, dashedName, blocks, - message, isOpen } = this.props; return ( @@ -95,7 +81,6 @@ export class SuperBlock extends PureComponent { key={ dashedName || title } onSelect={ this.handleSelect } > - { this.renderMessage(message) } ); From a7587ed6f0f09ea3f3d43220c8ec13bae224d51c Mon Sep 17 00:00:00 2001 From: Stuart Taylor Date: Fri, 23 Feb 2018 12:20:13 +0000 Subject: [PATCH 3/7] feat(challenge): Initial build of the challenge service --- common/app/Map/Block.jsx | 14 +++-- common/app/Map/Map.jsx | 18 +++++-- common/app/Map/redux/fetch-map-ui-epic.js | 11 ++-- common/app/Map/redux/index.js | 8 ++- common/app/entities/index.js | 27 ++++++++-- common/app/redux/fetch-challenges-epic.js | 47 ++++++++-------- common/app/redux/index.js | 53 +++++++++++-------- common/app/redux/utils.js | 3 +- common/app/routes/Challenges/Show.jsx | 6 ++- .../Settings/components/Internet-Settings.jsx | 1 - server/boot/a-services.js | 17 +++--- server/services/challenge.js | 53 +++++++++++++++++++ server/services/mapUi.js | 36 +++++++++++-- server/utils/map.js | 52 ++++++++++++++++++ 14 files changed, 263 insertions(+), 83 deletions(-) create mode 100644 server/services/challenge.js diff --git a/common/app/Map/Block.jsx b/common/app/Map/Block.jsx index 5f72d6f1f8..1387faa0bd 100644 --- a/common/app/Map/Block.jsx +++ b/common/app/Map/Block.jsx @@ -9,13 +9,16 @@ import ns from './ns.json'; import Challenges from './Challenges.jsx'; import { toggleThisPanel, - makePanelOpenSelector } from './redux'; +import { fetchNewBlock } from '../redux'; import { makeBlockSelector } from '../entities'; -const dispatchActions = { toggleThisPanel }; +const mapDispatchToProps = { + fetchNewBlock, + toggleThisPanel +}; function makeMapStateToProps(_, { dashedName }) { return createSelector( makeBlockSelector(dashedName), @@ -34,6 +37,7 @@ function makeMapStateToProps(_, { dashedName }) { const propTypes = { challenges: PropTypes.array, dashedName: PropTypes.string, + fetchNewBlock: PropTypes.func.isRequired, isOpen: PropTypes.bool, time: PropTypes.string, title: PropTypes.string, @@ -74,7 +78,8 @@ export class Block extends PureComponent { time, dashedName, isOpen, - challenges + challenges, + fetchNewBlock } = this.props; return ( fetchNewBlock(dashedName) } onSelect={ this.handleSelect } > { isOpen && } @@ -96,4 +102,4 @@ export class Block extends PureComponent { Block.displayName = 'Block'; Block.propTypes = propTypes; -export default connect(makeMapStateToProps, dispatchActions)(Block); +export default connect(makeMapStateToProps, mapDispatchToProps)(Block); diff --git a/common/app/Map/Map.jsx b/common/app/Map/Map.jsx index 056bab9926..b942992e5e 100644 --- a/common/app/Map/Map.jsx +++ b/common/app/Map/Map.jsx @@ -4,23 +4,32 @@ import { connect } from 'react-redux'; import { Col, Row } from 'react-bootstrap'; import ns from './ns.json'; +import { Loader } from '../helperComponents'; import SuperBlock from './Super-Block.jsx'; import { superBlocksSelector } from '../redux'; +import { fetchMapUi } from './redux'; const mapStateToProps = state => ({ superBlocks: superBlocksSelector(state) }); -const mapDispatchToProps = {}; +const mapDispatchToProps = { fetchMapUi }; const propTypes = { + fetchMapUi: PropTypes.func.isRequired, params: PropTypes.object, superBlocks: PropTypes.array }; export class ShowMap extends PureComponent { - renderSuperBlocks(superBlocks) { + + renderSuperBlocks() { + const { superBlocks } = this.props; if (!Array.isArray(superBlocks) || !superBlocks.length) { - return
No Super Blocks
; + return ( +
+ +
+ ); } return superBlocks.map(dashedName => (
- { this.renderSuperBlocks(superBlocks) } + { this.renderSuperBlocks() }
diff --git a/common/app/Map/redux/fetch-map-ui-epic.js b/common/app/Map/redux/fetch-map-ui-epic.js index 875f617683..9b129f8560 100644 --- a/common/app/Map/redux/fetch-map-ui-epic.js +++ b/common/app/Map/redux/fetch-map-ui-epic.js @@ -17,7 +17,8 @@ export default function fetchMapUiEpic( { services } ) { return actions::ofType( - appTypes.appMounted + appTypes.appMounted, + types.fetchMapUi.start ) .flatMapLatest(() => { const lang = langSelector(getState()); @@ -34,13 +35,7 @@ export default function fetchMapUiEpic( ), ...res })) - .map(({ entities, result } = {}) => { - return fetchMapUiComplete( - entities, - result - ); - }) - .startWith({ type: types.fetchMapUi.start }) + .map(fetchMapUiComplete) .catch(createErrorObservable); }); } diff --git a/common/app/Map/redux/index.js b/common/app/Map/redux/index.js index adff2aa4b1..342c53bcad 100644 --- a/common/app/Map/redux/index.js +++ b/common/app/Map/redux/index.js @@ -32,11 +32,8 @@ export const types = createTypes([ export const initMap = createAction(types.initMap); -export const fetchMapUiComplete = createAction( - types.fetchMapUi.complete, - (entities, result) => ({ entities, result }), - entities => ({ entities }) -); +export const fetchMapUi = createAction(types.fetchMapUi.start); +export const fetchMapUiComplete = createAction(types.fetchMapUi.complete); export const toggleThisPanel = createAction(types.toggleThisPanel); export const collapseAll = createAction(types.collapseAll); @@ -111,6 +108,7 @@ export default handleActions( const { entities, result } = payload; return { ...state, + ...result, mapUi: utils.createMapUi(entities, result) }; } diff --git a/common/app/entities/index.js b/common/app/entities/index.js index b58d8eeb89..8a85e99a11 100644 --- a/common/app/entities/index.js +++ b/common/app/entities/index.js @@ -1,6 +1,7 @@ -import { findIndex, invert, pick, property, merge } from 'lodash'; +import { findIndex, invert, pick, property, merge, union } from 'lodash'; import uuid from 'uuid/v4'; import { + combineActions, composeReducers, createAction, createTypes, @@ -8,8 +9,9 @@ import { } from 'berkeleys-redux-utils'; import { themes } from '../../utils/themes'; +import { usernameSelector, types as app } from '../redux'; import { types as challenges } from '../routes/Challenges/redux'; -import { usernameSelector } from '../redux'; +import { types as map } from '../Map/redux'; export const ns = 'entities'; export const getNS = state => state[ns]; @@ -85,7 +87,8 @@ const defaultState = { superBlock: {}, block: {}, challenge: {}, - user: {} + user: {}, + fullBlocks: [] }; export function selectiveChallengeTitleSelector(state, dashedName) { @@ -148,6 +151,8 @@ export function makeSuperBlockSelector(name) { export const isChallengeLoaded = (state, { dashedName }) => !!challengeMapSelector(state)[dashedName]; +export const fullBlocksSelector = state => getNS(state).fullBlocks; + export default composeReducers( ns, function metaReducer(state = defaultState, action) { @@ -162,7 +167,9 @@ export default composeReducers( } }; } - return merge(state, action.meta.entities); + return { + ...merge(state, action.meta.entities) + }; } return state; }, @@ -184,6 +191,18 @@ export default composeReducers( }, handleActions( () => ({ + [ + combineActions( + app.fetchChallenges.complete, + map.fetchMapUi.complete + ) + ]: (state, { payload }) => { + const {entities: { block } } = payload; + return { + ...merge(state, payload.entities), + fullBlocks: union(state.fullBlocks, [ Object.keys(block)[0] ]) + }; + }, [ challenges.submitChallenge.complete ]: (state, { payload: { username, points, challengeInfo } }) => ({ diff --git a/common/app/redux/fetch-challenges-epic.js b/common/app/redux/fetch-challenges-epic.js index 733223b4ee..6ed2f04703 100644 --- a/common/app/redux/fetch-challenges-epic.js +++ b/common/app/redux/fetch-challenges-epic.js @@ -9,9 +9,10 @@ import { delayedRedirect, fetchChallengeCompleted, - fetchChallengesCompleted + fetchChallengesCompleted, + challengeSelector } from './'; -import { isChallengeLoaded } from '../entities/index.js'; +import { isChallengeLoaded, fullBlocksSelector } from '../entities/index.js'; import { shapeChallenges } from './utils'; import { types as challenge } from '../routes/Challenges/redux'; @@ -19,7 +20,7 @@ import { langSelector } from '../Router/redux'; const isDev = debug.enabled('fcc:*'); -export default function fetchChallengeEpic(actions, { getState }, { services }) { +function fetchChallengeEpic(actions, { getState }, { services }) { return actions::ofType(challenge.onRouteChallenges) .filter(({ payload }) => !isChallengeLoaded(getState(), payload)) .flatMapLatest(({ payload: params }) => { @@ -49,38 +50,40 @@ export default function fetchChallengeEpic(actions, { getState }, { services }) }); } -export function fetchChallengesEpic( +export function fetchChallengesForBlockEpic( actions, { getState }, { services } ) { return actions::ofType( types.appMounted, - types.updateChallenges + types.updateChallenges, + types.fetchNewBlock.start ) - .flatMapLatest(() => { - const lang = langSelector(getState()); + .flatMapLatest(({ type, payload }) => { + const fetchAnotherBlock = type === types.fetchNewBlock.start; + const state = getState(); + let { block: blockName } = challengeSelector(state); + const lang = langSelector(state); + + if (fetchAnotherBlock) { + const fullBlocks = fullBlocksSelector(state); + if (fullBlocks.includes(payload)) { + return Observable.of({ type: 'NULL'}); + } + blockName = payload; + } + const options = { - params: { lang }, - service: 'map' + params: { lang, blockName }, + service: 'challenges-for-block' }; return services.readService$(options) .retry(3) - .map(({ entities, ...res }) => ({ - entities: shapeChallenges( - entities, - isDev - ), - ...res - })) - .map(({ entities, result } = {}) => { - return fetchChallengesCompleted( - entities, - result - ); - }) + .map(fetchChallengesCompleted) .startWith({ type: types.fetchChallenges.start }) .catch(createErrorObservable); }); } +export default combineEpics(fetchChallengeEpic, fetchChallengesForBlockEpic); diff --git a/common/app/redux/index.js b/common/app/redux/index.js index b0a634f0b7..553c18b75b 100644 --- a/common/app/redux/index.js +++ b/common/app/redux/index.js @@ -1,4 +1,4 @@ -import _ from 'lodash'; +import { flow, identity } from 'lodash'; import { Observable } from 'rx'; import { combineActions, @@ -19,6 +19,7 @@ import { updateThemeMetacreator, entitiesSelector } from '../entities'; import { utils } from '../Flash/redux'; import { paramsSelector } from '../Router/redux'; import { types as challenges } from '../routes/Challenges/redux'; +import { types as map } from '../Map/redux'; import { challengeToFiles } from '../routes/Challenges/utils'; import ns from '../ns.json'; @@ -41,6 +42,7 @@ export const types = createTypes([ createAsyncTypes('fetchChallenge'), createAsyncTypes('fetchChallenges'), + createAsyncTypes('fetchNewBlock'), 'updateChallenges', createAsyncTypes('fetchOtherUser'), createAsyncTypes('fetchUser'), @@ -66,7 +68,7 @@ const throwIfUndefined = () => { // label?: String, // value?: Number // }) => () => Object -export const createEventMetaCreator = ({ +export function createEventMetaCreator({ // categories are features or namespaces of the app (capitalized): // Map, Nav, Challenges, and so on category = throwIfUndefined, @@ -80,15 +82,17 @@ export const createEventMetaCreator = ({ label, // used to tack some specific value for a GA event value -} = throwIfUndefined) => () => ({ - analytics: { - type: 'event', - category, - action, - label, - value - } -}); +} = throwIfUndefined) { + return () => ({ + analytics: { + type: 'event', + category, + action, + label, + value + } + }); +} export const onRouteHome = createAction(types.onRouteHome); export const appMounted = createAction(types.appMounted); @@ -101,15 +105,20 @@ export const fetchChallengeCompleted = createAction( null, meta => ({ ...meta, - ..._.flow(challengeToFiles, createFilesMetaCreator)(meta.challenge) + ...flow(challengeToFiles, createFilesMetaCreator)(meta.challenge) }) ); export const fetchChallenges = createAction('' + types.fetchChallenges); export const fetchChallengesCompleted = createAction( - types.fetchChallenges.complete, - (entities, result) => ({ entities, result }), - entities => ({ entities }) + types.fetchChallenges.complete ); + +export const fetchNewBlock = createAction(types.fetchNewBlock.start); +export const fetchNewBlockComplete = createAction( + types.fetchNewBlock.complete, + ({ entities }) => entities +); + export const updateChallenges = createAction(types.updateChallenges); // updateTitle(title: String) => Action @@ -122,7 +131,7 @@ export const fetchOtherUser = createAction(types.fetchOtherUser.start); export const fetchOtherUserComplete = createAction( types.fetchOtherUser.complete, ({ result }) => result, - _.identity + identity ); // fetchUser() => Action @@ -131,7 +140,7 @@ export const fetchUser = createAction(types.fetchUser); export const fetchUserComplete = createAction( types.fetchUser.complete, ({ result }) => result, - _.identity + identity ); export const showSignIn = createAction(types.showSignIn); @@ -209,7 +218,7 @@ export const userByNameSelector = state => { return userMap[username] || {}; }; -export const themeSelector = _.flow( +export const themeSelector = flow( userSelector, user => user.theme || themes.default ); @@ -275,11 +284,13 @@ export default handleActions( }), [combineActions( types.fetchChallenge.complete, - types.fetchChallenges.complete - )]: (state, { payload }) => ({ + map.fetchMapUi.complete + )]: (state, { payload }) => { + return ({ ...state, superBlocks: payload.result.superBlocks - }), + }); + }, [challenges.onRouteChallenges]: (state, { payload: { dashedName } }) => ({ ...state, currentChallenge: dashedName diff --git a/common/app/redux/utils.js b/common/app/redux/utils.js index 968ab97e60..b7e4aefaa8 100644 --- a/common/app/redux/utils.js +++ b/common/app/redux/utils.js @@ -1,5 +1,6 @@ import flowRight from 'lodash/flowRight'; import { createNameIdMap } from '../../utils/map.js'; +import { partial } from 'lodash'; export function filterComingSoonBetaChallenge( isDev = false, @@ -13,7 +14,7 @@ export function filterComingSoonBetaFromEntities( { challenge: challengeMap, block: blockMap = {}, ...rest }, isDev = false ) { - const filter = filterComingSoonBetaChallenge.bind(null, isDev); + const filter = partial(filterComingSoonBetaChallenge, isDev); return { ...rest, block: Object.keys(blockMap) diff --git a/common/app/routes/Challenges/Show.jsx b/common/app/routes/Challenges/Show.jsx index b203a81705..f8a4ac62ab 100644 --- a/common/app/routes/Challenges/Show.jsx +++ b/common/app/routes/Challenges/Show.jsx @@ -12,6 +12,7 @@ import BackEnd from './views/backend'; import Quiz from './views/quiz'; import Modern from './views/Modern'; +import { fullBlocksSelector } from '../../entities'; import { fetchChallenge, challengeSelector, @@ -42,11 +43,14 @@ const mapStateToProps = createSelector( challengeSelector, challengeMetaSelector, paramsSelector, + fullBlocksSelector, ( { dashedName, isTranslated }, { viewType, title }, - params + params, + blocks ) => ({ + blocks, challenge: dashedName, isTranslated, params, diff --git a/common/app/routes/Settings/components/Internet-Settings.jsx b/common/app/routes/Settings/components/Internet-Settings.jsx index 72e5ab6574..ee96f1983e 100644 --- a/common/app/routes/Settings/components/Internet-Settings.jsx +++ b/common/app/routes/Settings/components/Internet-Settings.jsx @@ -54,7 +54,6 @@ class InternetSettings extends PureComponent { } handleSubmit(values) { - console.log(values); this.props.updateUserBackend(values); } diff --git a/server/boot/a-services.js b/server/boot/a-services.js index 777b367fce..0dedd080e1 100644 --- a/server/boot/a-services.js +++ b/server/boot/a-services.js @@ -2,14 +2,19 @@ import Fetchr from 'fetchr'; import getUserServices from '../services/user'; import getMapServices from '../services/map'; import getMapUiServices from '../services/mapUi'; +import getChallengesForBlockService from '../services/challenge'; export default function bootServices(app) { - const userServices = getUserServices(app); - const mapServices = getMapServices(app); - const mapUiServices = getMapUiServices(app); - Fetchr.registerFetcher(userServices); - Fetchr.registerFetcher(mapServices); - Fetchr.registerFetcher(mapUiServices); + const user = getUserServices(app); + const map = getMapServices(app); + const mapUi = getMapUiServices(app); + const challenge = getChallengesForBlockService(app); + + Fetchr.registerFetcher(user); + Fetchr.registerFetcher(map); + Fetchr.registerFetcher(mapUi); + Fetchr.registerFetcher(challenge); + app.use('/services', Fetchr.middleware()); } diff --git a/server/services/challenge.js b/server/services/challenge.js new file mode 100644 index 0000000000..06e2598d62 --- /dev/null +++ b/server/services/challenge.js @@ -0,0 +1,53 @@ +import debug from 'debug'; +import { pickBy } from 'lodash'; +import { Observable } from 'rx'; + +import { cachedMap, getMapForLang } from '../utils/map'; +import { shapeChallenges } from '../../common/app/redux/utils'; + +const log = debug('fcc:services:challenge'); +const isDev = debug.enabled('fcc:*'); + +export default function getChallengesForBlock(app) { + const challengeMap = cachedMap(app.models); + return { + name: 'challenges-for-block', + read: function readChallengesForBlock( + req, + resource, + { blockName, lang = 'en' } = {}, + config, + cb + ) { + log(`sourcing challenges for the ${blockName} block`); + return challengeMap.map(getMapForLang(lang)) + .flatMap(({ + result: { superBlocks }, + entities: { + block: fullBlockMap, + challenge: challengeMap + } + }) => { + const requestedChallenges = pickBy( + challengeMap, + ch => ch.block === blockName + ); + const entities = { + block: { + [blockName]: fullBlockMap[blockName] + }, + challenge: requestedChallenges + }; + const { challenge, block } = shapeChallenges(entities, isDev); + return Observable.of({ + result: { superBlocks }, + entities: { challenge, block } + }); + }) + .subscribe( + result => cb(null, result), + cb + ); + } + }; +} diff --git a/server/services/mapUi.js b/server/services/mapUi.js index b4cf0d46ac..b7dca26595 100644 --- a/server/services/mapUi.js +++ b/server/services/mapUi.js @@ -5,12 +5,18 @@ import { cachedMap, getMapForLang } from '../utils/map'; const log = debug('fcc:services:mapUi'); + export default function mapUiService(app) { + const supportedLangMap = {}; const challengeMap = cachedMap(app.models); return { name: 'map-ui', read: function readMapUi(req, resource, { lang = 'en' } = {}, config, cb) { log(`generating mapUi for ${lang}`); + if (lang in supportedLangMap) { + log(`using cache for ${lang} map`); + return cb(null, supportedLangMap[lang]); + } return challengeMap.map(getMapForLang(lang)) .flatMap(({ result: { superBlocks }, @@ -34,19 +40,39 @@ export default function mapUiService(app) { }, {}); const challengeMap = Object.keys(fullChallengeMap) .map(challenge => fullChallengeMap[challenge]) - .reduce((map, { dashedName, name, id}) => { - map[dashedName] = {name, dashedName, id}; + .reduce((map, challenge) => { + const { + dashedName, + id, + title, + name, + block, + isLocked, + isComingSoon, + isBeta + } = challenge; + map[dashedName] = { + dashedName, + id, + title, + name, + block, + isLocked, + isComingSoon, + isBeta + }; return map; }, {}); - - return Observable.of({ + const mapUi = { result: { superBlocks }, entities: { superBlock: superBlockMap, block: blockMap, challenge: challengeMap } - }); + }; + supportedLangMap[lang] = mapUi; + return Observable.of(mapUi); }).subscribe( mapUi => cb(null, mapUi ), err => { log(err); return cb(err); } diff --git a/server/utils/map.js b/server/utils/map.js index b7483ee961..43186d1051 100644 --- a/server/utils/map.js +++ b/server/utils/map.js @@ -154,6 +154,58 @@ export function getMapForLang(lang) { }; } +export function generateMapForLang( + superBlocks, + fullSuperBlockMap, + fullBlockMap, + fullChallengeMap +) { + const superBlockMap = superBlocks + .map(superBlock => fullSuperBlockMap[superBlock]) + .reduce((map, { dashedName, blocks, title }) => { + map[dashedName] = { blocks, title, dashedName}; + return map; + }, {}); + const blockMap = Object.keys(fullBlockMap) + .map(block => fullBlockMap[block]) + .reduce((map, { dashedName, title, time, challenges }) => { + map[dashedName] = { dashedName, title, time, challenges }; + return map; + }, {}); + const challengeMap = Object.keys(fullChallengeMap) + .map(challenge => fullChallengeMap[challenge]) + .reduce((map, challenge) => { + const { + dashedName, + id, + title, + block, + isLocked, + isComingSoon, + isBeta + } = challenge; + map[dashedName] = { + dashedName, + id, + title, + block, + isLocked, + isComingSoon, + isBeta + }; + return map; + }, {}); + + return { + result: { superBlocks }, + entities: { + superBlock: superBlockMap, + block: blockMap, + challenge: challengeMap + } + }; +} + // type ObjectId: String; // getChallengeById( // map: Observable[map], From 2143063084ca20a3e5bfc8342fc619357eca7c49 Mon Sep 17 00:00:00 2001 From: Stuart Taylor Date: Fri, 23 Feb 2018 17:08:42 +0000 Subject: [PATCH 4/7] feat(map-service): Refactor out the map service --- common/app/redux/fetch-challenges-epic.js | 4 +- server/boot/a-services.js | 11 ++--- server/services/challenge.js | 55 ++++++++++++----------- server/services/map.js | 28 ------------ server/utils/map.js | 52 --------------------- 5 files changed, 36 insertions(+), 114 deletions(-) delete mode 100644 server/services/map.js diff --git a/common/app/redux/fetch-challenges-epic.js b/common/app/redux/fetch-challenges-epic.js index 6ed2f04703..66e5b5c5b0 100644 --- a/common/app/redux/fetch-challenges-epic.js +++ b/common/app/redux/fetch-challenges-epic.js @@ -25,7 +25,7 @@ function fetchChallengeEpic(actions, { getState }, { services }) { .filter(({ payload }) => !isChallengeLoaded(getState(), payload)) .flatMapLatest(({ payload: params }) => { const options = { - service: 'map', + service: 'challenge', params }; return services.readService$(options) @@ -76,7 +76,7 @@ export function fetchChallengesForBlockEpic( const options = { params: { lang, blockName }, - service: 'challenges-for-block' + service: 'challenge' }; return services.readService$(options) .retry(3) diff --git a/server/boot/a-services.js b/server/boot/a-services.js index 0dedd080e1..4bbe43a936 100644 --- a/server/boot/a-services.js +++ b/server/boot/a-services.js @@ -1,20 +1,17 @@ import Fetchr from 'fetchr'; import getUserServices from '../services/user'; -import getMapServices from '../services/map'; import getMapUiServices from '../services/mapUi'; import getChallengesForBlockService from '../services/challenge'; export default function bootServices(app) { - const user = getUserServices(app); - const map = getMapServices(app); - const mapUi = getMapUiServices(app); const challenge = getChallengesForBlockService(app); + const mapUi = getMapUiServices(app); + const user = getUserServices(app); - Fetchr.registerFetcher(user); - Fetchr.registerFetcher(map); - Fetchr.registerFetcher(mapUi); Fetchr.registerFetcher(challenge); + Fetchr.registerFetcher(mapUi); + Fetchr.registerFetcher(user); app.use('/services', Fetchr.middleware()); } diff --git a/server/services/challenge.js b/server/services/challenge.js index 06e2598d62..e7d56488c2 100644 --- a/server/services/challenge.js +++ b/server/services/challenge.js @@ -2,7 +2,7 @@ import debug from 'debug'; import { pickBy } from 'lodash'; import { Observable } from 'rx'; -import { cachedMap, getMapForLang } from '../utils/map'; +import { cachedMap, getMapForLang, getChallenge } from '../utils/map'; import { shapeChallenges } from '../../common/app/redux/utils'; const log = debug('fcc:services:challenge'); @@ -11,16 +11,15 @@ const isDev = debug.enabled('fcc:*'); export default function getChallengesForBlock(app) { const challengeMap = cachedMap(app.models); return { - name: 'challenges-for-block', + name: 'challenge', read: function readChallengesForBlock( - req, - resource, - { blockName, lang = 'en' } = {}, - config, - cb - ) { - log(`sourcing challenges for the ${blockName} block`); - return challengeMap.map(getMapForLang(lang)) + req, + resource, + { dashedName, blockName, lang = 'en' } = {}, + config, + cb + ) { + const getChallengeBlock$ = challengeMap.map(getMapForLang(lang)) .flatMap(({ result: { superBlocks }, entities: { @@ -28,22 +27,28 @@ export default function getChallengesForBlock(app) { challenge: challengeMap } }) => { - const requestedChallenges = pickBy( - challengeMap, - ch => ch.block === blockName - ); - const entities = { - block: { - [blockName]: fullBlockMap[blockName] - }, - challenge: requestedChallenges - }; - const { challenge, block } = shapeChallenges(entities, isDev); - return Observable.of({ - result: { superBlocks }, - entities: { challenge, block } + log(`sourcing challenges for the ${blockName} block`); + const requestedChallenges = pickBy( + challengeMap, + ch => ch.block === blockName + ); + const entities = { + block: { + [blockName]: fullBlockMap[blockName] + }, + challenge: requestedChallenges + }; + const { challenge, block } = shapeChallenges(entities, isDev); + return Observable.of({ + result: { superBlocks }, + entities: { challenge, block } + }); }); - }) + return Observable.if( + () => !!dashedName, + getChallenge(dashedName, blockName, challengeMap, lang), + getChallengeBlock$ + ) .subscribe( result => cb(null, result), cb diff --git a/server/services/map.js b/server/services/map.js deleted file mode 100644 index 7c8b71eee2..0000000000 --- a/server/services/map.js +++ /dev/null @@ -1,28 +0,0 @@ -import { Observable } from 'rx'; -import debug from 'debug'; -import { - cachedMap, - getChallenge, - getMapForLang -} from '../utils/map'; - -const log = debug('fcc:services:map'); - -export default function mapService(app) { - const challengeMap = cachedMap(app.models); - return { - name: 'map', - read: (req, resource, { lang, block, dashedName } = {}, config, cb) => { - log(`${lang} language requested`); - return Observable.if( - () => !!dashedName, - getChallenge(dashedName, block, challengeMap, lang), - challengeMap.map(getMapForLang(lang)) - ) - .subscribe( - results => cb(null, results), - err => { log(err); cb(err); } - ); - } - }; -} diff --git a/server/utils/map.js b/server/utils/map.js index 43186d1051..b7483ee961 100644 --- a/server/utils/map.js +++ b/server/utils/map.js @@ -154,58 +154,6 @@ export function getMapForLang(lang) { }; } -export function generateMapForLang( - superBlocks, - fullSuperBlockMap, - fullBlockMap, - fullChallengeMap -) { - const superBlockMap = superBlocks - .map(superBlock => fullSuperBlockMap[superBlock]) - .reduce((map, { dashedName, blocks, title }) => { - map[dashedName] = { blocks, title, dashedName}; - return map; - }, {}); - const blockMap = Object.keys(fullBlockMap) - .map(block => fullBlockMap[block]) - .reduce((map, { dashedName, title, time, challenges }) => { - map[dashedName] = { dashedName, title, time, challenges }; - return map; - }, {}); - const challengeMap = Object.keys(fullChallengeMap) - .map(challenge => fullChallengeMap[challenge]) - .reduce((map, challenge) => { - const { - dashedName, - id, - title, - block, - isLocked, - isComingSoon, - isBeta - } = challenge; - map[dashedName] = { - dashedName, - id, - title, - block, - isLocked, - isComingSoon, - isBeta - }; - return map; - }, {}); - - return { - result: { superBlocks }, - entities: { - superBlock: superBlockMap, - block: blockMap, - challenge: challengeMap - } - }; -} - // type ObjectId: String; // getChallengeById( // map: Observable[map], From fec1abfb46f395af5dc5925bc2f64a8d22cae586 Mon Sep 17 00:00:00 2001 From: Stuart Taylor Date: Fri, 23 Feb 2018 18:34:46 +0000 Subject: [PATCH 5/7] feat(challenge): Add pre-fetching logic --- common/app/redux/fetch-challenges-epic.js | 62 ++++++++++++++++++- .../routes/Challenges/Completion-Modal.jsx | 11 +++- common/app/routes/Challenges/redux/index.js | 2 + 3 files changed, 71 insertions(+), 4 deletions(-) diff --git a/common/app/redux/fetch-challenges-epic.js b/common/app/redux/fetch-challenges-epic.js index 66e5b5c5b0..faecb7711a 100644 --- a/common/app/redux/fetch-challenges-epic.js +++ b/common/app/redux/fetch-challenges-epic.js @@ -10,12 +10,24 @@ import { fetchChallengeCompleted, fetchChallengesCompleted, - challengeSelector + fetchNewBlock, + challengeSelector, + superBlocksSelector, + currentChallengeSelector } from './'; -import { isChallengeLoaded, fullBlocksSelector } from '../entities/index.js'; +import { + isChallengeLoaded, + fullBlocksSelector, + entitiesSelector +} from '../entities'; import { shapeChallenges } from './utils'; import { types as challenge } from '../routes/Challenges/redux'; +import { + getFirstChallengeOfNextBlock, + getFirstChallengeOfNextSuperBlock, + getNextChallenge +} from '../routes/Challenges/utils'; import { langSelector } from '../Router/redux'; const isDev = debug.enabled('fcc:*'); @@ -86,4 +98,48 @@ export function fetchChallengesForBlockEpic( }); } -export default combineEpics(fetchChallengeEpic, fetchChallengesForBlockEpic); +function fetchChallengesForNextBlockEpic(action$, { getState }) { + return action$::ofType(challenge.checkForNextBlock) + .map(() => { + let nextChallenge = {}; + let isNewBlock = false; + let isNewSuperBlock = false; + const state = getState(); + const challenge = currentChallengeSelector(state); + const superBlocks = superBlocksSelector(state); + const entities = entitiesSelector(state); + nextChallenge = getNextChallenge(challenge, entities, { isDev }); + // block completed. + if (!nextChallenge) { + isNewBlock = true; + nextChallenge = getFirstChallengeOfNextBlock( + challenge, + entities, + { isDev } + ); + } + // superBlock completed + if (!nextChallenge) { + isNewSuperBlock = true; + nextChallenge = getFirstChallengeOfNextSuperBlock( + challenge, + entities, + superBlocks, + { isDev } + ); + } + const isNewBlockRequired = ( + (isNewBlock || isNewSuperBlock) && + !nextChallenge.description + ); + return isNewBlockRequired ? + fetchNewBlock(nextChallenge.block) : + { type: 'NULL' }; + }); +} + +export default combineEpics( + fetchChallengeEpic, + fetchChallengesForBlockEpic, + fetchChallengesForNextBlockEpic +); diff --git a/common/app/routes/Challenges/Completion-Modal.jsx b/common/app/routes/Challenges/Completion-Modal.jsx index 49c986f0db..6ef24ea35d 100644 --- a/common/app/routes/Challenges/Completion-Modal.jsx +++ b/common/app/routes/Challenges/Completion-Modal.jsx @@ -11,6 +11,8 @@ import { closeChallengeModal, submitChallenge, + checkForNextBlock, + challengeModalSelector, successMessageSelector } from './redux'; @@ -34,12 +36,14 @@ const mapDispatchToProps = function(dispatch) { }, submitChallenge: () => { dispatch(submitChallenge()); - } + }, + checkForNextBlock: () => dispatch(checkForNextBlock()) }; return () => dispatchers; }; const propTypes = { + checkForNextBlock: PropTypes.func.isRequired, close: PropTypes.func.isRequired, handleKeypress: PropTypes.func.isRequired, isOpen: PropTypes.bool, @@ -48,6 +52,11 @@ const propTypes = { }; export class CompletionModal extends PureComponent { + componentDidUpdate() { + if (this.props.isOpen) { + this.props.checkForNextBlock(); + } + } render() { const { close, diff --git a/common/app/routes/Challenges/redux/index.js b/common/app/routes/Challenges/redux/index.js index 090de13004..7d92931a69 100644 --- a/common/app/routes/Challenges/redux/index.js +++ b/common/app/routes/Challenges/redux/index.js @@ -77,6 +77,7 @@ export const types = createTypes([ 'checkChallenge', createAsyncTypes('submitChallenge'), 'moveToNextChallenge', + 'checkForNextBlock', // help 'openHelpModal', @@ -150,6 +151,7 @@ export const submitChallengeComplete = createAction( ); export const moveToNextChallenge = createAction(types.moveToNextChallenge); +export const checkForNextBlock = createAction(types.checkForNextBlock); // help export const openHelpModal = createAction(types.openHelpModal); From b27036a261880af0b88b8bc160214666eeff5726 Mon Sep 17 00:00:00 2001 From: Stuart Taylor Date: Fri, 23 Feb 2018 18:44:40 +0000 Subject: [PATCH 6/7] fix(format): Formatting fix --- common/app/Map/redux/index.js | 6 +-- server/utils/map.js | 74 +++++++++++++++++------------------ 2 files changed, 40 insertions(+), 40 deletions(-) diff --git a/common/app/Map/redux/index.js b/common/app/Map/redux/index.js index 342c53bcad..49d48752ee 100644 --- a/common/app/Map/redux/index.js +++ b/common/app/Map/redux/index.js @@ -5,7 +5,7 @@ import { handleActions } from 'berkeleys-redux-utils'; import { createSelector } from 'reselect'; -import { capitalize, noop} from 'lodash'; +import { capitalize, noop } from 'lodash'; import * as utils from './utils.js'; import ns from '../ns.json'; @@ -13,9 +13,9 @@ import { createEventMetaCreator } from '../../redux'; -import fewtchMapUiEpic from './fetch-map-ui-epic'; +import fetchMapUiEpic from './fetch-map-ui-epic'; -export const epics = [ fewtchMapUiEpic ]; +export const epics = [ fetchMapUiEpic ]; export const types = createTypes([ 'onRouteMap', diff --git a/server/utils/map.js b/server/utils/map.js index b7483ee961..99b67d159a 100644 --- a/server/utils/map.js +++ b/server/utils/map.js @@ -74,43 +74,43 @@ export function _cachedMap({ Block, Challenge }) { }, blocksMap); }); const superBlockMap = blocks.map(blocks => blocks.reduce((map, block) => { - if ( - map[block.superBlock] && - map[block.superBlock].blocks - ) { - map[block.superBlock].blocks.push(block.dashedName); - } else { - map[block.superBlock] = { - title: _.startCase(block.superBlock), - order: block.superOrder, - name: nameify(_.startCase(block.superBlock)), - dashedName: block.superBlock, - blocks: [block.dashedName], - message: block.superBlockMessage - }; - } - return map; - }, {})); - const superBlocks = superBlockMap.map(superBlockMap => { - return Object.keys(superBlockMap) - .map(key => superBlockMap[key]) - .map(({ dashedName }) => dashedName); - }); - return Observable.combineLatest( - superBlockMap, - blockMap, - challengeMap, - superBlocks, - (superBlock, block, challenge, superBlocks) => ({ - entities: { - superBlock, - block, - challenge - }, - result: { - superBlocks - } - }) + if ( + map[block.superBlock] && + map[block.superBlock].blocks + ) { + map[block.superBlock].blocks.push(block.dashedName); + } else { + map[block.superBlock] = { + title: _.startCase(block.superBlock), + order: block.superOrder, + name: nameify(_.startCase(block.superBlock)), + dashedName: block.superBlock, + blocks: [block.dashedName], + message: block.superBlockMessage + }; + } + return map; + }, {})); + const superBlocks = superBlockMap.map(superBlockMap => { + return Object.keys(superBlockMap) + .map(key => superBlockMap[key]) + .map(({ dashedName }) => dashedName); + }); + return Observable.combineLatest( + superBlockMap, + blockMap, + challengeMap, + superBlocks, + (superBlock, block, challenge, superBlocks) => ({ + entities: { + superBlock, + block, + challenge + }, + result: { + superBlocks + } + }) ) .do(checkMapData) .shareReplay(); From 329438bdf4cf5991c0677743266309263077338d Mon Sep 17 00:00:00 2001 From: Stuart Taylor Date: Sat, 24 Feb 2018 08:44:12 +0000 Subject: [PATCH 7/7] fix(nextChallenge): Remove some duplication --- common/app/Map/Map.jsx | 2 +- common/app/redux/fetch-challenges-epic.js | 54 +++------ common/app/redux/index.js | 50 ++++++++- .../routes/Challenges/redux/challenge-epic.js | 103 +++++------------- server/services/challenge.js | 30 ++--- 5 files changed, 102 insertions(+), 137 deletions(-) diff --git a/common/app/Map/Map.jsx b/common/app/Map/Map.jsx index b942992e5e..783543c614 100644 --- a/common/app/Map/Map.jsx +++ b/common/app/Map/Map.jsx @@ -26,7 +26,7 @@ export class ShowMap extends PureComponent { const { superBlocks } = this.props; if (!Array.isArray(superBlocks) || !superBlocks.length) { return ( -
+
); diff --git a/common/app/redux/fetch-challenges-epic.js b/common/app/redux/fetch-challenges-epic.js index faecb7711a..b0d0383ee9 100644 --- a/common/app/redux/fetch-challenges-epic.js +++ b/common/app/redux/fetch-challenges-epic.js @@ -12,22 +12,15 @@ import { fetchChallengesCompleted, fetchNewBlock, challengeSelector, - superBlocksSelector, - currentChallengeSelector + nextChallengeSelector } from './'; import { isChallengeLoaded, - fullBlocksSelector, - entitiesSelector + fullBlocksSelector } from '../entities'; import { shapeChallenges } from './utils'; import { types as challenge } from '../routes/Challenges/redux'; -import { - getFirstChallengeOfNextBlock, - getFirstChallengeOfNextSuperBlock, - getNextChallenge -} from '../routes/Challenges/utils'; import { langSelector } from '../Router/redux'; const isDev = debug.enabled('fcc:*'); @@ -81,7 +74,7 @@ export function fetchChallengesForBlockEpic( if (fetchAnotherBlock) { const fullBlocks = fullBlocksSelector(state); if (fullBlocks.includes(payload)) { - return Observable.of({ type: 'NULL'}); + return Observable.of(null); } blockName = payload; } @@ -95,47 +88,28 @@ export function fetchChallengesForBlockEpic( .map(fetchChallengesCompleted) .startWith({ type: types.fetchChallenges.start }) .catch(createErrorObservable); - }); + }) + .filter(Boolean); } function fetchChallengesForNextBlockEpic(action$, { getState }) { return action$::ofType(challenge.checkForNextBlock) .map(() => { - let nextChallenge = {}; - let isNewBlock = false; - let isNewSuperBlock = false; - const state = getState(); - const challenge = currentChallengeSelector(state); - const superBlocks = superBlocksSelector(state); - const entities = entitiesSelector(state); - nextChallenge = getNextChallenge(challenge, entities, { isDev }); - // block completed. - if (!nextChallenge) { - isNewBlock = true; - nextChallenge = getFirstChallengeOfNextBlock( - challenge, - entities, - { isDev } - ); - } - // superBlock completed - if (!nextChallenge) { - isNewSuperBlock = true; - nextChallenge = getFirstChallengeOfNextSuperBlock( - challenge, - entities, - superBlocks, - { isDev } - ); - } + const { + nextChallenge, + isNewBlock, + isNewSuperBlock + } = nextChallengeSelector(getState()); const isNewBlockRequired = ( (isNewBlock || isNewSuperBlock) && + nextChallenge && !nextChallenge.description ); return isNewBlockRequired ? fetchNewBlock(nextChallenge.block) : - { type: 'NULL' }; - }); + null; + }) + .filter(Boolean); } export default combineEpics( diff --git a/common/app/redux/index.js b/common/app/redux/index.js index 553c18b75b..d878ffc01f 100644 --- a/common/app/redux/index.js +++ b/common/app/redux/index.js @@ -8,6 +8,7 @@ import { handleActions } from 'berkeleys-redux-utils'; import { createSelector } from 'reselect'; +import debug from 'debug'; import fetchUserEpic from './fetch-user-epic.js'; import updateMyCurrentChallengeEpic from './update-my-challenge-epic.js'; @@ -20,12 +21,19 @@ import { utils } from '../Flash/redux'; import { paramsSelector } from '../Router/redux'; import { types as challenges } from '../routes/Challenges/redux'; import { types as map } from '../Map/redux'; -import { challengeToFiles } from '../routes/Challenges/utils'; +import { + challengeToFiles, + getFirstChallengeOfNextBlock, + getFirstChallengeOfNextSuperBlock, + getNextChallenge +} from '../routes/Challenges/utils'; import ns from '../ns.json'; import { themes, invertTheme } from '../../utils/themes.js'; +const isDev = debug.enabled('fcc:*'); + export const epics = [ fetchChallengesEpic, fetchUserEpic, @@ -271,6 +279,40 @@ export const firstChallengeSelector = createSelector( } ); +export const nextChallengeSelector = state => { + let nextChallenge = {}; + let isNewBlock = false; + let isNewSuperBlock = false; + const challenge = currentChallengeSelector(state); + const superBlocks = superBlocksSelector(state); + const entities = entitiesSelector(state); + nextChallenge = getNextChallenge(challenge, entities, { isDev }); + // block completed. + if (!nextChallenge) { + isNewBlock = true; + nextChallenge = getFirstChallengeOfNextBlock( + challenge, + entities, + { isDev } + ); + } + // superBlock completed + if (!nextChallenge) { + isNewSuperBlock = true; + nextChallenge = getFirstChallengeOfNextSuperBlock( + challenge, + entities, + superBlocks, + { isDev } + ); + } + return { + nextChallenge, + isNewBlock, + isNewSuperBlock + }; +}; + export default handleActions( () => ({ [types.updateTitle]: (state, { payload = 'Learn To Code' }) => ({ @@ -285,12 +327,10 @@ export default handleActions( [combineActions( types.fetchChallenge.complete, map.fetchMapUi.complete - )]: (state, { payload }) => { - return ({ + )]: (state, { payload }) => ({ ...state, superBlocks: payload.result.superBlocks - }); - }, + }), [challenges.onRouteChallenges]: (state, { payload: { dashedName } }) => ({ ...state, currentChallenge: dashedName diff --git a/common/app/routes/Challenges/redux/challenge-epic.js b/common/app/routes/Challenges/redux/challenge-epic.js index f9fb948403..a0a9056c4d 100644 --- a/common/app/routes/Challenges/redux/challenge-epic.js +++ b/common/app/routes/Challenges/redux/challenge-epic.js @@ -1,5 +1,4 @@ import _ from 'lodash'; -import debug from 'debug'; import { Observable } from 'rx'; import { combineEpics, ofType } from 'redux-epic'; @@ -10,23 +9,15 @@ import { onRouteChallenges, onRouteCurrentChallenge } from './'; -import { getNS as entitiesSelector } from '../../../entities'; -import { - getNextChallenge, - getFirstChallengeOfNextBlock, - getFirstChallengeOfNextSuperBlock -} from '../utils'; + import { createErrorObservable, - currentChallengeSelector, challengeSelector, - superBlocksSelector + nextChallengeSelector } from '../../../redux'; import { langSelector } from '../../../Router/redux'; import { makeToast } from '../../../Toasts/redux'; -const isDev = debug.enabled('fcc:*'); - // When we change challenge, update the current challenge // UI data. export function challengeUpdatedEpic(actions, { getState }) { @@ -52,73 +43,33 @@ export function resetChallengeEpic(actions, { getState }) { export function nextChallengeEpic(actions, { getState }) { return actions::ofType(types.moveToNextChallenge) .flatMap(() => { - let nextChallenge; - // let message = ''; - // let isNewBlock = false; - // let isNewSuperBlock = false; - try { - const state = getState(); - const superBlocks = superBlocksSelector(state); - const challenge = currentChallengeSelector(state); - const entities = entitiesSelector(state); - const lang = langSelector(state); - nextChallenge = getNextChallenge(challenge, entities, { isDev }); - // block completed. - if (!nextChallenge) { - // isNewBlock = true; - nextChallenge = getFirstChallengeOfNextBlock( - challenge, - entities, - { isDev } + const state = getState(); + const lang = langSelector(state); + const { nextChallenge } = nextChallengeSelector(state); + if (!nextChallenge) { + return createErrorObservable( + new Error('Next Challenge could not be found') ); - } - // superBlock completed - if (!nextChallenge) { - // isNewSuperBlock = true; - nextChallenge = getFirstChallengeOfNextSuperBlock( - challenge, - entities, - superBlocks, - { isDev } - ); - } - /* // TODO(berks): get this to work - if (isNewSuperBlock || isNewBlock) { - const getName = isNewSuperBlock ? - getCurrentSuperBlockName : - getCurrentBlockName; - const blockType = isNewSuperBlock ? 'SuperBlock' : 'Block'; - message = - `You've competed the ${getName(challenge, entities)} ${blockType}!`; - } - message += ' Your next challenge has arrived.'; - const toast = { - // title: isNewSuperBlock || isNewBlock ? randomVerb() : null, - message - }; - */ - if (nextChallenge.isLocked) { - return Observable.of( - makeToast({ - message: 'The next challenge has not been unlocked. ' + - 'Please revisit the required (*) challenges ' + - 'that have not been passed yet. ', - timeout: 15000 - }), - onRouteCurrentChallenge() - ); - } - return Observable.of( - // normally we wouldn't need to add the lang as - // addLangToRoutesEnhancer should add langs for us, but the way - // enhancers/middlewares and RFR orders things this action will not - // see addLangToRoutesEnhancer and cause RFR to render NotFound - onRouteChallenges({ lang, ...nextChallenge }), - makeToast({ message: 'Your next challenge has arrived.' }) - ); - } catch (err) { - return createErrorObservable(err); } + if (nextChallenge.isLocked) { + return Observable.of( + makeToast({ + message: 'The next challenge has not been unlocked. ' + + 'Please revisit the required (*) challenges ' + + 'that have not been passed yet. ', + timeout: 15000 + }), + onRouteCurrentChallenge() + ); + } + return Observable.of( + // normally we wouldn't need to add the lang as + // addLangToRoutesEnhancer should add langs for us, but the way + // enhancers/middlewares and RFR orders things this action will not + // see addLangToRoutesEnhancer and cause RFR to render NotFound + onRouteChallenges({ lang, ...nextChallenge }), + makeToast({ message: 'Your next challenge has arrived.' }) + ); }); } diff --git a/server/services/challenge.js b/server/services/challenge.js index e7d56488c2..53b92fbdb2 100644 --- a/server/services/challenge.js +++ b/server/services/challenge.js @@ -28,22 +28,22 @@ export default function getChallengesForBlock(app) { } }) => { log(`sourcing challenges for the ${blockName} block`); - const requestedChallenges = pickBy( - challengeMap, - ch => ch.block === blockName - ); - const entities = { - block: { - [blockName]: fullBlockMap[blockName] - }, - challenge: requestedChallenges - }; - const { challenge, block } = shapeChallenges(entities, isDev); - return Observable.of({ - result: { superBlocks }, - entities: { challenge, block } - }); + const requestedChallenges = pickBy( + challengeMap, + ch => ch.block === blockName + ); + const entities = { + block: { + [blockName]: fullBlockMap[blockName] + }, + challenge: requestedChallenges + }; + const { challenge, block } = shapeChallenges(entities, isDev); + return Observable.of({ + result: { superBlocks }, + entities: { challenge, block } }); + }); return Observable.if( () => !!dashedName, getChallenge(dashedName, blockName, challengeMap, lang),