Merge pull request #10064 from BerkeleyTrue/fix/actual-current-route
feature(challenges): load current challenge on first visit
This commit is contained in:
@ -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 (
|
||||
|
@ -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
|
||||
|
@ -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(
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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
|
||||
];
|
||||
|
92
common/app/redux/load-current-challenge-saga.js
Normal file
92
common/app/redux/load-current-challenge-saga.js
Normal 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
|
||||
);
|
@ -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 {};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
@ -14,6 +14,8 @@ export default createTypes([
|
||||
'updateUserLang',
|
||||
'updateCompletedChallenges',
|
||||
'showSignIn',
|
||||
'loadCurrentChallenge',
|
||||
'updateMyCurrentChallenge',
|
||||
|
||||
'handleError',
|
||||
'toggleNightMode',
|
||||
|
@ -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 ],
|
||||
|
13
common/app/utils/empty-protector.js
Normal file
13
common/app/utils/empty-protector.js
Normal 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;
|
||||
}
|
@ -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",
|
||||
|
10
common/utils/get-actions-of-type.js
Normal file
10
common/utils/get-actions-of-type.js
Normal 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);
|
||||
});
|
||||
}
|
44
common/utils/get-first-challenge.js
Normal file
44
common/utils/get-first-challenge.js
Normal 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]
|
||||
];
|
||||
}
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -26,7 +26,7 @@ const publicUserProps = [
|
||||
'sendNotificationEmail',
|
||||
'sendQuincyEmail',
|
||||
|
||||
'currentChallenge',
|
||||
'currentChallengeId',
|
||||
'challengeMap'
|
||||
];
|
||||
const log = debug('fcc:services:user');
|
||||
|
@ -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
|
||||
|
Reference in New Issue
Block a user