Fix: map should redirect to current challenge (#15723)

* fix(routes): /map redirects to current challenge

* fix(map): Normalize server map building

Localize all server code dealing with the map

* refactor(server): Remove unused services

* feat(Nav): Show Map button when no panes

This gives user the ability to quickly return to their challenge using a
known feature

* fix(server.map): Add caching to nameIdMap

Add caching to nameIdMap on the server

* fix(services.map): Fix map service

Move map building utils to map util. Fix bad import. Normalize challenge
lookup
This commit is contained in:
Berkeley Martinez
2017-08-03 20:45:36 -07:00
committed by Quincy Larson
parent f92294bbda
commit c547c26bba
16 changed files with 352 additions and 364 deletions

View File

@ -20,6 +20,7 @@ import SignUp from './Sign-Up.jsx';
import BinButton from './Bin-Button.jsx'; import BinButton from './Bin-Button.jsx';
import { import {
clickOnLogo, clickOnLogo,
clickOnMap,
openDropdown, openDropdown,
closeDropdown, closeDropdown,
createNavLinkActionCreator, createNavLinkActionCreator,
@ -73,6 +74,10 @@ function mapDispatchToProps(dispatch) {
return mdtp; return mdtp;
}, },
{ {
clickOnMap: e => {
e.preventDefault();
return clickOnMap();
},
clickOnLogo: e => { clickOnLogo: e => {
e.preventDefault(); e.preventDefault();
return clickOnLogo(); return clickOnLogo();
@ -180,12 +185,14 @@ export class FCCNav extends React.Component {
const { const {
panes, panes,
clickOnLogo, clickOnLogo,
clickOnMap,
username, username,
points, points,
picture, picture,
showLoading showLoading
} = this.props; } = this.props;
const shouldShowMapButton = panes.length === 0;
return ( return (
<Navbar <Navbar
className='nav-height' className='nav-height'
@ -221,6 +228,14 @@ export class FCCNav extends React.Component {
/> />
)) ))
} }
{ shouldShowMapButton ?
<BinButton
content='Map'
handleClick={ clickOnMap }
key='Map'
/> :
null
}
{ {
navLinks.map( navLinks.map(
this.renderLink.bind(this, true) this.renderLink.bind(this, true)

View File

@ -11,7 +11,7 @@ import {
import { entitiesSelector } from '../../entities'; import { entitiesSelector } from '../../entities';
export default function loadCurrentChallengeEpic(actions, { getState }) { export default function loadCurrentChallengeEpic(actions, { getState }) {
return actions::ofType(types.clickOnLogo) return actions::ofType(types.clickOnLogo, types.clickOnMap)
.debounce(500) .debounce(500)
.map(() => { .map(() => {
let finalChallenge; let finalChallenge;

View File

@ -1,6 +1,5 @@
import flowRight from 'lodash/flowRight'; import flowRight from 'lodash/flowRight';
import createNameIdMap from '../../utils/create-name-id-map.js'; import { createNameIdMap } from '../../utils/map.js';
export function filterComingSoonBetaChallenge( export function filterComingSoonBetaChallenge(
isDev = false, isDev = false,
@ -29,5 +28,8 @@ export function filterComingSoonBetaFromEntities(
export const shapeChallenges = flowRight( export const shapeChallenges = flowRight(
filterComingSoonBetaFromEntities, filterComingSoonBetaFromEntities,
createNameIdMap entities => ({
...entities,
...createNameIdMap(entities)
})
); );

View File

@ -20,7 +20,7 @@ export default function challengesRoutes() {
onEnter(nextState, replace) { onEnter(nextState, replace) {
// redirect /challenges to /map // redirect /challenges to /map
if (nextState.location.pathname === '/challenges') { if (nextState.location.pathname === '/challenges') {
replace('/map'); replace('/challenges/current-challenge');
} }
} }
}, { }, {

View File

@ -1,8 +1,6 @@
import ShowMap from '../../Map';
export default function mapRoute() { export default function mapRoute() {
return [{ return [{
path: 'map', path: 'map',
component: ShowMap onEnter: (_, replace) => replace('/challenges/current-challenge')
}]; }];
} }

View File

@ -0,0 +1,12 @@
import { Observable } from 'rx';
export default function(Challenge) {
Challenge.on('dataSourceAttached', () => {
Challenge.findOne$ =
Observable.fromNodeCallback(Challenge.findOne, Challenge);
Challenge.findById$ =
Observable.fromNodeCallback(Challenge.findById, Challenge);
Challenge.find$ =
Observable.fromNodeCallback(Challenge.find, Challenge);
});
}

View File

@ -1,12 +0,0 @@
// createNameIdMap(entities: Object) => Object
export default function createNameIdMap(entities) {
const { challenge } = entities;
return {
...entities,
challengeIdToName: Object.keys(challenge)
.reduce((map, challengeName) => {
map[challenge[challengeName].id] = challenge[challengeName].dashedName;
return map;
}, {})
};
}

View File

@ -1,44 +0,0 @@
import emptyProtector from '../app/utils/empty-protector';
export function checkMapData(
{
entities: {
challenge,
block,
superBlock,
challengeIdToName
},
result: { superBlocks }
}
) {
if (
!challenge ||
!block ||
!superBlock ||
!challengeIdToName ||
!superBlocks ||
!superBlocks.length
) {
throw new Error(
'entities not found, db may not be properly seeded'
);
}
}
// getFirstChallenge(
// map: {
// entities: { challenge: Object, block: Object, superBlock: Object },
// result: [...superBlockDashedName: String]
// }
// ) => Challenge|Void
export function getFirstChallenge({
entities: { superBlock, block, challenge },
result
}) {
return challenge[
emptyProtector(block[
emptyProtector(superBlock[
result[0]
]).blocks[0]
]).challenges[0]
];
}

74
common/utils/map.js Normal file
View File

@ -0,0 +1,74 @@
import emptyProtector from '../app/utils/empty-protector';
export function checkMapData(
{
entities: {
challenge,
block,
superBlock
},
result: { superBlocks }
}
) {
if (
!challenge ||
!block ||
!superBlock ||
!superBlocks ||
!superBlocks.length
) {
throw new Error(
'entities not found, db may not be properly seeded'
);
}
}
// getFirstChallenge(
// map: {
// entities: { challenge: Object, block: Object, superBlock: Object },
// result: [...superBlockDashedName: String]
// }
// ) => Challenge|Void
export function getFirstChallenge({
entities: { superBlock, block, challenge },
result: { superBlocks }
}) {
return challenge[
emptyProtector(block[
emptyProtector(superBlock[
superBlocks[0]
]).blocks[0]
]).challenges[0]
];
}
// let challengeDashedName: String;
// createNameIdMap({
// challenge: {
// [...challengeDashedName ]: Challenge
// }) => {
// challengeIdToName: {
// [ ...challengeId ]: challengeDashedName
// }
// };
export function createNameIdMap({ challenge }) {
return {
challengeIdToName: Object.keys(challenge)
.reduce((map, challengeName) => {
map[challenge[challengeName].id] =
challenge[challengeName].dashedName;
return map;
}, {})
};
}
// addNameIdMap(
// map: { entities; Object, ...rest }
// ) => { ...rest, entities: Object };
export function addNameIdMap({ entities, ...rest }) {
return {
...rest,
entities: {
...entities,
...createNameIdMap(entities)
}
};
}

View File

@ -1,14 +1,11 @@
import Fetchr from 'fetchr'; import Fetchr from 'fetchr';
import getHikesService from '../services/hikes';
import getUserServices from '../services/user'; import getUserServices from '../services/user';
import getMapServices from '../services/map'; import getMapServices from '../services/map';
export default function bootServices(app) { export default function bootServices(app) {
const hikesService = getHikesService(app);
const userServices = getUserServices(app); const userServices = getUserServices(app);
const mapServices = getMapServices(app); const mapServices = getMapServices(app);
Fetchr.registerFetcher(hikesService);
Fetchr.registerFetcher(userServices); Fetchr.registerFetcher(userServices);
Fetchr.registerFetcher(mapServices); Fetchr.registerFetcher(mapServices);
app.use('/services', Fetchr.middleware()); app.use('/services', Fetchr.middleware());

View File

@ -4,12 +4,7 @@ import accepts from 'accepts';
import dedent from 'dedent'; import dedent from 'dedent';
import { ifNoUserSend } from '../utils/middleware'; import { ifNoUserSend } from '../utils/middleware';
import { cachedMap } from '../utils/map'; import { getChallengeById, cachedMap } from '../utils/map';
import createNameIdMap from '../../common/utils/create-name-id-map';
import {
checkMapData,
getFirstChallenge
} from '../../common/utils/get-first-challenge';
const log = debug('fcc:boot:challenges'); const log = debug('fcc:boot:challenges');
@ -73,8 +68,7 @@ export default function(app) {
const send200toNonUser = ifNoUserSend(true); const send200toNonUser = ifNoUserSend(true);
const api = app.loopback.Router(); const api = app.loopback.Router();
const router = app.loopback.Router(); const router = app.loopback.Router();
const Block = app.models.Block; const map = cachedMap(app.models);
const map$ = cachedMap(Block);
api.post( api.post(
'/modern-challenge-completed', '/modern-challenge-completed',
@ -344,43 +338,23 @@ export default function(app) {
function redirectToCurrentChallenge(req, res, next) { function redirectToCurrentChallenge(req, res, next) {
const { user } = req; const { user } = req;
return map$ const challengeId = user && user.currentChallengeId;
.map(({ entities, result }) => ({ return getChallengeById(map, challengeId)
result, .map(challenge => {
entities: createNameIdMap(entities) const { block, dashedName } = challenge;
})) if (!dashedName || !block) {
.map(map => {
checkMapData(map);
const {
entities: { challenge: challengeMap, challengeIdToName }
} = map;
let finalChallenge;
const dashedName = challengeIdToName[user && user.currentChallengeId];
finalChallenge = challengeMap[dashedName];
// redirect to first challenge
if (!finalChallenge) {
finalChallenge = getFirstChallenge(map);
}
const { block, dashedName: finalDashedName } = finalChallenge || {};
if (!finalDashedName || !block) {
// this should normally not be hit if database is properly seeded // this should normally not be hit if database is properly seeded
console.error(new Error(dedent` throw new Error(dedent`
Attemped to find '${dashedName}' Attemped to find '${dashedName}'
from '${user && user.currentChallengeId || 'no challenge id found'}' from '${ challengeId || 'no challenge id found'}'
but came up empty. but came up empty.
db may not be properly seeded. db may not be properly seeded.
`)); `);
if (dashedName) {
// attempt to find according to dashedName
return `/challenges/${dashedName}`;
} else {
return null;
} }
} return `/challenges/${block}/${dashedName}`;
return `/challenges/${block}/${finalDashedName}`;
}) })
.subscribe( .subscribe(
redirect => res.redirect(redirect || '/map'), redirect => res.redirect(redirect || '/'),
next next
); );
} }

View File

@ -22,8 +22,7 @@ import {
calcLongestStreak calcLongestStreak
} from '../utils/user-stats'; } from '../utils/user-stats';
import supportedLanguages from '../../common/utils/supported-languages'; import supportedLanguages from '../../common/utils/supported-languages';
import createNameIdMap from '../../common/utils/create-name-id-map'; import { getChallengeInfo, cachedMap } from '../utils/map';
import { cachedMap } from '../utils/map';
const debug = debugFactory('fcc:boot:user'); const debug = debugFactory('fcc:boot:user');
const sendNonUserToMap = ifNoUserRedirectTo('/map'); const sendNonUserToMap = ifNoUserRedirectTo('/map');
@ -97,7 +96,7 @@ function getChallengeGroup(challenge) {
// challenges: Array // challenges: Array
// }] // }]
function buildDisplayChallenges( function buildDisplayChallenges(
{ challenge: challengeMap = {}, challengeIdToName }, { challengeMap, challengeIdToName },
userChallengeMap = {}, userChallengeMap = {},
timezone timezone
) { ) {
@ -139,10 +138,8 @@ function buildDisplayChallenges(
module.exports = function(app) { module.exports = function(app) {
const router = app.loopback.Router(); const router = app.loopback.Router();
const api = app.loopback.Router(); const api = app.loopback.Router();
const User = app.models.User; const { User, Email } = app.models;
const Block = app.models.Block; const map$ = cachedMap(app.models);
const { Email } = app.models;
const map$ = cachedMap(Block);
function findUserByUsername$(username, fields) { function findUserByUsername$(username, fields) {
return observeQuery( return observeQuery(
User, User,
@ -436,9 +433,9 @@ module.exports = function(app) {
userPortfolio.bio = emoji.emojify(userPortfolio.bio); userPortfolio.bio = emoji.emojify(userPortfolio.bio);
} }
return map$.map(({ entities }) => createNameIdMap(entities)) return getChallengeInfo(map$)
.flatMap(entities => buildDisplayChallenges( .flatMap(challengeInfo => buildDisplayChallenges(
entities, challengeInfo,
userPortfolio.challengeMap, userPortfolio.challengeMap,
timezone timezone
)) ))

View File

@ -1,32 +0,0 @@
import debugFactory from 'debug';
const debug = debugFactory('fcc:services:hikes');
export default function hikesService(app) {
const Challenge = app.models.Challenge;
return {
name: 'hikes',
read: (req, resource, { dashedName } = {}, config, cb) => {
const query = {
where: {
challengeType: '6',
isComingSoon: false
},
order: ['order ASC', 'suborder ASC' ]
};
debug('dashedName', dashedName);
if (dashedName) {
query.where.dashedName = { like: dashedName, options: 'i' };
}
debug('query', query);
Challenge.find(query, (err, hikes) => {
if (err) {
return cb(err);
}
return cb(null, hikes.map(hike => hike.toJSON()));
});
}
};
}

View File

@ -1,41 +0,0 @@
const whereFilt = {
where: {
isFilled: false,
isPaid: true,
isApproved: true
},
order: 'postedOn DESC'
};
export default function getJobServices(app) {
const { Job } = app.models;
return {
name: 'jobs',
create(req, resource, { job } = {}, body, config, cb) {
if (!job) {
return cb(new Error('job creation should get a job object'));
}
Object.assign(job, {
isPaid: false,
isApproved: false
});
return Job.create(job, (err, savedJob) => {
cb(err, savedJob.toJSON());
});
},
read(req, resource, params, config, cb) {
const id = params ? params.id : null;
if (id) {
return Job.findById(id)
.then(job => cb(null, job.toJSON()))
.catch(cb);
}
return Job.find(whereFilt)
.then(jobs => cb(null, jobs.map(job => job.toJSON())))
.catch(cb);
}
};
}

View File

@ -1,105 +1,15 @@
import { Observable } from 'rx'; import { Observable } from 'rx';
import debug from 'debug'; import debug from 'debug';
import { unDasherize } from '../utils'; import {
import { mapChallengeToLang, cachedMap, getMapForLang } from '../utils/map'; cachedMap,
getChallenge,
getMapForLang
} from '../utils/map';
const isDev = process.env.NODE_ENV !== 'production';
const isBeta = !!process.env.BETA;
const challengesRegex = /^(bonfire|waypoint|zipline|basejump|checkpoint)/i;
const log = debug('fcc:services:map'); const log = debug('fcc:services:map');
// if challenge is not isComingSoon or isBeta => load
// if challenge is ComingSoon we are in beta||dev => load
// if challenge is beta and we are in beta||dev => load
// else hide
function loadComingSoonOrBetaChallenge({
isComingSoon,
isBeta: challengeIsBeta
}) {
return !(isComingSoon || challengeIsBeta) || isDev || isBeta;
}
function getFirstChallenge(challengeMap$) {
return challengeMap$
.map(({ entities: { superBlock, block, challenge }, result }) => {
return challenge[
block[
superBlock[
result[0]
].blocks[0]
].challenges[0]
];
});
}
// this is a hard search
// falls back to soft search
function getChallenge(
challengeDashedName,
blockDashedName,
challengeMap$,
lang
) {
return challengeMap$
.flatMap(({ entities, result: { superBlocks } }) => {
const block = entities.block[blockDashedName];
const challenge = entities.challenge[challengeDashedName];
return Observable.if(
() => (
!blockDashedName ||
!block ||
!challenge ||
!loadComingSoonOrBetaChallenge(challenge)
),
getChallengeByDashedName(challengeDashedName, challengeMap$),
Observable.just(challenge)
)
.map(challenge => ({
redirect: challenge.block !== blockDashedName ?
`/challenges/${block.dashedName}/${challenge.dashedName}` :
false,
entities: {
challenge: {
[challenge.dashedName]: mapChallengeToLang(challenge, lang)
}
},
result: {
block: block.dashedName,
challenge: challenge.dashedName,
superBlocks
}
}));
});
}
function getChallengeByDashedName(dashedName, challengeMap$) {
const challengeName = unDasherize(dashedName)
.replace(challengesRegex, '');
const testChallengeName = new RegExp(challengeName, 'i');
log('looking for %s', testChallengeName);
return challengeMap$
.map(({ entities }) => entities.challenge)
.flatMap(challengeMap => {
return Observable.from(Object.keys(challengeMap))
.map(key => challengeMap[key]);
})
.filter(challenge => {
return loadComingSoonOrBetaChallenge(challenge) &&
testChallengeName.test(challenge.name);
})
.last({ defaultValue: null })
.flatMap(challengeOrNull => {
if (challengeOrNull) {
return Observable.just(challengeOrNull);
}
return getFirstChallenge(challengeMap$);
});
}
export default function mapService(app) { export default function mapService(app) {
const Block = app.models.Block; const challengeMap = cachedMap(app.models);
const challengeMap = cachedMap(Block);
return { return {
name: 'map', name: 'map',
read: (req, resource, { lang, block, dashedName } = {}, config, cb) => { read: (req, resource, { lang, block, dashedName } = {}, config, cb) => {
@ -109,7 +19,10 @@ export default function mapService(app) {
getChallenge(dashedName, block, challengeMap, lang), getChallenge(dashedName, block, challengeMap, lang),
challengeMap.map(getMapForLang(lang)) challengeMap.map(getMapForLang(lang))
) )
.subscribe(results => cb(null, results), cb); .subscribe(
results => cb(null, results),
err => { log(err); cb(err); }
);
} }
}; };
} }

View File

@ -1,24 +1,19 @@
import _ from 'lodash'; import _ from 'lodash';
import { Observable } from 'rx'; import { Observable } from 'rx';
import { Schema, valuesOf, arrayOf, normalize } from 'normalizr';
import { nameify } from '../utils'; import { unDasherize, nameify } from '../utils';
import supportedLanguages from '../../common/utils/supported-languages'; import supportedLanguages from '../../common/utils/supported-languages';
import {
addNameIdMap as _addNameIdToMap,
checkMapData,
getFirstChallenge as _getFirstChallenge
} from '../../common/utils/map.js';
const challenge = new Schema('challenge', { idAttribute: 'dashedName' }); const isDev = process.env.NODE_ENV !== 'production';
const block = new Schema('block', { idAttribute: 'dashedName' }); const isBeta = !!process.env.BETA;
const superBlock = new Schema('superBlock', { idAttribute: 'dashedName' }); const challengesRegex = /^(bonfire|waypoint|zipline|basejump|checkpoint)/i;
const addNameIdMap = _.once(_addNameIdToMap);
block.define({ const getFirstChallenge = _.once(_getFirstChallenge);
challenges: arrayOf(challenge)
});
superBlock.define({
blocks: arrayOf(block)
});
const mapSchema = valuesOf(superBlock);
let mapObservableCache;
/* /*
* interface ChallengeMap { * interface ChallengeMap {
* result: { * result: {
@ -26,82 +21,99 @@ let mapObservableCache;
* }, * },
* entities: { * entities: {
* superBlock: { * superBlock: {
* [ ...superBlockDashedName: String ]: SuperBlock * [ ...superBlockDashedName ]: SuperBlock
* }, * },
* block: { * block: {
* [ ...blockDashedName: String ]: Block, * [ ...blockDashedNameg ]: Block,
* challenge: { * challenge: {
* [ ...challengeDashedName: String ]: Challenge * [ ...challengeDashedNameg ]: Challenge
* } * }
* } * }
* } * }
*/ */
export function cachedMap(Block) { export function _cachedMap({ Block, Challenge }) {
if (mapObservableCache) { const challenges = Challenge.find$({
return mapObservableCache; order: [ 'order ASC', 'suborder ASC' ]
} });
const query = { const challengeMap = challenges
include: 'challenges', .map(
order: ['superOrder ASC', 'order ASC'] challenges => challenges
.map(challenge => challenge.toJSON())
.reduce((hash, challenge) => {
hash[challenge.dashedName] = challenge;
return hash;
}, {})
);
const blocks = Block.find$({ order: [ 'superOrder ASC', 'order ASC' ] });
const blockMap = Observable.combineLatest(
blocks.map(
blocks => blocks
.map(block => block.toJSON())
.reduce((hash, block) => {
hash[block.dashedName] = block;
return hash;
}, {})
),
challenges
)
.map(([ blocksMap, challenges ]) => {
return challenges.reduce((blocksMap, challenge) => {
if (blocksMap[challenge.block].challenges) {
blocksMap[challenge.block].challenges.push(challenge.dashedName);
} else {
blocksMap[challenge.block] = {
...blocksMap[challenge.block],
challenges: [ challenge.dashedName ]
}; };
const map$ = Block.find$(query) }
.flatMap(blocks => Observable.from(blocks.map(block => block.toJSON()))) return blocksMap;
.reduce((map, block) => { }, blocksMap);
if (map[block.superBlock]) { });
map[block.superBlock].blocks.push(block); 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 { } else {
map[block.superBlock] = { map[block.superBlock] = {
title: _.startCase(block.superBlock), title: _.startCase(block.superBlock),
order: block.superOrder, order: block.superOrder,
name: nameify(_.startCase(block.superBlock)), name: nameify(_.startCase(block.superBlock)),
dashedName: block.superBlock, dashedName: block.superBlock,
blocks: [block], blocks: [block.dashedName],
message: block.superBlockMessage message: block.superBlockMessage
}; };
} }
return map; return map;
}, {}) }, {}));
.map(map => normalize(map, mapSchema)) const superBlocks = superBlockMap.map(superBlockMap => {
.map(map => { return Object.keys(superBlockMap)
// make sure challenges are in the right order .map(key => superBlockMap[key])
map.entities.block = Object.keys(map.entities.block) .map(({ dashedName }) => dashedName);
// turn map into array });
.map(key => map.entities.block[key]) return Observable.combineLatest(
// perform re-order superBlockMap,
.map(block => { blockMap,
block.challenges = block.challenges.reduce((accu, dashedName) => { challengeMap,
const index = map.entities.challenge[dashedName].suborder; superBlocks,
accu[index - 1] = dashedName; (superBlock, block, challenge, superBlocks) => ({
return accu; entities: {
}, []); superBlock,
return block; block,
}) challenge
// turn back into map },
.reduce((blockMap, block) => {
blockMap[block.dashedName] = block;
return blockMap;
}, {});
return map;
})
.map(map => {
// re-order superBlocks result
const superBlocks = Object.keys(map.result).reduce((result, supName) => {
const index = map.entities.superBlock[supName].order;
result[index] = supName;
return result;
}, []);
return {
...map,
result: { result: {
superBlocks superBlocks
} }
};
}) })
)
.do(checkMapData)
.shareReplay(); .shareReplay();
mapObservableCache = map$;
return map$;
} }
export const cachedMap = _.once(_cachedMap);
export function mapChallengeToLang( export function mapChallengeToLang(
{ translations = {}, ...challenge }, { translations = {}, ...challenge },
lang lang
@ -137,3 +149,126 @@ export function getMapForLang(lang) {
return { result, entities }; return { result, entities };
}; };
} }
// type ObjectId: String;
// getChallengeById(
// map: Observable[map],
// id: ObjectId
// ) => Observable[Challenge] | Void;
export function getChallengeById(map, id) {
return Observable.if(
() => !id,
map.map(getFirstChallenge),
map.map(addNameIdMap)
.map(map => {
const {
entities: { challenge: challengeMap, challengeIdToName }
} = map;
let finalChallenge;
const dashedName = challengeIdToName[id];
finalChallenge = challengeMap[dashedName];
if (!finalChallenge) {
finalChallenge = getFirstChallenge(map);
}
return finalChallenge;
})
);
}
export function getChallengeInfo(map) {
return map.map(addNameIdMap)
.map(({
entities: {
challenge: challengeMap,
challengeIdToName
}
}) => ({
challengeMap,
challengeIdToName
}));
}
// if challenge is not isComingSoon or isBeta => load
// if challenge is ComingSoon we are in beta||dev => load
// if challenge is beta and we are in beta||dev => load
// else hide
function loadComingSoonOrBetaChallenge({
isComingSoon,
isBeta: challengeIsBeta
}) {
return !(isComingSoon || challengeIsBeta) || isDev || isBeta;
}
// this is a hard search
// falls back to soft search
export function getChallenge(
challengeDashedName,
blockDashedName,
map,
lang
) {
return map
.flatMap(({ entities, result: { superBlocks } }) => {
const block = entities.block[blockDashedName];
const challenge = entities.challenge[challengeDashedName];
return Observable.if(
() => (
!blockDashedName ||
!block ||
!challenge ||
!loadComingSoonOrBetaChallenge(challenge)
),
getChallengeByDashedName(challengeDashedName, map),
Observable.just({ block, challenge })
)
.map(({ challenge, block }) => ({
redirect: challenge.block !== blockDashedName ?
`/challenges/${block.dashedName}/${challenge.dashedName}` :
false,
entities: {
challenge: {
[challenge.dashedName]: mapChallengeToLang(challenge, lang)
}
},
result: {
block: block.dashedName,
challenge: challenge.dashedName,
superBlocks
}
}));
});
}
export function getBlockForChallenge(map, challenge) {
return map.map(({ entities: { block } }) => block[challenge.block]);
}
export function getChallengeByDashedName(dashedName, map) {
const challengeName = unDasherize(dashedName)
.replace(challengesRegex, '');
const testChallengeName = new RegExp(challengeName, 'i');
return map
.map(({ entities }) => entities.challenge)
.flatMap(challengeMap => {
return Observable.from(Object.keys(challengeMap))
.map(key => challengeMap[key]);
})
.filter(challenge => {
return loadComingSoonOrBetaChallenge(challenge) &&
testChallengeName.test(challenge.name);
})
.last({ defaultValue: null })
.flatMap(challengeOrNull => {
return Observable.if(
() => !!challengeOrNull,
Observable.just(challengeOrNull),
map.map(getFirstChallenge)
);
})
.flatMap(challenge => {
return getBlockForChallenge(map, challenge)
.map(block => ({ challenge, block }));
});
}