Merge pull request #10064 from BerkeleyTrue/fix/actual-current-route

feature(challenges): load current challenge on first visit
This commit is contained in:
Mrugesh Mohapatra
2016-08-06 17:11:07 +05:30
committed by GitHub
17 changed files with 351 additions and 59 deletions

View File

@ -11,7 +11,8 @@ import {
toggleMapDrawer,
toggleMainChat,
updateAppLang,
trackEvent
trackEvent,
loadCurrentChallenge
} from './redux/actions';
import { submitChallenge } from './routes/challenges/redux/actions';
@ -28,7 +29,8 @@ const bindableActions = {
toggleMapDrawer,
toggleMainChat,
updateAppLang,
trackEvent
trackEvent,
loadCurrentChallenge
};
const mapStateToProps = createSelector(
@ -80,7 +82,8 @@ export class FreeCodeCamp extends React.Component {
shouldShowSignIn: PropTypes.bool,
params: PropTypes.object,
updateAppLang: PropTypes.func.isRequired,
trackEvent: PropTypes.func.isRequired
trackEvent: PropTypes.func.isRequired,
loadCurrentChallenge: PropTypes.func.isRequired
};
componentWillReceiveProps(nextProps) {
@ -124,7 +127,8 @@ export class FreeCodeCamp extends React.Component {
toggleMainChat,
shouldShowSignIn,
params: { lang },
trackEvent
trackEvent,
loadCurrentChallenge
} = this.props;
const navProps = {
isOnMap: router.isActive(`/${lang}/map`),
@ -135,7 +139,8 @@ export class FreeCodeCamp extends React.Component {
toggleMapDrawer,
toggleMainChat,
shouldShowSignIn,
trackEvent
trackEvent,
loadCurrentChallenge
};
return (

View File

@ -15,16 +15,6 @@ import AvatarNavItem from './Avatar-Nav-Item.jsx';
const fCClogo = 'https://s3.amazonaws.com/freecodecamp/freecodecamp_logo.svg';
const logoElement = (
<a href='/'>
<img
alt='learn to code javascript at Free Code Camp logo'
className='img-responsive nav-logo'
src={ fCClogo }
/>
</a>
);
const toggleButtonChild = (
<Col xs={ 12 }>
<span className='hamburger-text'>Menu</span>
@ -43,6 +33,7 @@ export default class extends React.Component {
constructor(...props) {
super(...props);
this.handleMapClickOnMap = this.handleMapClickOnMap.bind(this);
this.handleLogoClick = this.handleLogoClick.bind(this);
navLinks.forEach(({ content }) => {
this[`handle${content}Click`] = handleNavLinkEvent.bind(this, content);
});
@ -58,7 +49,8 @@ export default class extends React.Component {
toggleMapDrawer: PropTypes.func,
toggleMainChat: PropTypes.func,
shouldShowSignIn: PropTypes.bool,
trackEvent: PropTypes.func.isRequired
trackEvent: PropTypes.func.isRequired,
loadCurrentChallenge: PropTypes.func.isRequired
};
componentDidMount() {
@ -83,6 +75,11 @@ export default class extends React.Component {
});
}
handleLogoClick(e) {
e.preventDefault();
this.props.loadCurrentChallenge();
}
renderMapLink(isOnMap, toggleMapDrawer) {
if (isOnMap) {
return (
@ -218,7 +215,18 @@ export default class extends React.Component {
className='nav-height'
fixedTop={ true }
>
<NavbarBrand>{ logoElement }</NavbarBrand>
<NavbarBrand>
<a
href='/challenges/current-challenge'
onClick={ this.handleLogoClick }
>
<img
alt='learn to code javascript at Free Code Camp logo'
className='img-responsive nav-logo'
src={ fCClogo }
/>
</a>
</NavbarBrand>
<Navbar.Toggle children={ toggleButtonChild } />
<Navbar.Collapse>
<Nav

View File

@ -60,6 +60,19 @@ export const addUser = createAction(
);
export const updateThisUser = createAction(types.updateThisUser);
export const showSignIn = createAction(types.showSignIn);
export const loadCurrentChallenge = createAction(
types.loadCurrentChallenge,
null,
() => createEventMeta({
category: 'Nav',
action: 'clicked',
label: 'fcc logo clicked'
})
);
export const updateMyCurrentChallenge = createAction(
types.updateMyCurrentChallenge,
(username, currentChallengeId) => ({ username, currentChallengeId })
);
// updateUserPoints(username: String, points: Number) => Action
export const updateUserPoints = createAction(

View File

@ -85,5 +85,18 @@ export default function entities(state = initialState, action) {
}
};
}
if (action.type === types.updateMyCurrentChallenge) {
return {
...state,
user: {
...state.user,
[username]: {
...state.user[username],
currentChallengeId: action.payload.currentChallengeId
}
}
};
}
return state;
}

View File

@ -1,6 +1,10 @@
import fetchUserSaga from './fetch-user-saga';
import loadCurrentChallengeSaga from './load-current-challenge-saga';
export { default as reducer } from './reducer';
export * as actions from './actions';
export { default as types } from './types';
export const sagas = [ fetchUserSaga ];
export const sagas = [
fetchUserSaga,
loadCurrentChallengeSaga
];

View File

@ -0,0 +1,92 @@
import { Observable } from 'rx';
import debug from 'debug';
import { push } from 'react-router-redux';
import types from './types';
import {
updateMyCurrentChallenge,
createErrorObservable
} from './actions';
import {
userSelector,
firstChallengeSelector
} from './selectors';
import { updateCurrentChallenge } from '../routes/challenges/redux/actions';
import getActionsOfType from '../../utils/get-actions-of-type';
import combineSagas from '../utils/combine-sagas';
import { postJSON$ } from '../../utils/ajax-stream';
const log = debug('fcc:app/redux/load-current-challenge-saga');
export function updateMyCurrentChallengeSaga(actions, getState) {
const updateChallenge$ = getActionsOfType(
actions,
updateCurrentChallenge.toString()
)
.map(({ payload: { id } }) => id)
.filter(() => {
const { app: { user: username } } = getState();
return !!username;
})
.distinctUntilChanged();
const optimistic = updateChallenge$.map(id => {
const { app: { user: username } } = getState();
return updateMyCurrentChallenge(username, id);
});
const ajaxUpdate = updateChallenge$
.debounce(250)
.flatMapLatest(currentChallengeId => {
const { app: { csrfToken: _csrf } } = getState();
return postJSON$(
'/update-my-current-challenge',
{ _csrf, currentChallengeId }
)
.map(({ message }) => log(message))
.catch(createErrorObservable);
});
return Observable.merge(optimistic, ajaxUpdate);
}
export function loadCurrentChallengeSaga(actions, getState) {
return getActionsOfType(actions, types.loadCurrentChallenge)
.flatMap(() => {
let finalChallenge;
const state = getState();
const {
entities: { challenge: challengeMap, challengeIdToName },
challengesApp: { id: currentlyLoadedChallengeId },
locationBeforeTransition: { pathname } = {}
} = state;
const firstChallenge = firstChallengeSelector(state);
const { user: { currentChallengeId } } = userSelector(state);
const isOnAChallenge = (/^\/[^\/]{2,6}\/challenges/).test(pathname);
if (!currentChallengeId) {
finalChallenge = firstChallenge;
} else {
finalChallenge = challengeMap[
challengeIdToName[ currentChallengeId ]
];
}
if (
// data might not be there yet, ignore for now
!finalChallenge ||
// are we already on that challenge?
(isOnAChallenge && finalChallenge.id === currentlyLoadedChallengeId)
) {
// don't reload if the challenge is already loaded.
// This may change to toast to avoid user confusion
return Observable.empty();
}
return Observable.of(
updateCurrentChallenge(finalChallenge),
push(
`/challenges/${finalChallenge.block}/${finalChallenge.dashedName}`
)
);
});
}
export default combineSagas(
updateMyCurrentChallengeSaga,
loadCurrentChallengeSaga
);

View File

@ -7,3 +7,32 @@ export const userSelector = createSelector(
user: userMap[username] || {}
})
);
export const firstChallengeSelector = createSelector(
state => state.entities.challenge,
state => state.entities.block,
state => state.entities.superBlock,
state => state.challengesApp.superBlocks,
(challengeMap, blockMap, superBlockMap, superBlocks) => {
if (
!challengeMap ||
!blockMap ||
!superBlockMap ||
!superBlocks
) {
return {};
}
try {
return challengeMap[
blockMap[
superBlockMap[
superBlocks[0]
].blocks[0]
].challenges[0]
];
} catch (err) {
console.error(err);
return {};
}
}
);

View File

@ -14,6 +14,8 @@ export default createTypes([
'updateUserLang',
'updateCompletedChallenges',
'showSignIn',
'loadCurrentChallenge',
'updateMyCurrentChallenge',
'handleError',
'toggleNightMode',

View File

@ -1,5 +1,6 @@
import { compose } from 'redux';
import { bonfire, html, js } from '../../utils/challengeTypes';
import protect from '../../utils/empty-protector';
export function encodeScriptTags(value) {
return value
@ -78,19 +79,6 @@ export function loggerToStr(args) {
.reduce((str, arg) => str + arg + '\n', '');
}
export function getFirstChallenge(
{ superBlock, block, challenge },
result
) {
return challenge[
block[
superBlock[
result[0]
].blocks[0]
].challenges[0]
];
}
export function getNextChallenge(
current,
entities,
@ -285,20 +273,6 @@ export function getMouse(e, [dx, dy]) {
return [pageX - dx, pageY - dy];
}
const emptyProtector = {
blocks: [],
challenges: []
};
// protect against malformed data
function protect(block) {
// if no block or block has no challenges or blocks
// use protector
if (!block || !(block.challenges || block.blocks)) {
return emptyProtector;
}
return block;
}
// interface Node {
// isHidden: Boolean,
// children: Void|[ ...Node ],

View File

@ -0,0 +1,13 @@
const emptyProtector = {
blocks: [],
challenges: []
};
// protect against malformed map data
// protect(block: { challenges: [], block: [] }|Void) => block|emptyProtector
export default function protect(block) {
// if no block or block has no challenges or blocks
if (!block || !(block.challenges || block.blocks)) {
return emptyProtector;
}
return block;
}

View File

@ -120,8 +120,14 @@
"description": "Campers profile does not show challenges/certificates to the public",
"default": false
},
"currentChallengeId": {
"type": "string",
"default": "",
"description": "the challenge last visited by the user"
},
"currentChallenge": {
"type": {}
"type": {},
"description": "deprecated"
},
"isUniqMigrated": {
"type": "boolean",

View File

@ -0,0 +1,10 @@
export default function getActionsOfType(actions, ...types) {
const length = types.length;
return actions
.filter(({ type }) => {
if (length === 1) {
return type === types[0];
}
return types.some(_type => _type === type);
});
}

View File

@ -0,0 +1,44 @@
import emptyProtector from '../app/utils/empty-protector';
export function checkMapData(
{
entities: {
challenge,
block,
superBlock,
challengeIdToName
},
result
}
) {
if (
!challenge ||
!block ||
!superBlock ||
!challengeIdToName ||
!result ||
!result.length
) {
throw new Error(
'entities not found, db may not be properly seeded. Crashing hard'
);
}
}
// 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]
];
}

View File

@ -1,11 +1,17 @@
import _ from 'lodash';
// import { Observable, Scheduler } from 'rx';
import debug from 'debug';
import accepts from 'accepts';
import dedent from 'dedent';
import { ifNoUserSend } from '../utils/middleware';
import { 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:challenges');
const log = debug('fcc:boot:challenges');
function buildUserUpdate(
user,
@ -58,11 +64,14 @@ function buildUserUpdate(
return { alreadyCompleted, updateData };
}
module.exports = function(app) {
const router = app.loopback.Router();
export default function(app) {
const send200toNonUser = ifNoUserSend(true);
const api = app.loopback.Router();
const router = app.loopback.Router();
const Block = app.models.Block;
const map$ = cachedMap(Block);
router.post(
api.post(
'/modern-challenge-completed',
send200toNonUser,
modernChallengeCompleted
@ -70,13 +79,13 @@ module.exports = function(app) {
// deprecate endpoint
// remove once new endpoint is live
router.post(
api.post(
'/completed-challenge',
send200toNonUser,
completedChallenge
);
router.post(
api.post(
'/challenge-completed',
send200toNonUser,
completedChallenge
@ -84,19 +93,25 @@ module.exports = function(app) {
// deprecate endpoint
// remove once new endpoint is live
router.post(
api.post(
'/completed-zipline-or-basejump',
send200toNonUser,
projectCompleted
);
router.post(
api.post(
'/project-completed',
send200toNonUser,
projectCompleted
);
app.use(router);
router.get(
'/challenges/current-challenge',
redirectToCurrentChallenge
);
app.use(api);
app.use('/:lang', router);
function modernChallengeCompleted(req, res, next) {
const type = accepts(req).type('html', 'json', 'text');
@ -255,4 +270,47 @@ module.exports = function(app) {
})
.subscribe(() => {}, next);
}
};
function redirectToCurrentChallenge(req, res, next) {
const { user } = req;
return map$
.map(({ entities, result }) => ({
result,
entities: createNameIdMap(entities)
}))
.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
console.error(new Error(dedent`
Attemped to find '${dashedName}'
from '${user && user.currentChallengeId || 'no challenge id found'}'
but came up empty.
db may not be properly seeded.
`));
if (dashedName) {
// attempt to find according to dashedName
return `/challenges/${dashedName}`;
} else {
return null;
}
}
return `/challenges/${block}/${finalDashedName}`;
})
.subscribe(
redirect => res.redirect(redirect || '/map'),
next
);
}
}

View File

@ -1,4 +1,5 @@
import { ifNoUser401 } from '../utils/middleware';
import { isMongoId } from 'validator';
import supportedLanguages from '../../common/utils/supported-languages.js';
export default function settingsController(app) {
@ -49,6 +50,20 @@ export default function settingsController(app) {
);
}
function updateMyCurrentChallenge(req, res, next) {
const { user, body: { currentChallengeId } } = req;
if (!isMongoId('' + currentChallengeId)) {
return next(new Error(`${currentChallengeId} is not a valid ObjectId`));
}
return user.update$({ currentChallengeId }).subscribe(
() => res.json({
message:
`your current challenge has been updated to ${currentChallengeId}`
}),
next
);
}
api.post(
'/toggle-lockdown',
toggleUserFlag('isLocked')
@ -78,5 +93,11 @@ export default function settingsController(app) {
ifNoUser401,
updateMyLang
);
api.post(
'/update-my-current-challenge',
ifNoUser401,
updateMyCurrentChallenge
);
app.use(api);
}

View File

@ -26,7 +26,7 @@ const publicUserProps = [
'sendNotificationEmail',
'sendQuincyEmail',
'currentChallenge',
'currentChallengeId',
'challengeMap'
];
const log = debug('fcc:services:user');

View File

@ -3,7 +3,7 @@ nav.navbar.navbar-default.navbar-fixed-top.nav-height
button.hamburger.navbar-toggle(type='button', data-toggle='collapse', data-target='.navbar-collapse')
.col-xs-12
span.hamburger-text Menu
a.navbar-brand(href='/challenges/next-challenge')
a.navbar-brand(href='/challenges/current-challenge')
img.img-responsive.nav-logo(src='https://s3.amazonaws.com/freecodecamp/freecodecamp_logo.svg', alt='learn to code javascript at Free Code Camp logo')
.collapse.navbar-collapse
ul.nav.navbar-nav.navbar-right.hamburger-dropdown