Feature(settings): complete settings page logic

This commit is contained in:
Berkeley Martinez
2016-07-19 16:36:34 -07:00
parent 5d94cb369d
commit 9a2dfca0fc
16 changed files with 514 additions and 112 deletions

View File

@ -9,7 +9,7 @@ export const updateTitle = createAction(types.updateTitle);
// 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( // addUser(
// entities: { [userId]: User } // entities: { [userId]: User }
// ) => Action // ) => Action
export const addUser = createAction( export const addUser = createAction(
@ -25,11 +25,21 @@ export const updateUserPoints = createAction(
types.updateUserPoints, types.updateUserPoints,
(username, points) => ({ username, points }) (username, points) => ({ username, points })
); );
// updateUserPoints(username: String, flag: String) => Action // updateUserFlag(username: String, flag: String) => Action
export const updateUserFlag = createAction( export const updateUserFlag = createAction(
types.updateUserFlag, types.updateUserFlag,
(username, flag) => ({ username, flag }) (username, flag) => ({ username, flag })
); );
// updateUserEmail(username: String, email: String) => Action
export const updateUserEmail = createAction(
types.updateUserFlag,
(username, email) => ({ username, email })
);
// updateUserLang(username: String, lang: String) => Action
export const updateUserLang = createAction(
types.updateUserLang,
(username, lang) => ({ username, lang })
);
// updateCompletedChallenges(username: String) => Action // updateCompletedChallenges(username: String) => Action
export const updateCompletedChallenges = createAction( export const updateCompletedChallenges = createAction(
types.updateCompletedChallenges types.updateCompletedChallenges
@ -55,6 +65,16 @@ export const createErrorObservable = error => Observable.just({
type: types.handleError, type: types.handleError,
error error
}); });
// doActionOnError(
// actionCreator: (() => Action|Null)
// ) => (error: Error) => Observable[Action]
export const doActionOnError = actionCreator => error => Observable.of(
{
type: types.handleError,
error
},
actionCreator()
);
// drawers // drawers

View File

@ -8,8 +8,13 @@ const initialState = {
user: {} user: {}
}; };
// future refactor(berks): Several of the actions here are just updating props
// on the main user. These can be refactors into one response for all actions
export default function entities(state = initialState, action) { export default function entities(state = initialState, action) {
const { type, payload: { username, points, flag } = {} } = action; const {
type,
payload: { email, username, points, flag, languageTag } = {}
} = action;
if (type === updateCompletedChallenges) { if (type === updateCompletedChallenges) {
const username = action.payload; const username = action.payload;
const completedChallengeMap = state.user[username].challengeMap || {}; const completedChallengeMap = state.user[username].challengeMap || {};
@ -26,6 +31,12 @@ export default function entities(state = initialState, action) {
}, {}) }, {})
}; };
} }
if (action.meta && action.meta.entities) {
return {
...state,
...action.meta.entities
};
}
if (type === updateUserPoints) { if (type === updateUserPoints) {
return { return {
...state, ...state,
@ -38,12 +49,6 @@ export default function entities(state = initialState, action) {
} }
}; };
} }
if (action.meta && action.meta.entities) {
return {
...state,
...action.meta.entities
};
}
if (action.type === types.updateUserFlag) { if (action.type === types.updateUserFlag) {
return { return {
...state, ...state,
@ -56,5 +61,29 @@ export default function entities(state = initialState, action) {
} }
}; };
} }
if (action.type === types.updateUserEmail) {
return {
...state,
user: {
...state.user,
[username]: {
...state.user[username],
email
}
}
};
}
if (action.type === types.updateUserLang) {
return {
...state,
user: {
...state.user,
[username]: {
...state.user[username],
languageTag
}
}
};
}
return state; return state;
} }

View File

