Add block scoping to challenges url

This commit is contained in:
Berkeley Martinez
2016-06-09 16:02:51 -07:00
parent 91dc3625d9
commit acf4d99f67
15 changed files with 139 additions and 45 deletions

View File

@ -28,6 +28,8 @@ export const setUser = createAction(types.setUser);
// updatePoints(points: Number) => Action // updatePoints(points: Number) => Action
export const updatePoints = createAction(types.updatePoints); export const updatePoints = createAction(types.updatePoints);
// used when server needs client to redirect
export const delayedRedirect = createAction(types.delayedRedirect);
// hardGoTo(path: String) => Action // hardGoTo(path: String) => Action
export const hardGoTo = createAction(types.hardGoTo); export const hardGoTo = createAction(types.hardGoTo);

View File

@ -55,6 +55,10 @@ export default handleActions(
[types.toggleMainChat]: state => ({ [types.toggleMainChat]: state => ({
...state, ...state,
isMainChatOpen: !state.isMainChatOpen isMainChatOpen: !state.isMainChatOpen
}),
[types.delayedRedirect]: (state, { payload }) => ({
...state,
delayedRedirect: payload
}) })
}, },
initialState initialState

View File

@ -11,6 +11,7 @@ export default createTypes([
'handleError', 'handleError',
// used to hit the server // used to hit the server
'hardGoTo', 'hardGoTo',
'delayedRedirect',
'initWindowHeight', 'initWindowHeight',
'updateWindowHeight', 'updateWindowHeight',

View File

@ -35,8 +35,8 @@ const mapStateToProps = createSelector(
const fetchOptions = { const fetchOptions = {
fetchAction: 'fetchChallenge', fetchAction: 'fetchChallenge',
getActionArgs({ params: { dashedName } }) { getActionArgs({ params: { block, dashedName } }) {
return [ dashedName ]; return [ dashedName, block ];
}, },
isPrimed({ challenge }) { isPrimed({ challenge }) {
return !!challenge; return !!challenge;

View File

@ -13,12 +13,13 @@ export class Block extends PureComponent {
static displayName = 'Block'; static displayName = 'Block';
static propTypes = { static propTypes = {
title: PropTypes.string, title: PropTypes.string,
dashedName: PropTypes.string,
time: PropTypes.string, time: PropTypes.string,
challenges: PropTypes.array, challenges: PropTypes.array,
updateCurrentChallenge: PropTypes.func updateCurrentChallenge: PropTypes.func
}; };
renderChallenges(challenges, updateCurrentChallenge) { renderChallenges(blockName, challenges, updateCurrentChallenge) {
if (!Array.isArray(challenges) || !challenges.length) { if (!Array.isArray(challenges) || !challenges.length) {
return <div>No Challenges Found</div>; return <div>No Challenges Found</div>;
} }
@ -37,7 +38,8 @@ export class Block extends PureComponent {
return ( return (
<p <p
className={ challengeClassName } className={ challengeClassName }
key={ title }> key={ title }
>
{ title } { title }
{ {
isRequired ? isRequired ?
@ -50,10 +52,12 @@ export class Block extends PureComponent {
return ( return (
<p <p
className={ challengeClassName } className={ challengeClassName }
key={ title }> key={ title }
<Link to={ `/challenges/${dashedName}` }> >
<Link to={ `/challenges/${blockName}/${dashedName}` }>
<span <span
onClick={ () => updateCurrentChallenge(challenge) }> onClick={ () => updateCurrentChallenge(challenge) }
>
{ title } { title }
<span className='sr-only'>complete</span> <span className='sr-only'>complete</span>
{ {
@ -69,7 +73,13 @@ export class Block extends PureComponent {
} }
render() { render() {
const { title, time, challenges, updateCurrentChallenge } = this.props; const {
title,
time,
challenges,
updateCurrentChallenge,
dashedName
} = this.props;
return ( return (
<Panel <Panel
bsClass='map-accordion-panel-nested' bsClass='map-accordion-panel-nested'
@ -82,8 +92,11 @@ export class Block extends PureComponent {
</div> </div>
} }
id={ title } id={ title }
key={ title }> key={ title }
{ this.renderChallenges(challenges, updateCurrentChallenge) } >
{
this.renderChallenges(dashedName, challenges, updateCurrentChallenge)
}
</Panel> </Panel>
); );
} }

View File

@ -21,7 +21,8 @@ export default class SuperBlock extends PureComponent {
return ( return (
<Block <Block
key={ block.title } key={ block.title }
{ ...block } /> { ...block }
/>
); );
}); });
} }
@ -35,7 +36,8 @@ export default class SuperBlock extends PureComponent {
expanded={ true } expanded={ true }
header={ <h2><FA name='caret-right' />{ title }</h2> } header={ <h2><FA name='caret-right' />{ title }</h2> }
id={ title } id={ title }
key={ title }> key={ title }
>
{ {
message ? message ?
<div className='challenge-block-description'> <div className='challenge-block-description'>
@ -44,7 +46,8 @@ export default class SuperBlock extends PureComponent {
'' ''
} }
<div <div
className='map-accordion-block'> className='map-accordion-block'
>
{ this.renderBlocks(blocks) } { this.renderBlocks(blocks) }
</div> </div>
</Panel> </Panel>

View File

@ -12,6 +12,11 @@ export const challenges = {
} }
}; };
export const modernChallenges = {
path: 'challenges/:block/:dashedName',
component: Show
};
export const map = { export const map = {
path: 'map', path: 'map',
component: ShowMap component: ShowMap

View File

@ -9,7 +9,10 @@ export const goToStep = createAction(types.goToStep);
export const completeAction = createAction(types.completeAction); export const completeAction = createAction(types.completeAction);
// challenges // challenges
export const fetchChallenge = createAction(types.fetchChallenge); export const fetchChallenge = createAction(
types.fetchChallenge,
(dashedName, block) => ({ dashedName, block })
);
export const fetchChallengeCompleted = createAction( export const fetchChallengeCompleted = createAction(
types.fetchChallengeCompleted, types.fetchChallengeCompleted,
(_, challenge) => challenge, (_, challenge) => challenge,

View File

@ -1,20 +1,13 @@
import { Observable } from 'rx'; import { Observable } from 'rx';
import { push } from 'react-router-redux';
import types from './types'; import types from './types';
import { import { showChallengeComplete, moveToNextChallenge } from './actions';
showChallengeComplete,
moveToNextChallenge,
updateCurrentChallenge
} from './actions';
import { import {
createErrorObservable, createErrorObservable,
makeToast, makeToast,
updatePoints updatePoints
} from '../../../redux/actions'; } from '../../../redux/actions';
import { getNextChallenge } from '../utils';
import { challengeSelector } from './selectors'; import { challengeSelector } from './selectors';
import { backEndProject } from '../../../utils/challengeTypes'; import { backEndProject } from '../../../utils/challengeTypes';
import { randomCompliment } from '../../../utils/get-words'; import { randomCompliment } from '../../../utils/get-words';
import { postJSON$ } from '../../../../utils/ajax-stream'; import { postJSON$ } from '../../../../utils/ajax-stream';
@ -179,25 +172,13 @@ export default function completionSaga(actions$, getState) {
return actions$ return actions$
.filter(({ type }) => ( .filter(({ type }) => (
type === types.checkChallenge || type === types.checkChallenge ||
type === types.submitChallenge || type === types.submitChallenge
type === types.moveToNextChallenge
)) ))
.flatMap(({ type, payload }) => { .flatMap(({ type, payload }) => {
const state = getState(); const state = getState();
const { submitType } = challengeSelector(state); const { submitType } = challengeSelector(state);
const submitter = submitTypes[submitType] || const submitter = submitTypes[submitType] ||
(() => Observable.just(null)); (() => Observable.just(null));
if (type === types.moveToNextChallenge) {
const nextChallenge = getNextChallenge(
state.challengesApp.challenge,
state.entities,
state.challengesApp.superBlocks
);
return Observable.of(
updateCurrentChallenge(nextChallenge),
push(`/challenges/${nextChallenge.dashedName}`)
);
}
return submitter(type, state, payload); return submitter(type, state, payload);
}); });
} }

View File

@ -1,7 +1,10 @@
import { Observable } from 'rx'; import { Observable } from 'rx';
import { fetchChallenge, fetchChallenges } from './types'; import { fetchChallenge, fetchChallenges } from './types';
import { import {
createErrorObserable, delayedRedirect,
createErrorObserable
} from '../../../redux/actions';
import {
fetchChallengeCompleted, fetchChallengeCompleted,
fetchChallengesCompleted, fetchChallengesCompleted,
updateCurrentChallenge updateCurrentChallenge
@ -13,17 +16,18 @@ export default function fetchChallengesSaga(action$, getState, { services }) {
type === fetchChallenges || type === fetchChallenges ||
type === fetchChallenge type === fetchChallenge
)) ))
.flatMap(({ type, payload })=> { .flatMap(({ type, payload: { dashedName, block } = {} }) => {
const options = { service: 'map' }; const options = { service: 'map' };
if (type === fetchChallenge) { if (type === fetchChallenge) {
options.params = { dashedName: payload }; options.params = { dashedName, block };
} }
return services.readService$(options) return services.readService$(options)
.flatMap(({ entities, result } = {}) => { .flatMap(({ entities, result, redirect } = {}) => {
if (type === fetchChallenge) { if (type === fetchChallenge) {
return Observable.of( return Observable.of(
fetchChallengeCompleted(entities, result), fetchChallengeCompleted(entities, result),
updateCurrentChallenge(entities.challenge[result]) updateCurrentChallenge(entities.challenge[result.challenge]),
redirect ? delayedRedirect(redirect) : null
); );
} }
return Observable.just(fetchChallengesCompleted(entities, result)); return Observable.just(fetchChallengesCompleted(entities, result));

View File

@ -0,0 +1,23 @@
import { Observable } from 'rx';
import { push } from 'react-router-redux';
import { moveToNextChallenge } from './types';
import { getNextChallenge } from '../utils';
import { updateCurrentChallenge } from './actions';
// import { createErrorObservable, makeToast } from '../../../redux/actions';
export default function nextChallengeSaga(actions$, getState) {
return actions$
.filter(({ type }) => type === moveToNextChallenge)
.flatMap(() => {
const state = getState();
const nextChallenge = getNextChallenge(
state.challengesApp.challenge,
state.entities,
state.challengesApp.superBlocks
);
return Observable.of(
updateCurrentChallenge(nextChallenge),
push(`/challenges/${nextChallenge.dashedName}`)
);
});
}

View File

@ -61,7 +61,7 @@ export function getFileKey({ challengeType }) {
export function createTests({ tests = [] }) { export function createTests({ tests = [] }) {
return tests return tests
.map(test => ({ .map(test => ({
text: test.split('message: ').pop().replace(/\'\);/g, ''), text: ('' + test).split('message: ').pop().replace(/\'\);/g, ''),
testString: test testString: test
})); }));
} }

View File

@ -1,6 +1,6 @@
import Jobs from './Jobs'; import Jobs from './Jobs';
import Hikes from './Hikes'; import Hikes from './Hikes';
import { map, challenges } from './challenges'; import { modernChallenges, map, challenges } from './challenges';
import NotFound from '../components/NotFound/index.jsx'; import NotFound from '../components/NotFound/index.jsx';
export default { export default {
@ -9,6 +9,7 @@ export default {
Jobs, Jobs,
Hikes, Hikes,
challenges, challenges,
modernChallenges,
map, map,
{ {
path: '*', path: '*',

View File

@ -64,10 +64,19 @@ export default function reactSubRouter(app) {
) )
.map(({ markup }) => ({ markup, store, epic })); .map(({ markup }) => ({ markup, store, epic }));
}) })
.filter(({ store, epic }) => {
const { delayedRedirect } = store.getState().app;
if (delayedRedirect) {
res.redirect(delayedRedirect);
epic.dispose();
return false;
}
return true;
})
.flatMap(function({ markup, store, epic }) { .flatMap(function({ markup, store, epic }) {
log('react markup rendered, data fetched'); log('react markup rendered, data fetched');
const state = store.getState(); const state = store.getState();
const { title } = state.app.title; const { title } = state.app;
epic.dispose(); epic.dispose();
res.expose(state, 'data'); res.expose(state, 'data');
return res.render$( return res.render$(

View File

@ -1,6 +1,7 @@
import { Observable } from 'rx'; import { Observable } from 'rx';
import { Schema, valuesOf, arrayOf, normalize } from 'normalizr'; import { Schema, valuesOf, arrayOf, normalize } from 'normalizr';
import { nameify, dasherize, unDasherize } from '../utils'; import { nameify, dasherize, unDasherize } from '../utils';
import { dashify } from '../../common/utils';
import debug from 'debug'; import debug from 'debug';
const isDev = process.env.NODE_ENV !== 'production'; const isDev = process.env.NODE_ENV !== 'production';
@ -118,6 +119,41 @@ function getFirstChallenge(challengeMap$) {
}); });
} }
// this is a hard search
// falls back to soft search
function getChallengeAndBlock(
challengeDashedName,
blockDashedName,
challengeMap$
) {
return challengeMap$
.flatMap(({ entities }) => {
const block = entities.block[blockDashedName];
const challenge = entities.challenge[challengeDashedName];
if (
!block ||
!challenge ||
!shouldNotFilterComingSoon(challenge)
) {
return getChallengeByDashedName(challengeDashedName, challengeMap$);
}
return Observable.just({
redirect: block.dashedName !== blockDashedName ?
`/challenges/${block.dashedName}/${challenge.dashedName}` :
false,
entities: {
challenge: {
[challenge.dashedName]: challenge
}
},
result: {
block: block.dashedName,
challenge: challenge.dashedName
}
});
});
}
function getChallengeByDashedName(dashedName, challengeMap$) { function getChallengeByDashedName(dashedName, challengeMap$) {
const challengeName = unDasherize(dashedName) const challengeName = unDasherize(dashedName)
.replace(challengesRegex, ''); .replace(challengesRegex, '');
@ -142,8 +178,13 @@ function getChallengeByDashedName(dashedName, challengeMap$) {
return getFirstChallenge(challengeMap$); return getFirstChallenge(challengeMap$);
}) })
.map(challenge => ({ .map(challenge => ({
redirect:
`/challenges/${dashify(challenge.block)}/${challenge.dashedName}`,
entities: { challenge: { [challenge.dashedName]: challenge } }, entities: { challenge: { [challenge.dashedName]: challenge } },
result: challenge.dashedName result: {
challenge: challenge.dashedName,
block: dashify(challenge.block)
}
})); }));
} }
@ -152,7 +193,11 @@ export default function mapService(app) {
const challengeMap$ = cachedMap(Block); const challengeMap$ = cachedMap(Block);
return { return {
name: 'map', name: 'map',
read: (req, resource, { dashedName } = {}, config, cb) => { read: (req, resource, { block, dashedName } = {}, config, cb) => {
if (block && dashedName) {
return getChallengeAndBlock(dashedName, block, challengeMap$)
.subscribe(challenge => cb(null, challenge), cb);
}
if (dashedName) { if (dashedName) {
return getChallengeByDashedName(dashedName, challengeMap$) return getChallengeByDashedName(dashedName, challengeMap$)
.subscribe(challenge => cb(null, challenge), cb); .subscribe(challenge => cb(null, challenge), cb);