diff --git a/client/sagas/hard-go-to-saga.js b/client/sagas/hard-go-to-saga.js index 46bfd15289..b3c9a8a3a7 100644 --- a/client/sagas/hard-go-to-saga.js +++ b/client/sagas/hard-go-to-saga.js @@ -4,7 +4,7 @@ export default function hardGoToSaga(action$, getState, { history }) { return action$ .filter(({ type }) => type === hardGoTo) .map(({ payload = '/settings' }) => { - history.push(history.state, null, payload); + history.pushState(history.state, null, payload); return null; }); } diff --git a/common/app/App.jsx b/common/app/App.jsx index f8494c14c8..e6554b7711 100644 --- a/common/app/App.jsx +++ b/common/app/App.jsx @@ -1,9 +1,7 @@ import React, { PropTypes } from 'react'; import { Button, Row } from 'react-bootstrap'; import { ToastMessage, ToastContainer } from 'react-toastr'; -import { compose } from 'redux'; import { connect } from 'react-redux'; -import { contain } from 'redux-epic'; import { createSelector } from 'reselect'; import MapDrawer from './components/Map-Drawer.jsx'; @@ -19,26 +17,27 @@ import { submitChallenge } from './routes/challenges/redux/actions'; import Nav from './components/Nav'; import { randomCompliment } from './utils/get-words'; +import { userSelector } from './redux/selectors'; const toastMessageFactory = React.createFactory(ToastMessage.animation); const mapStateToProps = createSelector( - state => state.app.username, - state => state.app.points, - state => state.app.picture, + userSelector, + state => state.app.shouldShowSignIn, state => state.app.toast, state => state.app.isMapDrawerOpen, state => state.app.isMapAlreadyLoaded, state => state.challengesApp.toast, ( - username, - points, - picture, + { user: { username, points, picture } }, + shouldShowSignIn, toast, isMapDrawerOpen, isMapAlreadyLoaded, showChallengeComplete ) => ({ + shouldShowSignIn, + isSignedIn: !!username, username, points, picture, @@ -58,13 +57,6 @@ const bindableActions = { toggleMainChat }; -const fetchContainerOptions = { - fetchAction: 'fetchUser', - isPrimed({ username }) { - return !!username; - } -}; - // export plain class for testing export class FreeCodeCamp extends React.Component { static displayName = 'FreeCodeCamp'; @@ -72,6 +64,7 @@ export class FreeCodeCamp extends React.Component { static propTypes = { children: PropTypes.node, username: PropTypes.string, + isSignedIn: PropTypes.bool, points: PropTypes.number, picture: PropTypes.string, toast: PropTypes.object, @@ -82,7 +75,9 @@ export class FreeCodeCamp extends React.Component { isMapDrawerOpen: PropTypes.bool, isMapAlreadyLoaded: PropTypes.bool, toggleMapDrawer: PropTypes.func, - toggleMainChat: PropTypes.func + toggleMainChat: PropTypes.func, + fetchUser: PropTypes.func, + shouldShowSignIn: PropTypes.bool }; componentWillReceiveProps({ @@ -119,6 +114,9 @@ export class FreeCodeCamp extends React.Component { componentDidMount() { this.props.initWindowHeight(); + if (!this.props.isSignedIn) { + this.props.fetchUser(); + } } renderChallengeComplete() { @@ -145,7 +143,8 @@ export class FreeCodeCamp extends React.Component { isMapDrawerOpen, isMapAlreadyLoaded, toggleMapDrawer, - toggleMainChat + toggleMainChat, + shouldShowSignIn } = this.props; const navProps = { username, @@ -153,7 +152,8 @@ export class FreeCodeCamp extends React.Component { picture, updateNavHeight, toggleMapDrawer, - toggleMainChat + toggleMainChat, + shouldShowSignIn }; return ( @@ -177,11 +177,7 @@ export class FreeCodeCamp extends React.Component { } } -const wrapComponent = compose( - // connect Component to Redux Store - connect(mapStateToProps, bindableActions), - // handles prefetching data - contain(fetchContainerOptions) -); - -export default wrapComponent(FreeCodeCamp); +export default connect( + mapStateToProps, + bindableActions +)(FreeCodeCamp); diff --git a/common/app/components/Nav/Nav.jsx b/common/app/components/Nav/Nav.jsx index 1989a7a1f3..bb13d5a810 100644 --- a/common/app/components/Nav/Nav.jsx +++ b/common/app/components/Nav/Nav.jsx @@ -19,7 +19,8 @@ const logoElement = ( learn to code javascript at Free Code Camp logo + src={ fCClogo } + /> ); @@ -41,7 +42,8 @@ export default class extends React.Component { username: PropTypes.string, updateNavHeight: PropTypes.func, toggleMapDrawer: PropTypes.func, - toggleMainChat: PropTypes.func + toggleMainChat: PropTypes.func, + shouldShowSignIn: PropTypes.bool }; componentDidMount() { @@ -56,7 +58,7 @@ export default class extends React.Component { e.preventDefault()} - > + > Map @@ -65,7 +67,8 @@ export default class extends React.Component { return ( + to='/map' + > { if (!(e.ctrlKey || e.metaKey)) { @@ -74,7 +77,7 @@ export default class extends React.Component { } }} target='/map' - > + > Map @@ -93,7 +96,7 @@ export default class extends React.Component { } }} target='_blank' - > + > Chat ); @@ -106,9 +109,11 @@ export default class extends React.Component { + to={ link } + > + target={ target || null } + > { content } @@ -119,36 +124,45 @@ export default class extends React.Component { eventKey={ index + 1 } href={ link } key={ content } - target={ target || null }> + target={ target || null } + > { content } ); }); } - renderPoints(username, points) { - if (!username) { + renderPoints(username, points, shouldShowSignIn) { + if (!username || !shouldShowSignIn) { return null; } return ( + href={ '/' + username } + key='points' + > [ { points } ] ); } - renderSignin(username, picture) { + renderSignIn(username, picture, shouldShowSignIn) { + if (!shouldShowSignIn) { + return null; + } if (username) { return (
  • + eventKey={ 2 } + key='user' + > + src={ picture } + />
  • ); @@ -156,7 +170,9 @@ export default class extends React.Component { return ( + href='/signin' + key='signin' + > Sign In ); @@ -169,7 +185,8 @@ export default class extends React.Component { points, picture, toggleMapDrawer, - toggleMainChat + toggleMainChat, + shouldShowSignIn } = this.props; const { router } = this.context; const isOnMap = router.isActive('/map'); @@ -177,19 +194,21 @@ export default class extends React.Component { return ( + fixedTop={ true } + > { logoElement } diff --git a/common/app/redux/actions.js b/common/app/redux/actions.js index 281c937080..5407e521f7 100644 --- a/common/app/redux/actions.js +++ b/common/app/redux/actions.js @@ -23,11 +23,21 @@ export const makeToast = createAction( // used in combination with fetch-user-saga export const fetchUser = createAction(types.fetchUser); -// setUser(userInfo: Object) => Action -export const setUser = createAction(types.setUser); +// setUser( +// entities: { [userId]: User } +// ) => Action +export const addUser = createAction( + types.addUser, + () => {}, + entities => ({ entities }) +); +export const updateThisUser = createAction(types.updateThisUser); -// updatePoints(points: Number) => Action -export const updatePoints = createAction(types.updatePoints); +// updateUserPoints(username: String, points: Number) => Action +export const updateUserPoints = createAction( + types.updateUserPoints, + (username, points) => ({ username, points }) +); // used when server needs client to redirect export const delayedRedirect = createAction(types.delayedRedirect); diff --git a/common/app/redux/entities-reducer.js b/common/app/redux/entities-reducer.js index e7487b0534..8ba8dc5ab0 100644 --- a/common/app/redux/entities-reducer.js +++ b/common/app/redux/entities-reducer.js @@ -1,12 +1,26 @@ +import { updateUserPoints } from './types'; + const initialState = { - hike: {}, superBlock: {}, block: {}, challenge: {}, - job: {} + user: {} }; export default function entities(state = initialState, action) { + const { type, payload: { username, points } = {} } = action; + if (type === updateUserPoints) { + return { + ...state, + user: { + ...state.user, + [username]: { + ...state.user[username], + points + } + } + }; + } if (action.meta && action.meta.entities) { return { ...state, diff --git a/common/app/redux/fetch-user-saga.js b/common/app/redux/fetch-user-saga.js index 874ee4ff78..db3bd88a29 100644 --- a/common/app/redux/fetch-user-saga.js +++ b/common/app/redux/fetch-user-saga.js @@ -1,16 +1,25 @@ -import { setUser, fetchUser } from './types'; -import { createErrorObservable } from './actions'; +import { Observable } from 'rx'; +import { fetchUser } from './types'; +import { + addUser, + updateThisUser, + createErrorObservable, + showSignIn +} from './actions'; export default function getUserSaga(action$, getState, { services }) { return action$ .filter(action => action.type === fetchUser) .flatMap(() => { return services.readService$({ service: 'user' }) - .map(user => { - return { - type: setUser, - payload: user - }; + .flatMap(({ entities, result })=> { + if (!entities || !result) { + return Observable.just(showSignIn()); + } + return Observable.of( + addUser(entities), + updateThisUser(result) + ); }) .catch(createErrorObservable); }); diff --git a/common/app/redux/reducer.js b/common/app/redux/reducer.js index d3d28812e3..066bac4c13 100644 --- a/common/app/redux/reducer.js +++ b/common/app/redux/reducer.js @@ -3,10 +3,8 @@ import types from './types'; const initialState = { title: 'Learn To Code | Free Code Camp', - username: null, - picture: null, - points: 0, - isSignedIn: false, + shouldShowSignIn: false, + user: '', csrfToken: '', windowHeight: 0, navHeight: 0, @@ -25,20 +23,20 @@ export default handleActions( toast }), - [types.setUser]: (state, { payload: user }) => ({ + [types.updateThisUser]: (state, { payload: user }) => ({ ...state, - ...user, - isSignedIn: true + user, + shouldShowSignIn: true + }), + [types.showSignIn]: state => ({ + ...state, + shouldShowSignIn: true }), [types.challengeSaved]: (state, { payload: { points = 0 } }) => ({ ...state, points }), - [types.updatePoints]: (state, { payload: points }) => ({ - ...state, - points - }), [types.updateWindowHeight]: (state, { payload: windowHeight }) => ({ ...state, windowHeight diff --git a/common/app/redux/selectors.js b/common/app/redux/selectors.js new file mode 100644 index 0000000000..0af91b151d --- /dev/null +++ b/common/app/redux/selectors.js @@ -0,0 +1,9 @@ +import { createSelector } from 'reselect'; + +export const userSelector = createSelector( + state => state.app.user, + state => state.entities.user, + (username, userMap) => ({ + user: userMap[username] || {} + }) +); diff --git a/common/app/redux/types.js b/common/app/redux/types.js index 8338e9ff69..5a5dd84883 100644 --- a/common/app/redux/types.js +++ b/common/app/redux/types.js @@ -4,10 +4,12 @@ export default createTypes([ 'updateTitle', 'fetchUser', - 'setUser', + 'addUser', + 'updateThisUser', + 'updateUserPoints', + 'showSignIn', 'makeToast', - 'updatePoints', 'handleError', 'toggleNightMode', // used to hit the server diff --git a/common/app/routes/challenges/redux/completion-saga.js b/common/app/routes/challenges/redux/completion-saga.js index 870698b66a..5cff0717cb 100644 --- a/common/app/routes/challenges/redux/completion-saga.js +++ b/common/app/routes/challenges/redux/completion-saga.js @@ -4,7 +4,7 @@ import { showChallengeComplete, moveToNextChallenge } from './actions'; import { createErrorObservable, makeToast, - updatePoints + updateUserPoints } from '../../../redux/actions'; import { challengeSelector } from './selectors'; @@ -16,25 +16,16 @@ import { postJSON$ } from '../../../../utils/ajax-stream'; // lots of repeat code function completedChallenge(state) { - let body; - let isSignedIn = false; - try { - const { - challenge: { id } - } = challengeSelector(state); - const { - app: { isSignedIn: _isSignedId, csrfToken }, - challengesApp: { files } - } = state; - isSignedIn = _isSignedId; - body = { - id, - _csrf: csrfToken, - files - }; - } catch (err) { - return createErrorObservable(err); - } + const { challenge: { id } } = challengeSelector(state); + const { + app: { user, csrfToken }, + challengesApp: { files } + } = state; + const body = { + id, + _csrf: csrfToken, + files + }; const saveChallenge$ = postJSON$('/modern-challenge-completed', body) .retry(3) .flatMap(({ alreadyCompleted, points }) => { @@ -46,7 +37,7 @@ function completedChallenge(state) { title: 'Saved', type: 'info' }), - updatePoints(points) + updateUserPoints(user, points) ); }) .catch(createErrorObservable); @@ -55,7 +46,7 @@ function completedChallenge(state) { moveToNextChallenge(), makeToast({ title: 'Congratulations!', - message: isSignedIn ? ' Saving...' : 'Moving on to next challenge.', + message: user ? ' Saving...' : 'Moving on to next challenge.', type: 'success' }) ); @@ -87,7 +78,7 @@ function submitProject(type, state, { solution, githubLink }) { challenge: { id, challengeType } } = challengeSelector(state); const { - app: { isSignedIn, csrfToken } + app: { user, csrfToken } } = state; const body = { id, @@ -109,7 +100,7 @@ function submitProject(type, state, { solution, githubLink }) { title: 'Saved', type: 'info' }), - updatePoints(points) + updateUserPoints(user, points) ); }) .catch(createErrorObservable); @@ -117,7 +108,7 @@ function submitProject(type, state, { solution, githubLink }) { const challengeCompleted$ = Observable.of( makeToast({ title: randomCompliment(), - message: isSignedIn ? ' Saving...' : 'Moving on to next challenge.', + message: user ? ' Saving...' : 'Moving on to next challenge.', type: 'success' }) // moveToNextChallenge() @@ -130,7 +121,7 @@ function submitSimpleChallenge(type, state) { challenge: { id } } = challengeSelector(state); const { - app: { isSignedIn, csrfToken } + app: { user, csrfToken } } = state; const body = { id, @@ -147,7 +138,7 @@ function submitSimpleChallenge(type, state) { title: 'Saved', type: 'info' }), - updatePoints(points) + updateUserPoints(user, points) ); }) .catch(createErrorObservable); @@ -155,7 +146,7 @@ function submitSimpleChallenge(type, state) { const challengeCompleted$ = Observable.of( makeToast({ title: randomCompliment(), - message: isSignedIn ? ' Saving...' : 'Moving on to next challenge.', + message: user ? ' Saving...' : 'Moving on to next challenge.', type: 'success' }), moveToNextChallenge() diff --git a/common/app/routes/challenges/redux/fetch-challenges-saga.js b/common/app/routes/challenges/redux/fetch-challenges-saga.js index 8fccade58c..72cc63c904 100644 --- a/common/app/routes/challenges/redux/fetch-challenges-saga.js +++ b/common/app/routes/challenges/redux/fetch-challenges-saga.js @@ -15,6 +15,18 @@ import { updateCurrentChallenge } from './actions'; +function createNameIdMap(entities) { + const { challenge } = entities; + return { + ...entities, + challengeIdToName: Object.keys(challenge) + .reduce((map, challengeName) => { + map[challenge[challengeName].id] = challenge[challengeName].dashedName; + return map; + }, {}) + }; +} + export default function fetchChallengesSaga(action$, getState, { services }) { return action$ .filter(({ type }) => ( @@ -48,12 +60,20 @@ export default function fetchChallengesSaga(action$, getState, { services }) { .flatMap(({ entities, result, redirect } = {}) => { if (type === fetchChallenge) { return Observable.of( - fetchChallengeCompleted(entities, result), + fetchChallengeCompleted( + createNameIdMap(entities), + result + ), updateCurrentChallenge(entities.challenge[result.challenge]), redirect ? delayedRedirect(redirect) : null ); } - return Observable.just(fetchChallengesCompleted(entities, result)); + return Observable.just( + fetchChallengesCompleted( + createNameIdMap(entities), + result + ) + ); }) .catch(createErrorObserable); }); diff --git a/server/services/user.js b/server/services/user.js index 51871ae503..378bc6e00f 100644 --- a/server/services/user.js +++ b/server/services/user.js @@ -1,11 +1,29 @@ -import debugFactory from 'debug'; +import debug from 'debug'; +import _ from 'lodash'; -const censor = '**********************:P********'; -const debug = debugFactory('fcc:services:user'); -const protectedUserFields = { - password: censor, - profiles: censor -}; +const publicUserProps = [ + 'id', + 'name', + 'username', + 'bio', + 'theme', + 'picture', + 'points', + 'languageTag', + + 'isCheater', + 'isGithubCool', + + 'isFrontEndCert', + 'isBackEndCert', + 'isDataVisCert', + 'isFullStackCert', + + 'githubURL', + 'currentChallenge', + 'challengeMap' +]; +const log = debug('fcc:services:user'); export default function userServices() { return { @@ -13,19 +31,26 @@ export default function userServices() { read: (req, resource, params, config, cb) => { let { user } = req; if (user) { - debug('user is signed in'); - // Zalgo!!! - return process.nextTick(() => { - cb( - null, - { - ...user.toJSON(), - ...protectedUserFields - } + log('user is signed in'); + return user.getChallengeMap$() + .map(challengeMap => ({ ...user.toJSON(), challengeMap })) + .subscribe( + user => cb( + null, + { + entities: { + user: { + [user.username]: _.pick(user, publicUserProps) + } + }, + result: user.username + } + ), + cb ); - }); } debug('user is not signed in'); + // Zalgo!!! return process.nextTick(() => { cb(null, {}); });