@ -8,6 +8,8 @@ export default createTypes([
'updateThisUser', 'updateThisUser',
'updateUserPoints', 'updateUserPoints',
'updateUserFlag', 'updateUserFlag',
'updateUserEmail',
'updateUserLang',
'updateCompletedChallenges', 'updateCompletedChallenges',
'showSignIn', 'showSignIn',

View File

@ -1,20 +1,22 @@
import React, { PropTypes } from 'react'; import React, { PropTypes } from 'react';
import { Link } from 'react-router';
import { Button, Row, Col } from 'react-bootstrap'; import { Button, Row, Col } from 'react-bootstrap';
import FA from 'react-fontawesome'; import FA from 'react-fontawesome';
import classnames from 'classnames'; import classnames from 'classnames';
export function UpdateEmailButton() { export function UpdateEmailButton() {
return ( return (
<Link to='/settings/update-email'>
<Button <Button
block={ true } block={ true }
bsSize='lg' bsSize='lg'
bsStyle='primary' bsStyle='primary'
className='btn-link-social' className='btn-link-social'
href='/update-email'
> >
<FA name='envelope' /> <FA name='envelope' />
Update my Email Update my Email
</Button> </Button>
</Link>
); );
} }

View File

@ -1,7 +1,27 @@
import React, { PropTypes } from 'react'; import React, { PropTypes } from 'react';
import { createSelector } from 'reselect';
import { reduxForm } from 'redux-form';
import { FormControl } from 'react-bootstrap'; import { FormControl } from 'react-bootstrap';
import { updateMyLang } from '../redux/actions';
import { userSelector } from '../../../redux/selectors';
import langs from '../../../../utils/supported-languages'; import langs from '../../../../utils/supported-languages';
const mapStateToProps = createSelector(
userSelector,
({ user: { languageTag } }) => ({
// send null to prevent redux-form from initialize empty
initialValues: languageTag ? { lang: languageTag } : null
})
);
const actions = { updateMyLang };
const fields = [ 'lang' ];
const validator = values => {
if (!langs[values.lang]) {
return { lang: `${values.lang} is unsupported` };
}
return {};
};
const options = [( const options = [(
<option <option
disabled={ true } disabled={ true }
@ -30,19 +50,58 @@ const options = [(
) )
]; ];
export default function LangaugeSettings({ userLang }) { export class LangaugeSettings extends React.Component {
static propTypes = {
fields: PropTypes.object,
handleSubmit: PropTypes.func.isRequired,
updateMyLang: PropTypes.func.isRequired
};
constructor(...props) {
super(...props);
this.handleChange = this.handleChange.bind(this);
}
componentWillUnmount() {
// make sure to clear timeout if it exist
if (this.timeout) {
clearTimeout(this.timeout);
}
}
handleChange(e) {
e.preventDefault();
this.props.fields.lang.onChange(e);
// give redux-form HOC state time to catch up before
// attempting to submit
this.timeout = setTimeout(
() => this.props.handleSubmit(this.props.updateMyLang)(),
0
);
}
render() {
const {
fields: { lang }
} = this.props;
return ( return (
<FormControl <FormControl
className='btn btn-block btn-primary btn-link-social' className='btn btn-block btn-primary btn-link-social'
componentClass='select' componentClass='select'
defaultValue='not-the-momma' { ...lang }
value={ userLang } onChange={ this.handleChange }
> >
{ options } { options }
</FormControl> </FormControl>
); );
}
} }
LangaugeSettings.propTypes = { export default reduxForm(
userLang: PropTypes.string {
}; form: 'lang',
fields,
validator,
overwriteOnInitialValuesChange: false
},
mapStateToProps,
actions
)(LangaugeSettings);

View File

@ -14,9 +14,13 @@ import {
openDeleteModal, openDeleteModal,
hideDeleteModal hideDeleteModal
} from '../redux/actions'; } from '../redux/actions';
import { toggleNightMode } from '../../../redux/actions'; import {
toggleNightMode,
updateTitle
} from '../../../redux/actions';
const actions = { const actions = {
updateTitle,
toggleNightMode, toggleNightMode,
openDeleteModal, openDeleteModal,
hideDeleteModal, hideDeleteModal,
@ -57,8 +61,13 @@ const mapStateToProps = state => {
}; };
export class Settings extends React.Component { export class Settings extends React.Component {
constructor(...props) {
super(...props);
this.updateMyLang = this.updateMyLang.bind(this);
}
static displayName = 'Settings'; static displayName = 'Settings';
static propTypes = { static propTypes = {
children: PropTypes.element,
username: PropTypes.string, username: PropTypes.string,
isDeleteOpen: PropTypes.bool, isDeleteOpen: PropTypes.bool,
isLocked: PropTypes.bool, isLocked: PropTypes.bool,
@ -69,17 +78,32 @@ export class Settings extends React.Component {
sendMonthlyEmail: PropTypes.bool, sendMonthlyEmail: PropTypes.bool,
sendNotificationEmail: PropTypes.bool, sendNotificationEmail: PropTypes.bool,
sendQuincyEmail: PropTypes.bool, sendQuincyEmail: PropTypes.bool,
toggleNightMode: PropTypes.func, updateTitle: PropTypes.func.isRequired,
toggleIsLocked: PropTypes.func, toggleNightMode: PropTypes.func.isRequired,
toggleQuincyEmail: PropTypes.func, toggleIsLocked: PropTypes.func.isRequired,
toggleMonthlyEmail: PropTypes.func, toggleQuincyEmail: PropTypes.func.isRequired,
toggleNotificationEmail: PropTypes.func, toggleMonthlyEmail: PropTypes.func.isRequired,
openDeleteModal: PropTypes.func, toggleNotificationEmail: PropTypes.func.isRequired,
hideDeleteModal: PropTypes.func openDeleteModal: PropTypes.func.isRequired,
hideDeleteModal: PropTypes.func.isRequired,
lang: PropTypes.string,
initialLang: PropTypes.string,
updateMyLang: PropTypes.func
}; };
updateMyLang(e) {
e.preventDefault();
const lang = e.target.value;
this.props.updateMyLang(lang);
}
componentWillMount() {
this.props.updateTitle('Settings');
}
render() { render() {
const { const {
children,
username, username,
isDeleteOpen, isDeleteOpen,
isLocked, isLocked,
@ -98,6 +122,18 @@ export class Settings extends React.Component {
openDeleteModal, openDeleteModal,
hideDeleteModal hideDeleteModal
} = this.props; } = this.props;
if (children) {
return (
<Row>
<Col
sm={ 4 }
smOffset={ 4 }
>
{ children }
</Col>
</Row>
);
}
return ( return (
<div> <div>
<Row> <Row>

View File

@ -1,6 +1,10 @@
import Settings from './components/Settings.jsx'; import Settings from './components/Settings.jsx';
import updateEmail from './routes/update-email';
export default { export default {
path: 'settings', path: 'settings',
component: Settings component: Settings,
childRoutes: [
updateEmail
]
}; };

View File

@ -8,12 +8,19 @@ const initialState = {
export const types = createTypes([ export const types = createTypes([
'toggleUserFlag', 'toggleUserFlag',
'openDeleteModal', 'openDeleteModal',
'hideDeleteModal' 'hideDeleteModal',
'updateMyEmail',
'updateMyLang'
], 'settings'); ], 'settings');
export const toggleUserFlag = createAction(types.toggleUserFlag); export const toggleUserFlag = createAction(types.toggleUserFlag);
export const openDeleteModal = createAction(types.openDeleteModal); export const openDeleteModal = createAction(types.openDeleteModal);
export const hideDeleteModal = createAction(types.hideDeleteModal); export const hideDeleteModal = createAction(types.hideDeleteModal);
export const updateMyEmail = createAction(types.updateMyEmail);
export const updateMyLang = createAction(
types.updateMyLang,
(values) => values.lang
);
export default handleActions( export default handleActions(
{ {

View File

@ -1,7 +1,16 @@
import { Observable } from 'rx'; import { Observable } from 'rx';
import { types } from './actions'; import { types } from './actions';
import combineSagas from '../../../utils/combine-sagas';
import { makeToast } from '../../../toasts/redux/actions';
import {
updateUserFlag,
updateUserEmail,
updateUserLang,
doActionOnError
} from '../../../redux/actions';
import { postJSON$ } from '../../../../utils/ajax-stream'; import { postJSON$ } from '../../../../utils/ajax-stream';
import { updateUserFlag, createErrorObservable } from '../../../redux/actions'; import langs from '../../../../utils/supported-languages';
const urlMap = { const urlMap = {
isLocked: 'lockdown', isLocked: 'lockdown',
@ -9,7 +18,61 @@ const urlMap = {
sendNotificationEmail: 'notification-email', sendNotificationEmail: 'notification-email',
sendMonthlyEmail: 'announcement-email' sendMonthlyEmail: 'announcement-email'
}; };
export default function userUpdateSaga(actions$, getState) {
export function updateUserEmailSaga(actions$, getState) {
return actions$
.filter(({ type }) => type === types.updateMyEmail)
.flatMap(({ payload: email }) => {
const {
app: { user: username, csrfToken: _csrf },
entities: { user: userMap }
} = getState();
const { email: oldEmail } = userMap[username] || {};
const body = { _csrf, email };
const optimisticUpdate$ = Observable.just(
updateUserEmail(username, email)
);
const ajaxUpdate$ = postJSON$('/update-my-email', body)
.map(({ message }) => makeToast({ message }))
.catch(doActionOnError(() => oldEmail ?
updateUserFlag(username, oldEmail) :
null
));
return Observable.merge(optimisticUpdate$, ajaxUpdate$);
});
}
export function updateUserLangSaga(actions$, getState) {
const updateLang$ = actions$
.filter(({ type, payload }) => (
type === types.updateMyLang && !!langs[payload]
))
.map(({ payload }) => payload);
const ajaxUpdate$ = updateLang$
.flatMap(lang => {
const {
app: { user: username, csrfToken: _csrf },
entities: { user: userMap }
} = getState();
const { languageTag: oldLang } = userMap[username] || {};
const body = {
_csrf,
lang
};
return postJSON$('/update-my-lang', body)
.map(({ message }) => makeToast({ message }))
.catch(doActionOnError(() => {
return updateUserLang(username, oldLang);
}));
});
const optimistic$ = updateLang$
.map(lang => {
const { app: { user: username } } = getState();
return updateUserLang(username, lang);
});
return Observable.merge(ajaxUpdate$, optimistic$);
}
export function updateUserFlagSaga(actions$, getState) {
const toggleFlag$ = actions$ const toggleFlag$ = actions$
.filter(({ type, payload }) => type === types.toggleUserFlag && payload) .filter(({ type, payload }) => type === types.toggleUserFlag && payload)
.map(({ payload }) => payload); .map(({ payload }) => payload);
@ -34,7 +97,15 @@ export default function userUpdateSaga(actions$, getState) {
} }
return updateUserFlag(username, flag); return updateUserFlag(username, flag);
}) })
.catch(createErrorObservable); .catch(doActionOnError(() => {
return updateUserFlag(username, currentValue);
}));
}); });
return Observable.merge(optimistic$, serverUpdate$); return Observable.merge(optimistic$, serverUpdate$);
} }
export default combineSagas(
updateUserFlagSaga,
updateUserEmailSaga,
updateUserLangSaga
);

View File

@ -0,0 +1,144 @@
import React, { PropTypes } from 'react';
import { Button, HelpBlock, FormControl, FormGroup } from 'react-bootstrap';
import { LinkContainer } from 'react-router-bootstrap';
import { reduxForm } from 'redux-form';
import { isEmail } from 'validator';
import { getValidationState } from '../../../../utils/form';
import { updateMyEmail } from '../../redux/actions';
const actions = {
updateMyEmail
};
const fields = [
'email',
'duplicate'
];
const validateFields = ({ email, duplicate }) => {
const errors = {};
if (!isEmail('' + email)) {
errors.email = 'This email is invalid.';
}
if (duplicate && email !== duplicate) {
errors.duplicate = 'This email does not match the one above.';
}
return errors;
};
const mapStateToProps = state => {
const {
app: { user: username },
entities: { user: userMap }
} = state;
const { email, emailVerified } = userMap[username] || {};
return {
initialValues: { email },
isEmailThere: !!email,
isVerified: !!emailVerified
};
};
export class UpdateEmail extends React.Component {
constructor(...props) {
super(...props);
this.handleSubmit = this.handleSubmit.bind(this);
}
static displayName = 'UpdateEmail';
static propTypes = {
isEmailThere: PropTypes.bool,
isVerified: PropTypes.bool,
fields: PropTypes.object.isRequired,
submitting: PropTypes.bool,
handleSubmit: PropTypes.func.isRequired,
updateMyEmail: PropTypes.func.isRequired
};
handleSubmit(e) {
e.preventDefault();
this.props.handleSubmit(({ email }) => this.props.updateMyEmail(email))(e);
}
render() {
const {
isEmailThere,
isVerified,
fields: { email, duplicate },
submitting
} = this.props;
const buttonCopy = !isEmailThere || isVerified ?
'Update my Email' :
'Verify Email';
return (
<div>
<h2 className='text-center'>Update your email address here:</h2>
<form
name='update-email'
onSubmit={ this.handleSubmit }
>
<FormGroup
bsSize='lg'
controlId='email'
validationState={ getValidationState(email) }
>
<FormControl
autofocus={ true }
placeholder='Enter your new email'
type='email'
{ ...email }
/>
{
!email.error ?
null :
<HelpBlock>{ email.error }</HelpBlock>
}
</FormGroup>
<FormGroup
bsSize='lg'
controlId='duplicate'
validationState={ getValidationState(duplicate) }
>
<FormControl
placeholder='re-type your email address'
type='email'
{ ...duplicate }
/>
{
!duplicate.error ?
null :
<HelpBlock>{ duplicate.error }</HelpBlock>
}
</FormGroup>
<FormGroup>
<Button
block={ true }
bsSize='lg'
bsStyle='primary'
disabled={ submitting }
type='submit'
>
{ buttonCopy }
</Button>
<div className='button-spacer' />
<LinkContainer to='/settings'>
<Button
block={ true }
bsSize='lg'
bsStyle='primary'
>
Go back to Settings
</Button>
</LinkContainer>
</FormGroup>
</form>
</div>
);
}
}
export default reduxForm(
{
form: 'update-email',
fields,
validate: validateFields
},
mapStateToProps,
actions
)(UpdateEmail);

View File

@ -0,0 +1,6 @@
import UpdateEmail from './Update-Email.jsx';
export default {
path: 'update-email',
component: UpdateEmail
};

View File

@ -0,0 +1,9 @@
import { Observable } from 'rx';
export default function combineSagas(...sagas) {
return (actions$, getState, deps) => {
return Observable.merge(
sagas.map(saga => saga(actions$, getState, deps))
);
};
}

View File

@ -390,14 +390,14 @@ module.exports = function(User) {
lastEmailSentAt.isBefore(fiveMinutesAgo) : lastEmailSentAt.isBefore(fiveMinutesAgo) :
true; true;
if (!isEmail(email)) { if (!isEmail('' + email)) {
return Promise.reject( return Observable.throw(
new Error('The submitted email not valid.') new Error('The submitted email not valid.')
); );
} }
// email is already associated and verified with this account // email is already associated and verified with this account
if (ownEmail && this.emailVerified) { if (ownEmail && this.emailVerified) {
return Promise.reject(new Error( return Observable.throw(new Error(
`${email} is already associated with this account.` `${email} is already associated with this account.`
)); ));
} }
@ -410,13 +410,13 @@ module.exports = function(User) {
`${minutesLeft} minute${minutesLeft > 1 ? 's' : ''}` : `${minutesLeft} minute${minutesLeft > 1 ? 's' : ''}` :
'a few seconds'; 'a few seconds';
return Promise.reject(new Error( return Observable.throw(new Error(
`Please wait ${timeToWait} to resend email verification.` `Please wait ${timeToWait} to resend email verification.`
)); ));
} }
return User.doesExist(null, email) return Observable.fromPromise(User.doesExist(null, email))
.then(exists => { .flatMap(exists => {
// not associated with this account, but is associated with another // not associated with this account, but is associated with another
if (!ownEmail && exists) { if (!ownEmail && exists) {
return Promise.reject( return Promise.reject(
@ -434,9 +434,10 @@ module.exports = function(User) {
this.email = email; this.email = email;
this.emailVerified = emailVerified; this.emailVerified = emailVerified;
this.emailVerifyTTL = new Date(); this.emailVerifyTTL = new Date();
});
}) })
.flatMap(() => { .flatMap(() => {
var mailOptions = { const mailOptions = {
type: 'email', type: 'email',
to: email, to: email,
from: 'Team@freecodecamp.com', from: 'Team@freecodecamp.com',
@ -459,42 +460,9 @@ module.exports = function(User) {
.map(() => dedent` .map(() => dedent`
Please check your email. Please check your email.
We sent you a link that you can click to verify your email address. We sent you a link that you can click to verify your email address.
`) `);
.catch(error => {
debug(error);
return Observable.throw(
'Oops, something went wrong, please try again later.'
);
})
.toPromise();
});
}; };
User.remoteMethod(
'updateEmail',
{
isStatic: false,
description: 'updates the email of the user object',
accepts: [
{
arg: 'email',
type: 'string',
required: true
}
],
returns: [
{
arg: 'message',
type: 'string'
}
],
http: {
path: '/update-email',
verb: 'POST'
}
}
);
User.giveBrowniePoints = User.giveBrowniePoints =
function giveBrowniePoints(receiver, giver, data = {}, dev = false, cb) { function giveBrowniePoints(receiver, giver, data = {}, dev = false, cb) {
const findUser = observeMethod(User, 'findOne'); const findUser = observeMethod(User, 'findOne');

View File

@ -15,7 +15,8 @@ const routes = [
'/challenges', '/challenges',
'/challenges/*', '/challenges/*',
'/map', '/map',
'/settings' '/settings',
'/settings/*'
]; ];
const devRoutes = []; const devRoutes = [];

View File

@ -1,4 +1,5 @@
import { ifNoUser401 } from '../utils/middleware'; import { ifNoUser401 } from '../utils/middleware';
import supportedLanguages from '../../common/utils/supported-languages.js';
export default function settingsController(app) { export default function settingsController(app) {
const api = app.loopback.Router(); const api = app.loopback.Router();
@ -15,6 +16,39 @@ export default function settingsController(app) {
next next
); );
}; };
function updateMyEmail(req, res, next) {
const { user, body: { email } } = req;
return user.updateEmail(email)
.subscribe(
(message) => res.json({ message }),
next
);
}
function updateMyLang(req, res, next) {
const { user, body: { lang } = {} } = req;
const langName = supportedLanguages[lang];
const update = { languageTag: lang };
if (!supportedLanguages[lang]) {
return res.json({
message: `${lang} is currently unsupported`
});
}
if (user.langaugeTag === lang) {
return res.json({
message: `Your language is already set to ${langName}`
});
}
return user.update$(update)
.subscribe(
() => res.json({
message: `Your language has been updated to '${langName}'`
}),
next
);
}
api.post( api.post(
'/toggle-lockdown', '/toggle-lockdown',
toggleUserFlag('isLocked') toggleUserFlag('isLocked')
@ -34,5 +68,15 @@ export default function settingsController(app) {
ifNoUser401, ifNoUser401,
toggleUserFlag('sendQuincyEmail') toggleUserFlag('sendQuincyEmail')
); );
api.post(
'/update-my-email',
ifNoUser401,
updateMyEmail
);
api.post(
'/update-my-lang',
ifNoUser401,
updateMyLang
);
app.use(api); app.use(api);
} }