feat(challenge): Initial build of the challenge service

This commit is contained in:
Stuart Taylor
2018-02-23 12:20:13 +00:00
committed by Stuart Taylor
parent d17c2d33eb
commit a7587ed6f0
14 changed files with 263 additions and 83 deletions

View File

@ -9,13 +9,16 @@ import ns from './ns.json';
import Challenges from './Challenges.jsx'; import Challenges from './Challenges.jsx';
import { import {
toggleThisPanel, toggleThisPanel,
makePanelOpenSelector makePanelOpenSelector
} from './redux'; } from './redux';
import { fetchNewBlock } from '../redux';
import { makeBlockSelector } from '../entities'; import { makeBlockSelector } from '../entities';
const dispatchActions = { toggleThisPanel }; const mapDispatchToProps = {
fetchNewBlock,
toggleThisPanel
};
function makeMapStateToProps(_, { dashedName }) { function makeMapStateToProps(_, { dashedName }) {
return createSelector( return createSelector(
makeBlockSelector(dashedName), makeBlockSelector(dashedName),
@ -34,6 +37,7 @@ function makeMapStateToProps(_, { dashedName }) {
const propTypes = { const propTypes = {
challenges: PropTypes.array, challenges: PropTypes.array,
dashedName: PropTypes.string, dashedName: PropTypes.string,
fetchNewBlock: PropTypes.func.isRequired,
isOpen: PropTypes.bool, isOpen: PropTypes.bool,
time: PropTypes.string, time: PropTypes.string,
title: PropTypes.string, title: PropTypes.string,
@ -74,7 +78,8 @@ export class Block extends PureComponent {
time, time,
dashedName, dashedName,
isOpen, isOpen,
challenges challenges,
fetchNewBlock
} = this.props; } = this.props;
return ( return (
<Panel <Panel
@ -85,6 +90,7 @@ export class Block extends PureComponent {
header={ this.renderHeader(isOpen, title, time) } header={ this.renderHeader(isOpen, title, time) }
id={ title } id={ title }
key={ title } key={ title }
onClick={ () => fetchNewBlock(dashedName) }
onSelect={ this.handleSelect } onSelect={ this.handleSelect }
> >
{ isOpen && <Challenges challenges={ challenges } /> } { isOpen && <Challenges challenges={ challenges } /> }
@ -96,4 +102,4 @@ export class Block extends PureComponent {
Block.displayName = 'Block'; Block.displayName = 'Block';
Block.propTypes = propTypes; Block.propTypes = propTypes;
export default connect(makeMapStateToProps, dispatchActions)(Block); export default connect(makeMapStateToProps, mapDispatchToProps)(Block);

View File

@ -4,23 +4,32 @@ import { connect } from 'react-redux';
import { Col, Row } from 'react-bootstrap'; import { Col, Row } from 'react-bootstrap';
import ns from './ns.json'; import ns from './ns.json';
import { Loader } from '../helperComponents';
import SuperBlock from './Super-Block.jsx'; import SuperBlock from './Super-Block.jsx';
import { superBlocksSelector } from '../redux'; import { superBlocksSelector } from '../redux';
import { fetchMapUi } from './redux';
const mapStateToProps = state => ({ const mapStateToProps = state => ({
superBlocks: superBlocksSelector(state) superBlocks: superBlocksSelector(state)
}); });
const mapDispatchToProps = {}; const mapDispatchToProps = { fetchMapUi };
const propTypes = { const propTypes = {
fetchMapUi: PropTypes.func.isRequired,
params: PropTypes.object, params: PropTypes.object,
superBlocks: PropTypes.array superBlocks: PropTypes.array
}; };
export class ShowMap extends PureComponent { export class ShowMap extends PureComponent {
renderSuperBlocks(superBlocks) {
renderSuperBlocks() {
const { superBlocks } = this.props;
if (!Array.isArray(superBlocks) || !superBlocks.length) { if (!Array.isArray(superBlocks) || !superBlocks.length) {
return <div>No Super Blocks</div>; return (
<div style={{ hieght: '300px' }}>
<Loader />
</div>
);
} }
return superBlocks.map(dashedName => ( return superBlocks.map(dashedName => (
<SuperBlock <SuperBlock
@ -31,12 +40,11 @@ export class ShowMap extends PureComponent {
} }
render() { render() {
const { superBlocks } = this.props;
return ( return (
<Row> <Row>
<Col xs={ 12 }> <Col xs={ 12 }>
<div className={ `${ns}-accordion center-block` }> <div className={ `${ns}-accordion center-block` }>
{ this.renderSuperBlocks(superBlocks) } { this.renderSuperBlocks() }
<div className='spacer' /> <div className='spacer' />
</div> </div>
</Col> </Col>

View File

@ -17,7 +17,8 @@ export default function fetchMapUiEpic(
{ services } { services }
) { ) {
return actions::ofType( return actions::ofType(
appTypes.appMounted appTypes.appMounted,
types.fetchMapUi.start
) )
.flatMapLatest(() => { .flatMapLatest(() => {
const lang = langSelector(getState()); const lang = langSelector(getState());
@ -34,13 +35,7 @@ export default function fetchMapUiEpic(
), ),
...res ...res
})) }))
.map(({ entities, result } = {}) => { .map(fetchMapUiComplete)
return fetchMapUiComplete(
entities,
result
);
})
.startWith({ type: types.fetchMapUi.start })
.catch(createErrorObservable); .catch(createErrorObservable);
}); });
} }

View File

@ -32,11 +32,8 @@ export const types = createTypes([
export const initMap = createAction(types.initMap); export const initMap = createAction(types.initMap);
export const fetchMapUiComplete = createAction( export const fetchMapUi = createAction(types.fetchMapUi.start);
types.fetchMapUi.complete, export const fetchMapUiComplete = createAction(types.fetchMapUi.complete);
(entities, result) => ({ entities, result }),
entities => ({ entities })
);
export const toggleThisPanel = createAction(types.toggleThisPanel); export const toggleThisPanel = createAction(types.toggleThisPanel);
export const collapseAll = createAction(types.collapseAll); export const collapseAll = createAction(types.collapseAll);
@ -111,6 +108,7 @@ export default handleActions(
const { entities, result } = payload; const { entities, result } = payload;
return { return {
...state, ...state,
...result,
mapUi: utils.createMapUi(entities, result) mapUi: utils.createMapUi(entities, result)
}; };
} }

View File

@ -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 uuid from 'uuid/v4';
import { import {
combineActions,
composeReducers, composeReducers,
createAction, createAction,
createTypes, createTypes,
@ -8,8 +9,9 @@ import {
} from 'berkeleys-redux-utils'; } from 'berkeleys-redux-utils';
import { themes } from '../../utils/themes'; import { themes } from '../../utils/themes';
import { usernameSelector, types as app } from '../redux';
import { types as challenges } from '../routes/Challenges/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 ns = 'entities';
export const getNS = state => state[ns]; export const getNS = state => state[ns];
@ -85,7 +87,8 @@ const defaultState = {
superBlock: {}, superBlock: {},
block: {}, block: {},
challenge: {}, challenge: {},
user: {} user: {},
fullBlocks: []
}; };
export function selectiveChallengeTitleSelector(state, dashedName) { export function selectiveChallengeTitleSelector(state, dashedName) {
@ -148,6 +151,8 @@ export function makeSuperBlockSelector(name) {
export const isChallengeLoaded = (state, { dashedName }) => export const isChallengeLoaded = (state, { dashedName }) =>
!!challengeMapSelector(state)[dashedName]; !!challengeMapSelector(state)[dashedName];
export const fullBlocksSelector = state => getNS(state).fullBlocks;
export default composeReducers( export default composeReducers(
ns, ns,
function metaReducer(state = defaultState, action) { 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; return state;
}, },
@ -184,6 +191,18 @@ export default composeReducers(
}, },
handleActions( 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 challenges.submitChallenge.complete
]: (state, { payload: { username, points, challengeInfo } }) => ({ ]: (state, { payload: { username, points, challengeInfo } }) => ({

View File

@ -9,9 +9,10 @@ import {
delayedRedirect, delayedRedirect,
fetchChallengeCompleted, fetchChallengeCompleted,
fetchChallengesCompleted fetchChallengesCompleted,
challengeSelector
} from './'; } from './';
import { isChallengeLoaded } from '../entities/index.js'; import { isChallengeLoaded, fullBlocksSelector } from '../entities/index.js';
import { shapeChallenges } from './utils'; import { shapeChallenges } from './utils';
import { types as challenge } from '../routes/Challenges/redux'; import { types as challenge } from '../routes/Challenges/redux';
@ -19,7 +20,7 @@ import { langSelector } from '../Router/redux';
const isDev = debug.enabled('fcc:*'); const isDev = debug.enabled('fcc:*');
export default function fetchChallengeEpic(actions, { getState }, { services }) { function fetchChallengeEpic(actions, { getState }, { services }) {
return actions::ofType(challenge.onRouteChallenges) return actions::ofType(challenge.onRouteChallenges)
.filter(({ payload }) => !isChallengeLoaded(getState(), payload)) .filter(({ payload }) => !isChallengeLoaded(getState(), payload))
.flatMapLatest(({ payload: params }) => { .flatMapLatest(({ payload: params }) => {
@ -49,38 +50,40 @@ export default function fetchChallengeEpic(actions, { getState }, { services })
}); });
} }
export function fetchChallengesEpic( export function fetchChallengesForBlockEpic(
actions, actions,
{ getState }, { getState },
{ services } { services }
) { ) {
return actions::ofType( return actions::ofType(
types.appMounted, types.appMounted,
types.updateChallenges types.updateChallenges,
types.fetchNewBlock.start
) )
.flatMapLatest(() => { .flatMapLatest(({ type, payload }) => {
const lang = langSelector(getState()); 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 = { const options = {
params: { lang }, params: { lang, blockName },
service: 'map' service: 'challenges-for-block'
}; };
return services.readService$(options) return services.readService$(options)
.retry(3) .retry(3)
.map(({ entities, ...res }) => ({ .map(fetchChallengesCompleted)
entities: shapeChallenges(
entities,
isDev
),
...res
}))
.map(({ entities, result } = {}) => {
return fetchChallengesCompleted(
entities,
result
);
})
.startWith({ type: types.fetchChallenges.start }) .startWith({ type: types.fetchChallenges.start })
.catch(createErrorObservable); .catch(createErrorObservable);
}); });
} }
export default combineEpics(fetchChallengeEpic, fetchChallengesForBlockEpic);

View File

@ -1,4 +1,4 @@
import _ from 'lodash'; import { flow, identity } from 'lodash';
import { Observable } from 'rx'; import { Observable } from 'rx';
import { import {
combineActions, combineActions,
@ -19,6 +19,7 @@ import { updateThemeMetacreator, entitiesSelector } from '../entities';
import { utils } from '../Flash/redux'; import { utils } from '../Flash/redux';
import { paramsSelector } from '../Router/redux'; import { paramsSelector } from '../Router/redux';
import { types as challenges } from '../routes/Challenges/redux'; import { types as challenges } from '../routes/Challenges/redux';
import { types as map } from '../Map/redux';
import { challengeToFiles } from '../routes/Challenges/utils'; import { challengeToFiles } from '../routes/Challenges/utils';
import ns from '../ns.json'; import ns from '../ns.json';
@ -41,6 +42,7 @@ export const types = createTypes([
createAsyncTypes('fetchChallenge'), createAsyncTypes('fetchChallenge'),
createAsyncTypes('fetchChallenges'), createAsyncTypes('fetchChallenges'),
createAsyncTypes('fetchNewBlock'),
'updateChallenges', 'updateChallenges',
createAsyncTypes('fetchOtherUser'), createAsyncTypes('fetchOtherUser'),
createAsyncTypes('fetchUser'), createAsyncTypes('fetchUser'),
@ -66,7 +68,7 @@ const throwIfUndefined = () => {
// label?: String, // label?: String,
// value?: Number // value?: Number
// }) => () => Object // }) => () => Object
export const createEventMetaCreator = ({ export function createEventMetaCreator({
// categories are features or namespaces of the app (capitalized): // categories are features or namespaces of the app (capitalized):
// Map, Nav, Challenges, and so on // Map, Nav, Challenges, and so on
category = throwIfUndefined, category = throwIfUndefined,
@ -80,7 +82,8 @@ export const createEventMetaCreator = ({
label, label,
// used to tack some specific value for a GA event // used to tack some specific value for a GA event
value value
} = throwIfUndefined) => () => ({ } = throwIfUndefined) {
return () => ({
analytics: { analytics: {
type: 'event', type: 'event',
category, category,
@ -88,7 +91,8 @@ export const createEventMetaCreator = ({
label, label,
value value
} }
}); });
}
export const onRouteHome = createAction(types.onRouteHome); export const onRouteHome = createAction(types.onRouteHome);
export const appMounted = createAction(types.appMounted); export const appMounted = createAction(types.appMounted);
@ -101,15 +105,20 @@ export const fetchChallengeCompleted = createAction(
null, null,
meta => ({ meta => ({
...meta, ...meta,
..._.flow(challengeToFiles, createFilesMetaCreator)(meta.challenge) ...flow(challengeToFiles, createFilesMetaCreator)(meta.challenge)
}) })
); );
export const fetchChallenges = createAction('' + types.fetchChallenges); export const fetchChallenges = createAction('' + types.fetchChallenges);
export const fetchChallengesCompleted = createAction( export const fetchChallengesCompleted = createAction(
types.fetchChallenges.complete, types.fetchChallenges.complete
(entities, result) => ({ entities, result }),
entities => ({ entities })
); );
export const fetchNewBlock = createAction(types.fetchNewBlock.start);
export const fetchNewBlockComplete = createAction(
types.fetchNewBlock.complete,
({ entities }) => entities
);
export const updateChallenges = createAction(types.updateChallenges); export const updateChallenges = createAction(types.updateChallenges);
// updateTitle(title: String) => Action // updateTitle(title: String) => Action
@ -122,7 +131,7 @@ export const fetchOtherUser = createAction(types.fetchOtherUser.start);
export const fetchOtherUserComplete = createAction( export const fetchOtherUserComplete = createAction(
types.fetchOtherUser.complete, types.fetchOtherUser.complete,
({ result }) => result, ({ result }) => result,
_.identity identity
); );
// fetchUser() => Action // fetchUser() => Action
@ -131,7 +140,7 @@ export const fetchUser = createAction(types.fetchUser);
export const fetchUserComplete = createAction( export const fetchUserComplete = createAction(
types.fetchUser.complete, types.fetchUser.complete,
({ result }) => result, ({ result }) => result,
_.identity identity
); );
export const showSignIn = createAction(types.showSignIn); export const showSignIn = createAction(types.showSignIn);
@ -209,7 +218,7 @@ export const userByNameSelector = state => {
return userMap[username] || {}; return userMap[username] || {};
}; };
export const themeSelector = _.flow( export const themeSelector = flow(
userSelector, userSelector,
user => user.theme || themes.default user => user.theme || themes.default
); );
@ -275,11 +284,13 @@ export default handleActions(
}), }),
[combineActions( [combineActions(
types.fetchChallenge.complete, types.fetchChallenge.complete,
types.fetchChallenges.complete map.fetchMapUi.complete
)]: (state, { payload }) => ({ )]: (state, { payload }) => {
return ({
...state, ...state,
superBlocks: payload.result.superBlocks superBlocks: payload.result.superBlocks
}), });
},
[challenges.onRouteChallenges]: (state, { payload: { dashedName } }) => ({ [challenges.onRouteChallenges]: (state, { payload: { dashedName } }) => ({
...state, ...state,
currentChallenge: dashedName currentChallenge: dashedName

View File

@ -1,5 +1,6 @@
import flowRight from 'lodash/flowRight'; import flowRight from 'lodash/flowRight';
import { createNameIdMap } from '../../utils/map.js'; import { createNameIdMap } from '../../utils/map.js';
import { partial } from 'lodash';
export function filterComingSoonBetaChallenge( export function filterComingSoonBetaChallenge(
isDev = false, isDev = false,
@ -13,7 +14,7 @@ export function filterComingSoonBetaFromEntities(
{ challenge: challengeMap, block: blockMap = {}, ...rest }, { challenge: challengeMap, block: blockMap = {}, ...rest },
isDev = false isDev = false
) { ) {
const filter = filterComingSoonBetaChallenge.bind(null, isDev); const filter = partial(filterComingSoonBetaChallenge, isDev);
return { return {
...rest, ...rest,
block: Object.keys(blockMap) block: Object.keys(blockMap)

View File

@ -12,6 +12,7 @@ import BackEnd from './views/backend';
import Quiz from './views/quiz'; import Quiz from './views/quiz';
import Modern from './views/Modern'; import Modern from './views/Modern';
import { fullBlocksSelector } from '../../entities';
import { import {
fetchChallenge, fetchChallenge,
challengeSelector, challengeSelector,
@ -42,11 +43,14 @@ const mapStateToProps = createSelector(
challengeSelector, challengeSelector,
challengeMetaSelector, challengeMetaSelector,
paramsSelector, paramsSelector,
fullBlocksSelector,
( (
{ dashedName, isTranslated }, { dashedName, isTranslated },
{ viewType, title }, { viewType, title },
params params,
blocks
) => ({ ) => ({
blocks,
challenge: dashedName, challenge: dashedName,
isTranslated, isTranslated,
params, params,

View File

@ -54,7 +54,6 @@ class InternetSettings extends PureComponent {
} }
handleSubmit(values) { handleSubmit(values) {
console.log(values);
this.props.updateUserBackend(values); this.props.updateUserBackend(values);
} }

View File

@ -2,14 +2,19 @@ import Fetchr from 'fetchr';
import getUserServices from '../services/user'; import getUserServices from '../services/user';
import getMapServices from '../services/map'; import getMapServices from '../services/map';
import getMapUiServices from '../services/mapUi'; import getMapUiServices from '../services/mapUi';
import getChallengesForBlockService from '../services/challenge';
export default function bootServices(app) { export default function bootServices(app) {
const userServices = getUserServices(app);
const mapServices = getMapServices(app);
const mapUiServices = getMapUiServices(app);
Fetchr.registerFetcher(userServices); const user = getUserServices(app);
Fetchr.registerFetcher(mapServices); const map = getMapServices(app);
Fetchr.registerFetcher(mapUiServices); 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()); app.use('/services', Fetchr.middleware());
} }

View File

@ -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
);
}
};
}

View File

@ -5,12 +5,18 @@ import { cachedMap, getMapForLang } from '../utils/map';
const log = debug('fcc:services:mapUi'); const log = debug('fcc:services:mapUi');
export default function mapUiService(app) { export default function mapUiService(app) {
const supportedLangMap = {};
const challengeMap = cachedMap(app.models); const challengeMap = cachedMap(app.models);
return { return {
name: 'map-ui', name: 'map-ui',
read: function readMapUi(req, resource, { lang = 'en' } = {}, config, cb) { read: function readMapUi(req, resource, { lang = 'en' } = {}, config, cb) {
log(`generating mapUi for ${lang}`); 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)) return challengeMap.map(getMapForLang(lang))
.flatMap(({ .flatMap(({
result: { superBlocks }, result: { superBlocks },
@ -34,19 +40,39 @@ export default function mapUiService(app) {
}, {}); }, {});
const challengeMap = Object.keys(fullChallengeMap) const challengeMap = Object.keys(fullChallengeMap)
.map(challenge => fullChallengeMap[challenge]) .map(challenge => fullChallengeMap[challenge])
.reduce((map, { dashedName, name, id}) => { .reduce((map, challenge) => {
map[dashedName] = {name, dashedName, id}; const {
dashedName,
id,
title,
name,
block,
isLocked,
isComingSoon,
isBeta
} = challenge;
map[dashedName] = {
dashedName,
id,
title,
name,
block,
isLocked,
isComingSoon,
isBeta
};
return map; return map;
}, {}); }, {});
const mapUi = {
return Observable.of({
result: { superBlocks }, result: { superBlocks },
entities: { entities: {
superBlock: superBlockMap, superBlock: superBlockMap,
block: blockMap, block: blockMap,
challenge: challengeMap challenge: challengeMap
} }
}); };
supportedLangMap[lang] = mapUi;
return Observable.of(mapUi);
}).subscribe( }).subscribe(
mapUi => cb(null, mapUi ), mapUi => cb(null, mapUi ),
err => { log(err); return cb(err); } err => { log(err); return cb(err); }

View File

@ -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; // type ObjectId: String;
// getChallengeById( // getChallengeById(
// map: Observable[map], // map: Observable[map],