Feature(components): fetch user after page load

This makes it easier to serve whole site statically in the future
Feature(redux): Move user state into entities
This commit is contained in:
Berkeley Martinez
2016-06-20 11:35:19 -07:00
parent d9e9af0a0f
commit 0c07e961a7
12 changed files with 214 additions and 121 deletions

View File

@ -4,7 +4,7 @@ export default function hardGoToSaga(action$, getState, { history }) {
return action$ return action$
.filter(({ type }) => type === hardGoTo) .filter(({ type }) => type === hardGoTo)
.map(({ payload = '/settings' }) => { .map(({ payload = '/settings' }) => {
history.push(history.state, null, payload); history.pushState(history.state, null, payload);
return null; return null;
}); });
} }

View File

@ -1,9 +1,7 @@
import React, { PropTypes } from 'react'; import React, { PropTypes } from 'react';
import { Button, Row } from 'react-bootstrap'; import { Button, Row } from 'react-bootstrap';
import { ToastMessage, ToastContainer } from 'react-toastr'; import { ToastMessage, ToastContainer } from 'react-toastr';
import { compose } from 'redux';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { contain } from 'redux-epic';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import MapDrawer from './components/Map-Drawer.jsx'; import MapDrawer from './components/Map-Drawer.jsx';
@ -19,26 +17,27 @@ import { submitChallenge } from './routes/challenges/redux/actions';
import Nav from './components/Nav'; import Nav from './components/Nav';
import { randomCompliment } from './utils/get-words'; import { randomCompliment } from './utils/get-words';
import { userSelector } from './redux/selectors';
const toastMessageFactory = React.createFactory(ToastMessage.animation); const toastMessageFactory = React.createFactory(ToastMessage.animation);
const mapStateToProps = createSelector( const mapStateToProps = createSelector(
state => state.app.username, userSelector,
state => state.app.points, state => state.app.shouldShowSignIn,
state => state.app.picture,
state => state.app.toast, state => state.app.toast,
state => state.app.isMapDrawerOpen, state => state.app.isMapDrawerOpen,
state => state.app.isMapAlreadyLoaded, state => state.app.isMapAlreadyLoaded,
state => state.challengesApp.toast, state => state.challengesApp.toast,
( (
username, { user: { username, points, picture } },
points, shouldShowSignIn,
picture,
toast, toast,
isMapDrawerOpen, isMapDrawerOpen,
isMapAlreadyLoaded, isMapAlreadyLoaded,
showChallengeComplete showChallengeComplete
) => ({ ) => ({
shouldShowSignIn,
isSignedIn: !!username,
username, username,
points, points,
picture, picture,
@ -58,13 +57,6 @@ const bindableActions = {
toggleMainChat toggleMainChat
}; };
const fetchContainerOptions = {
fetchAction: 'fetchUser',
isPrimed({ username }) {
return !!username;
}
};
// export plain class for testing // export plain class for testing
export class FreeCodeCamp extends React.Component { export class FreeCodeCamp extends React.Component {
static displayName = 'FreeCodeCamp'; static displayName = 'FreeCodeCamp';
@ -72,6 +64,7 @@ export class FreeCodeCamp extends React.Component {
static propTypes = { static propTypes = {
children: PropTypes.node, children: PropTypes.node,
username: PropTypes.string, username: PropTypes.string,
isSignedIn: PropTypes.bool,
points: PropTypes.number, points: PropTypes.number,
picture: PropTypes.string, picture: PropTypes.string,
toast: PropTypes.object, toast: PropTypes.object,
@ -82,7 +75,9 @@ export class FreeCodeCamp extends React.Component {
isMapDrawerOpen: PropTypes.bool, isMapDrawerOpen: PropTypes.bool,
isMapAlreadyLoaded: PropTypes.bool, isMapAlreadyLoaded: PropTypes.bool,
toggleMapDrawer: PropTypes.func, toggleMapDrawer: PropTypes.func,
toggleMainChat: PropTypes.func toggleMainChat: PropTypes.func,
fetchUser: PropTypes.func,
shouldShowSignIn: PropTypes.bool
}; };
componentWillReceiveProps({ componentWillReceiveProps({
@ -119,6 +114,9 @@ export class FreeCodeCamp extends React.Component {
componentDidMount() { componentDidMount() {
this.props.initWindowHeight(); this.props.initWindowHeight();
if (!this.props.isSignedIn) {
this.props.fetchUser();
}
} }
renderChallengeComplete() { renderChallengeComplete() {
@ -145,7 +143,8 @@ export class FreeCodeCamp extends React.Component {
isMapDrawerOpen, isMapDrawerOpen,
isMapAlreadyLoaded, isMapAlreadyLoaded,
toggleMapDrawer, toggleMapDrawer,
toggleMainChat toggleMainChat,
shouldShowSignIn
} = this.props; } = this.props;
const navProps = { const navProps = {
username, username,
@ -153,7 +152,8 @@ export class FreeCodeCamp extends React.Component {
picture, picture,
updateNavHeight, updateNavHeight,
toggleMapDrawer, toggleMapDrawer,
toggleMainChat toggleMainChat,
shouldShowSignIn
}; };
return ( return (
@ -177,11 +177,7 @@ export class FreeCodeCamp extends React.Component {
} }
} }
const wrapComponent = compose( export default connect(
// connect Component to Redux Store mapStateToProps,
connect(mapStateToProps, bindableActions), bindableActions
// handles prefetching data )(FreeCodeCamp);
contain(fetchContainerOptions)
);
export default wrapComponent(FreeCodeCamp);

View File

@ -19,7 +19,8 @@ const logoElement = (
<img <img
alt='learn to code javascript at Free Code Camp logo' alt='learn to code javascript at Free Code Camp logo'
className='img-responsive nav-logo' className='img-responsive nav-logo'
src={ fCClogo } /> src={ fCClogo }
/>
</a> </a>
); );
@ -41,7 +42,8 @@ export default class extends React.Component {
username: PropTypes.string, username: PropTypes.string,
updateNavHeight: PropTypes.func, updateNavHeight: PropTypes.func,
toggleMapDrawer: PropTypes.func, toggleMapDrawer: PropTypes.func,
toggleMainChat: PropTypes.func toggleMainChat: PropTypes.func,
shouldShowSignIn: PropTypes.bool
}; };
componentDidMount() { componentDidMount() {
@ -56,7 +58,7 @@ export default class extends React.Component {
<a <a
href='#' href='#'
onClick={ e => e.preventDefault()} onClick={ e => e.preventDefault()}
> >
Map Map
</a> </a>
</li> </li>
@ -65,7 +67,8 @@ export default class extends React.Component {
return ( return (
<LinkContainer <LinkContainer
eventKey={ 1 } eventKey={ 1 }
to='/map'> to='/map'
>
<NavItem <NavItem
onClick={ e => { onClick={ e => {
if (!(e.ctrlKey || e.metaKey)) { if (!(e.ctrlKey || e.metaKey)) {
@ -74,7 +77,7 @@ export default class extends React.Component {
} }
}} }}
target='/map' target='/map'
> >
Map Map
</NavItem> </NavItem>
</LinkContainer> </LinkContainer>
@ -93,7 +96,7 @@ export default class extends React.Component {
} }
}} }}
target='_blank' target='_blank'
> >
Chat Chat
</NavItem> </NavItem>
); );
@ -106,9 +109,11 @@ export default class extends React.Component {
<LinkContainer <LinkContainer
eventKey={ index + 2 } eventKey={ index + 2 }
key={ content } key={ content }
to={ link }> to={ link }
>
<NavItem <NavItem
target={ target || null }> target={ target || null }
>
{ content } { content }
</NavItem> </NavItem>
</LinkContainer> </LinkContainer>
@ -119,36 +124,45 @@ export default class extends React.Component {
eventKey={ index + 1 } eventKey={ index + 1 }
href={ link } href={ link }
key={ content } key={ content }
target={ target || null }> target={ target || null }
>
{ content } { content }
</NavItem> </NavItem>
); );
}); });
} }
renderPoints(username, points) { renderPoints(username, points, shouldShowSignIn) {
if (!username) { if (!username || !shouldShowSignIn) {
return null; return null;
} }
return ( return (
<FCCNavItem <FCCNavItem
className='brownie-points-nav' className='brownie-points-nav'
href={ '/' + username }> href={ '/' + username }
key='points'
>
[ { points } ] [ { points } ]
</FCCNavItem> </FCCNavItem>
); );
} }
renderSignin(username, picture) { renderSignIn(username, picture, shouldShowSignIn) {
if (!shouldShowSignIn) {
return null;
}
if (username) { if (username) {
return ( return (
<li <li
className='hidden-xs hidden-sm avatar' className='hidden-xs hidden-sm avatar'
eventKey={ 2 }> eventKey={ 2 }
key='user'
>
<a href={ '/' + username }> <a href={ '/' + username }>
<img <img
className='profile-picture float-right' className='profile-picture float-right'
src={ picture } /> src={ picture }
/>
</a> </a>
</li> </li>
); );
@ -156,7 +170,9 @@ export default class extends React.Component {
return ( return (
<NavItem <NavItem
eventKey={ 2 } eventKey={ 2 }
href='/signin'> href='/signin'
key='signin'
>
Sign In Sign In
</NavItem> </NavItem>
); );
@ -169,7 +185,8 @@ export default class extends React.Component {
points, points,
picture, picture,
toggleMapDrawer, toggleMapDrawer,
toggleMainChat toggleMainChat,
shouldShowSignIn
} = this.props; } = this.props;
const { router } = this.context; const { router } = this.context;
const isOnMap = router.isActive('/map'); const isOnMap = router.isActive('/map');
@ -177,19 +194,21 @@ export default class extends React.Component {
return ( return (
<Navbar <Navbar
className='nav-height' className='nav-height'
fixedTop={ true }> fixedTop={ true }
>
<NavbarBrand>{ logoElement }</NavbarBrand> <NavbarBrand>{ logoElement }</NavbarBrand>
<Navbar.Toggle children={ toggleButtonChild } /> <Navbar.Toggle children={ toggleButtonChild } />
<Navbar.Collapse eventKey={ 0 }> <Navbar.Collapse eventKey={ 0 }>
<Nav <Nav
className='hamburger-dropdown' className='hamburger-dropdown'
navbar={ true } navbar={ true }
pullRight={ true }> pullRight={ true }
>
{ this.renderMapLink(isOnMap, toggleMapDrawer) } { this.renderMapLink(isOnMap, toggleMapDrawer) }
{ this.renderChat(toggleMainChat) } { this.renderChat(toggleMainChat) }
{ this.renderLinks() } { this.renderLinks() }
{ this.renderPoints(username, points) } { this.renderPoints(username, points, shouldShowSignIn) }
{ this.renderSignin(username, picture) } { this.renderSignIn(username, picture, shouldShowSignIn) }
</Nav> </Nav>
</Navbar.Collapse> </Navbar.Collapse>
</Navbar> </Navbar>

View File

@ -23,11 +23,21 @@ export const makeToast = createAction(
// used in combination with fetch-user-saga // used in combination with fetch-user-saga
export const fetchUser = createAction(types.fetchUser); export const fetchUser = createAction(types.fetchUser);
// setUser(userInfo: Object) => Action // setUser(
export const setUser = createAction(types.setUser); // entities: { [userId]: User }
// ) => Action
export const addUser = createAction(
types.addUser,
() => {},
entities => ({ entities })
);
export const updateThisUser = createAction(types.updateThisUser);
// updatePoints(points: Number) => Action // updateUserPoints(username: String, points: Number) => Action
export const updatePoints = createAction(types.updatePoints); export const updateUserPoints = createAction(
types.updateUserPoints,
(username, points) => ({ username, points })
);
// used when server needs client to redirect // used when server needs client to redirect
export const delayedRedirect = createAction(types.delayedRedirect); export const delayedRedirect = createAction(types.delayedRedirect);

View File

@ -1,12 +1,26 @@
import { updateUserPoints } from './types';
const initialState = { const initialState = {
hike: {},
superBlock: {}, superBlock: {},
block: {}, block: {},
challenge: {}, challenge: {},
job: {} user: {}
}; };
export default function entities(state = initialState, action) { 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) { if (action.meta && action.meta.entities) {
return { return {
...state, ...state,

View File

@ -1,16 +1,25 @@
import { setUser, fetchUser } from './types'; import { Observable } from 'rx';
import { createErrorObservable } from './actions'; import { fetchUser } from './types';
import {
addUser,
updateThisUser,
createErrorObservable,
showSignIn
} from './actions';
export default function getUserSaga(action$, getState, { services }) { export default function getUserSaga(action$, getState, { services }) {
return action$ return action$
.filter(action => action.type === fetchUser) .filter(action => action.type === fetchUser)
.flatMap(() => { .flatMap(() => {
return services.readService$({ service: 'user' }) return services.readService$({ service: 'user' })
.map(user => { .flatMap(({ entities, result })=> {
return { if (!entities || !result) {
type: setUser, return Observable.just(showSignIn());
payload: user }
}; return Observable.of(
addUser(entities),
updateThisUser(result)
);
}) })
.catch(createErrorObservable); .catch(createErrorObservable);
}); });

View File

@ -3,10 +3,8 @@ import types from './types';
const initialState = { const initialState = {
title: 'Learn To Code | Free Code Camp', title: 'Learn To Code | Free Code Camp',
username: null, shouldShowSignIn: false,
picture: null, user: '',
points: 0,
isSignedIn: false,
csrfToken: '', csrfToken: '',
windowHeight: 0, windowHeight: 0,
navHeight: 0, navHeight: 0,
@ -25,20 +23,20 @@ export default handleActions(
toast toast
}), }),
[types.setUser]: (state, { payload: user }) => ({ [types.updateThisUser]: (state, { payload: user }) => ({
...state, ...state,
...user, user,
isSignedIn: true shouldShowSignIn: true
}),
[types.showSignIn]: state => ({
...state,
shouldShowSignIn: true
}), }),
[types.challengeSaved]: (state, { payload: { points = 0 } }) => ({ [types.challengeSaved]: (state, { payload: { points = 0 } }) => ({
...state, ...state,
points points
}), }),
[types.updatePoints]: (state, { payload: points }) => ({
...state,
points
}),
[types.updateWindowHeight]: (state, { payload: windowHeight }) => ({ [types.updateWindowHeight]: (state, { payload: windowHeight }) => ({
...state, ...state,
windowHeight windowHeight

View File

@ -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] || {}
})
);

View File

@ -4,10 +4,12 @@ export default createTypes([
'updateTitle', 'updateTitle',
'fetchUser', 'fetchUser',
'setUser', 'addUser',
'updateThisUser',
'updateUserPoints',
'showSignIn',
'makeToast', 'makeToast',
'updatePoints',
'handleError', 'handleError',
'toggleNightMode', 'toggleNightMode',
// used to hit the server // used to hit the server

View File

@ -4,7 +4,7 @@ import { showChallengeComplete, moveToNextChallenge } from './actions';
import { import {
createErrorObservable, createErrorObservable,
makeToast, makeToast,
updatePoints updateUserPoints
} from '../../../redux/actions'; } from '../../../redux/actions';
import { challengeSelector } from './selectors'; import { challengeSelector } from './selectors';
@ -16,25 +16,16 @@ import { postJSON$ } from '../../../../utils/ajax-stream';
// lots of repeat code // lots of repeat code
function completedChallenge(state) { function completedChallenge(state) {
let body; const { challenge: { id } } = challengeSelector(state);
let isSignedIn = false; const {
try { app: { user, csrfToken },
const { challengesApp: { files }
challenge: { id } } = state;
} = challengeSelector(state); const body = {
const { id,
app: { isSignedIn: _isSignedId, csrfToken }, _csrf: csrfToken,
challengesApp: { files } files
} = state; };
isSignedIn = _isSignedId;
body = {
id,
_csrf: csrfToken,
files
};
} catch (err) {
return createErrorObservable(err);
}
const saveChallenge$ = postJSON$('/modern-challenge-completed', body) const saveChallenge$ = postJSON$('/modern-challenge-completed', body)
.retry(3) .retry(3)
.flatMap(({ alreadyCompleted, points }) => { .flatMap(({ alreadyCompleted, points }) => {
@ -46,7 +37,7 @@ function completedChallenge(state) {
title: 'Saved', title: 'Saved',
type: 'info' type: 'info'
}), }),
updatePoints(points) updateUserPoints(user, points)
); );
}) })
.catch(createErrorObservable); .catch(createErrorObservable);
@ -55,7 +46,7 @@ function completedChallenge(state) {
moveToNextChallenge(), moveToNextChallenge(),
makeToast({ makeToast({
title: 'Congratulations!', title: 'Congratulations!',
message: isSignedIn ? ' Saving...' : 'Moving on to next challenge.', message: user ? ' Saving...' : 'Moving on to next challenge.',
type: 'success' type: 'success'
}) })
); );
@ -87,7 +78,7 @@ function submitProject(type, state, { solution, githubLink }) {
challenge: { id, challengeType } challenge: { id, challengeType }
} = challengeSelector(state); } = challengeSelector(state);
const { const {
app: { isSignedIn, csrfToken } app: { user, csrfToken }
} = state; } = state;
const body = { const body = {
id, id,
@ -109,7 +100,7 @@ function submitProject(type, state, { solution, githubLink }) {
title: 'Saved', title: 'Saved',
type: 'info' type: 'info'
}), }),
updatePoints(points) updateUserPoints(user, points)
); );
}) })
.catch(createErrorObservable); .catch(createErrorObservable);
@ -117,7 +108,7 @@ function submitProject(type, state, { solution, githubLink }) {
const challengeCompleted$ = Observable.of( const challengeCompleted$ = Observable.of(
makeToast({ makeToast({
title: randomCompliment(), title: randomCompliment(),
message: isSignedIn ? ' Saving...' : 'Moving on to next challenge.', message: user ? ' Saving...' : 'Moving on to next challenge.',
type: 'success' type: 'success'
}) })
// moveToNextChallenge() // moveToNextChallenge()
@ -130,7 +121,7 @@ function submitSimpleChallenge(type, state) {
challenge: { id } challenge: { id }
} = challengeSelector(state); } = challengeSelector(state);
const { const {
app: { isSignedIn, csrfToken } app: { user, csrfToken }
} = state; } = state;
const body = { const body = {
id, id,
@ -147,7 +138,7 @@ function submitSimpleChallenge(type, state) {
title: 'Saved', title: 'Saved',
type: 'info' type: 'info'
}), }),
updatePoints(points) updateUserPoints(user, points)
); );
}) })
.catch(createErrorObservable); .catch(createErrorObservable);
@ -155,7 +146,7 @@ function submitSimpleChallenge(type, state) {
const challengeCompleted$ = Observable.of( const challengeCompleted$ = Observable.of(
makeToast({ makeToast({
title: randomCompliment(), title: randomCompliment(),
message: isSignedIn ? ' Saving...' : 'Moving on to next challenge.', message: user ? ' Saving...' : 'Moving on to next challenge.',
type: 'success' type: 'success'
}), }),
moveToNextChallenge() moveToNextChallenge()

View File

@ -15,6 +15,18 @@ import {
updateCurrentChallenge updateCurrentChallenge
} from './actions'; } 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 }) { export default function fetchChallengesSaga(action$, getState, { services }) {
return action$ return action$
.filter(({ type }) => ( .filter(({ type }) => (
@ -48,12 +60,20 @@ export default function fetchChallengesSaga(action$, getState, { services }) {
.flatMap(({ entities, result, redirect } = {}) => { .flatMap(({ entities, result, redirect } = {}) => {
if (type === fetchChallenge) { if (type === fetchChallenge) {
return Observable.of( return Observable.of(
fetchChallengeCompleted(entities, result), fetchChallengeCompleted(
createNameIdMap(entities),
result
),
updateCurrentChallenge(entities.challenge[result.challenge]), updateCurrentChallenge(entities.challenge[result.challenge]),
redirect ? delayedRedirect(redirect) : null redirect ? delayedRedirect(redirect) : null
); );
} }
return Observable.just(fetchChallengesCompleted(entities, result)); return Observable.just(
fetchChallengesCompleted(
createNameIdMap(entities),
result
)
);
}) })
.catch(createErrorObserable); .catch(createErrorObserable);
}); });

View File

@ -1,11 +1,29 @@
import debugFactory from 'debug'; import debug from 'debug';
import _ from 'lodash';
const censor = '**********************:P********'; const publicUserProps = [
const debug = debugFactory('fcc:services:user'); 'id',
const protectedUserFields = { 'name',
password: censor, 'username',
profiles: censor 'bio',
}; 'theme',
'picture',
'points',
'languageTag',
'isCheater',
'isGithubCool',
'isFrontEndCert',
'isBackEndCert',
'isDataVisCert',
'isFullStackCert',
'githubURL',
'currentChallenge',
'challengeMap'
];
const log = debug('fcc:services:user');
export default function userServices() { export default function userServices() {
return { return {
@ -13,19 +31,26 @@ export default function userServices() {
read: (req, resource, params, config, cb) => { read: (req, resource, params, config, cb) => {
let { user } = req; let { user } = req;
if (user) { if (user) {
debug('user is signed in'); log('user is signed in');
// Zalgo!!! return user.getChallengeMap$()
return process.nextTick(() => { .map(challengeMap => ({ ...user.toJSON(), challengeMap }))
cb( .subscribe(
null, user => cb(
{ null,
...user.toJSON(), {
...protectedUserFields entities: {
} user: {
[user.username]: _.pick(user, publicUserProps)
}
},
result: user.username
}
),
cb
); );
});
} }
debug('user is not signed in'); debug('user is not signed in');
// Zalgo!!!
return process.nextTick(() => { return process.nextTick(() => {
cb(null, {}); cb(null, {});
}); });