From a7587ed6f0f09ea3f3d43220c8ec13bae224d51c Mon Sep 17 00:00:00 2001 From: Stuart Taylor Date: Fri, 23 Feb 2018 12:20:13 +0000 Subject: [PATCH] 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],