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 {
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 (
<Panel
@ -85,6 +90,7 @@ export class Block extends PureComponent {
header={ this.renderHeader(isOpen, title, time) }
id={ title }
key={ title }
onClick={ () => fetchNewBlock(dashedName) }
onSelect={ this.handleSelect }
>
{ isOpen && <Challenges challenges={ challenges } /> }
@ -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);

View File

@ -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 <div>No Super Blocks</div>;
return (
<div style={{ hieght: '300px' }}>
<Loader />
</div>
);
}
return superBlocks.map(dashedName => (
<SuperBlock
@ -31,12 +40,11 @@ export class ShowMap extends PureComponent {
}
render() {
const { superBlocks } = this.props;
return (
<Row>
<Col xs={ 12 }>
<div className={ `${ns}-accordion center-block` }>
{ this.renderSuperBlocks(superBlocks) }
{ this.renderSuperBlocks() }
<div className='spacer' />
</div>
</Col>

View File

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

View File

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

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 {
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 } }) => ({

View File

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

View File

@ -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

View File

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

View File

@ -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,

View File

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

View File

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

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

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