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

View File

@ -8,8 +8,13 @@ const initialState = {
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) {
const { type, payload: { username, points, flag } = {} } = action;
const {
type,
payload: { email, username, points, flag, languageTag } = {}
} = action;
if (type === updateCompletedChallenges) {
const username = action.payload;
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) {
return {
...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) {
return {
...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;
}

View File

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

View File

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

View File

@ -1,7 +1,27 @@
import React, { PropTypes } from 'react';
import { createSelector } from 'reselect';
import { reduxForm } from 'redux-form';
import { FormControl } from 'react-bootstrap';
import { updateMyLang } from '../redux/actions';
import { userSelector } from '../../../redux/selectors';
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 = [(
<option
disabled={ true }
@ -30,19 +50,58 @@ const options = [(
)
];
export default function LangaugeSettings({ userLang }) {
return (
<FormControl
className='btn btn-block btn-primary btn-link-social'
componentClass='select'
defaultValue='not-the-momma'
value={ userLang }
>
{ options }
</FormControl>
);
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 (
<FormControl
className='btn btn-block btn-primary btn-link-social'
componentClass='select'
{ ...lang }
onChange={ this.handleChange }
>
{ options }
</FormControl>
);
}
}
LangaugeSettings.propTypes = {
userLang: PropTypes.string
};
export default reduxForm(
{
form: 'lang',
fields,
validator,
overwriteOnInitialValuesChange: false
},
mapStateToProps,
actions
)(LangaugeSettings);

View File

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

View File

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

View File

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

View File

@ -1,7 +1,16 @@
import { Observable } from 'rx';
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 { updateUserFlag, createErrorObservable } from '../../../redux/actions';
import langs from '../../../../utils/supported-languages';
const urlMap = {
isLocked: 'lockdown',
@ -9,7 +18,61 @@ const urlMap = {
sendNotificationEmail: 'notification-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$
.filter(({ type, payload }) => type === types.toggleUserFlag && payload)
.map(({ payload }) => payload);
@ -34,7 +97,15 @@ export default function userUpdateSaga(actions$, getState) {
}
return updateUserFlag(username, flag);
})
.catch(createErrorObservable);
.catch(doActionOnError(() => {
return updateUserFlag(username, currentValue);
}));
});
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) :
true;
if (!isEmail(email)) {
return Promise.reject(
if (!isEmail('' + email)) {
return Observable.throw(
new Error('The submitted email not valid.')
);
}
// email is already associated and verified with this account
if (ownEmail && this.emailVerified) {
return Promise.reject(new Error(
return Observable.throw(new Error(
`${email} is already associated with this account.`
));
}
@ -410,13 +410,13 @@ module.exports = function(User) {
`${minutesLeft} minute${minutesLeft > 1 ? 's' : ''}` :
'a few seconds';
return Promise.reject(new Error(
return Observable.throw(new Error(
`Please wait ${timeToWait} to resend email verification.`
));
}
return User.doesExist(null, email)
.then(exists => {
return Observable.fromPromise(User.doesExist(null, email))
.flatMap(exists => {
// not associated with this account, but is associated with another
if (!ownEmail && exists) {
return Promise.reject(
@ -434,67 +434,35 @@ module.exports = function(User) {
this.email = email;
this.emailVerified = emailVerified;
this.emailVerifyTTL = new Date();
})
.flatMap(() => {
var mailOptions = {
type: 'email',
to: email,
from: 'Team@freecodecamp.com',
subject: 'Welcome to Free Code Camp!',
protocol: isDev ? null : 'https',
host: isDev ? 'localhost' : 'freecodecamp.com',
port: isDev ? null : 443,
template: path.join(
__dirname,
'..',
'..',
'server',
'views',
'emails',
'user-email-verify.ejs'
)
};
return this.verify(mailOptions);
})
.map(() => dedent`
Please check your email.
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();
});
});
})
.flatMap(() => {
const mailOptions = {
type: 'email',
to: email,
from: 'Team@freecodecamp.com',
subject: 'Welcome to Free Code Camp!',
protocol: isDev ? null : 'https',
host: isDev ? 'localhost' : 'freecodecamp.com',
port: isDev ? null : 443,
template: path.join(
__dirname,
'..',
'..',
'server',
'views',
'emails',
'user-email-verify.ejs'
)
};
return this.verify(mailOptions);
})
.map(() => dedent`
Please check your email.
We sent you a link that you can click to verify your email address.
`);
};
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 =
function giveBrowniePoints(receiver, giver, data = {}, dev = false, cb) {
const findUser = observeMethod(User, 'findOne');

View File

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

View File

@ -1,4 +1,5 @@
import { ifNoUser401 } from '../utils/middleware';
import supportedLanguages from '../../common/utils/supported-languages.js';
export default function settingsController(app) {
const api = app.loopback.Router();
@ -15,6 +16,39 @@ export default function settingsController(app) {
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(
'/toggle-lockdown',
toggleUserFlag('isLocked')
@ -34,5 +68,15 @@ export default function settingsController(app) {
ifNoUser401,
toggleUserFlag('sendQuincyEmail')
);
api.post(
'/update-my-email',
ifNoUser401,
updateMyEmail
);
api.post(
'/update-my-lang',
ifNoUser401,
updateMyLang
);
app.use(api);
}