From 0d3dd75f41f805b184dbdfddd4cb8c1d4ce8d5d9 Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Sat, 16 Jul 2016 10:38:06 -0700 Subject: [PATCH] Feature(settings): add user flag logic This also moves a couple of settings to their own controller --- common/app/create-reducer.js | 2 + common/app/redux/actions.js | 5 ++ common/app/redux/entities-reducer.js | 14 ++- common/app/redux/types.js | 1 + .../settings/components/Email-Setting.jsx | 14 ++- .../settings/components/Language-Settings.jsx | 23 +++-- .../settings/components/Locked-Settings.jsx | 6 +- .../routes/settings/components/Settings.jsx | 86 +++++++++++++++++-- .../settings/components/Social-Settings.jsx | 2 +- common/app/routes/settings/redux/actions.js | 30 +++++++ common/app/routes/settings/redux/index.js | 9 ++ .../routes/settings/redux/update-user-saga.js | 40 +++++++++ common/app/sagas.js | 4 +- server/boot/settings.js | 38 ++++++++ server/boot/user.js | 76 ---------------- server/services/user.js | 12 ++- 16 files changed, 261 insertions(+), 101 deletions(-) create mode 100644 common/app/routes/settings/redux/actions.js create mode 100644 common/app/routes/settings/redux/index.js create mode 100644 common/app/routes/settings/redux/update-user-saga.js create mode 100644 server/boot/settings.js diff --git a/common/app/create-reducer.js b/common/app/create-reducer.js index 80964bc069..619f0416ff 100644 --- a/common/app/create-reducer.js +++ b/common/app/create-reducer.js @@ -8,6 +8,7 @@ import { reducer as challengesApp, projectNormalizer } from './routes/challenges/redux'; +import { reducer as settingsApp } from './routes/settings/redux'; export default function createReducer(sideReducers = {}) { return combineReducers({ @@ -16,6 +17,7 @@ export default function createReducer(sideReducers = {}) { app, toasts, challengesApp, + settingsApp, form: formReducer.normalize({ ...projectNormalizer }) }); } diff --git a/common/app/redux/actions.js b/common/app/redux/actions.js index 75d921a777..435d3e93cd 100644 --- a/common/app/redux/actions.js +++ b/common/app/redux/actions.js @@ -25,6 +25,11 @@ export const updateUserPoints = createAction( types.updateUserPoints, (username, points) => ({ username, points }) ); +// updateUserPoints(username: String, flag: String) => Action +export const updateUserFlag = createAction( + types.updateUserFlag, + (username, flag) => ({ username, flag }) +); // updateCompletedChallenges(username: String) => Action export const updateCompletedChallenges = createAction( types.updateCompletedChallenges diff --git a/common/app/redux/entities-reducer.js b/common/app/redux/entities-reducer.js index 979fb7ca98..250ae395cc 100644 --- a/common/app/redux/entities-reducer.js +++ b/common/app/redux/entities-reducer.js @@ -9,7 +9,7 @@ const initialState = { }; export default function entities(state = initialState, action) { - const { type, payload: { username, points } = {} } = action; + const { type, payload: { username, points, flag } = {} } = action; if (type === updateCompletedChallenges) { const username = action.payload; const completedChallengeMap = state.user[username].challengeMap || {}; @@ -44,5 +44,17 @@ export default function entities(state = initialState, action) { ...action.meta.entities }; } + if (action.type === types.updateUserFlag) { + return { + ...state, + user: { + ...state.user, + [username]: { + ...state.user[username], + [flag]: !state.user[username][flag] + } + } + }; + } return state; } diff --git a/common/app/redux/types.js b/common/app/redux/types.js index db6d159809..3ab9197c8b 100644 --- a/common/app/redux/types.js +++ b/common/app/redux/types.js @@ -7,6 +7,7 @@ export default createTypes([ 'addUser', 'updateThisUser', 'updateUserPoints', + 'updateUserFlag', 'updateCompletedChallenges', 'showSignIn', diff --git a/common/app/routes/settings/components/Email-Setting.jsx b/common/app/routes/settings/components/Email-Setting.jsx index 418e86e145..a9332d56e4 100644 --- a/common/app/routes/settings/components/Email-Setting.jsx +++ b/common/app/routes/settings/components/Email-Setting.jsx @@ -10,6 +10,7 @@ export function UpdateEmailButton() { bsSize='lg' bsStyle='primary' className='btn-link-social' + href='/update-email' > Update my Email @@ -21,7 +22,10 @@ export default function EmailSettings({ email, sendMonthlyEmail, sendNotificationEmail, - sendQuincyEmail + sendQuincyEmail, + toggleMonthlyEmail, + toggleNotificationEmail, + toggleQuincyEmail }) { if (!email) { return ( @@ -63,6 +67,7 @@ export default function EmailSettings({ className={ classnames('positive-20', { active: sendMonthlyEmail }) } + onClick={ toggleMonthlyEmail } > { sendMonthlyEmail ? 'On' : 'Off' } @@ -84,6 +89,7 @@ export default function EmailSettings({ className={ classnames('positive-20', { active: sendNotificationEmail }) } + onClick={ toggleNotificationEmail } > { sendNotificationEmail ? 'On' : 'Off' } @@ -105,6 +111,7 @@ export default function EmailSettings({ className={ classnames('positive-20', { active: sendQuincyEmail }) } + onClick={ toggleQuincyEmail } > { sendQuincyEmail ? 'On' : 'Off' } @@ -118,5 +125,8 @@ EmailSettings.propTypes = { email: PropTypes.string, sendMonthlyEmail: PropTypes.bool, sendNotificationEmail: PropTypes.bool, - sendQuincyEmail: PropTypes.bool + sendQuincyEmail: PropTypes.bool, + toggleMonthlyEmail: PropTypes.func.isRequired, + toggleNotificationEmail: PropTypes.func.isRequired, + toggleQuincyEmail: PropTypes.func.isRequired }; diff --git a/common/app/routes/settings/components/Language-Settings.jsx b/common/app/routes/settings/components/Language-Settings.jsx index 6f907803ff..f8b11aaec1 100644 --- a/common/app/routes/settings/components/Language-Settings.jsx +++ b/common/app/routes/settings/components/Language-Settings.jsx @@ -2,7 +2,15 @@ import React, { PropTypes } from 'react'; import { FormControl } from 'react-bootstrap'; import langs from '../../../../utils/supported-languages'; -const langOptions = [ +const options = [( + + ), ...Object.keys(langs).map(tag => { return ( - ), - ...langOptions - ]; return ( { options } diff --git a/common/app/routes/settings/components/Locked-Settings.jsx b/common/app/routes/settings/components/Locked-Settings.jsx index e9dbee516f..077661d3f7 100644 --- a/common/app/routes/settings/components/Locked-Settings.jsx +++ b/common/app/routes/settings/components/Locked-Settings.jsx @@ -2,7 +2,7 @@ import React, { PropTypes } from 'react'; import { Button, Row, Col } from 'react-bootstrap'; import classnames from 'classnames'; -export default function LockSettings({ isLocked }) { +export default function LockSettings({ isLocked, toggle }) { const className = classnames({ 'positive-20': true, active: isLocked @@ -22,6 +22,7 @@ export default function LockSettings({ isLocked }) { bsSize='lg' bsStyle='primary' className={ className } + onClick={ toggle } > { isLocked ? 'On' : 'Off' } @@ -31,5 +32,6 @@ export default function LockSettings({ isLocked }) { } LockSettings.propTypes = { - isLocked: PropTypes.bool + isLocked: PropTypes.bool, + toggle: PropTypes.func.isRequired }; diff --git a/common/app/routes/settings/components/Settings.jsx b/common/app/routes/settings/components/Settings.jsx index 0ca4ad9c2f..60adfe30b3 100644 --- a/common/app/routes/settings/components/Settings.jsx +++ b/common/app/routes/settings/components/Settings.jsx @@ -1,4 +1,5 @@ import React, { PropTypes } from 'react'; +import { connect } from 'react-redux'; import { Button, Row, Col } from 'react-bootstrap'; import FA from 'react-fontawesome'; @@ -8,10 +9,58 @@ import EmailSettings from './Email-Setting.jsx'; import LangaugeSettings from './Language-Settings.jsx'; import DeleteModal from './Delete-Modal.jsx'; -export default class Settings extends React.Component { +import { + toggleUserFlag, + openDeleteModal, + hideDeleteModal +} from '../redux/actions'; +import { toggleNightMode } from '../../../redux/actions'; + +const actions = { + toggleNightMode, + openDeleteModal, + hideDeleteModal, + toggleIsLocked: () => toggleUserFlag('isLocked'), + toggleQuincyEmail: () => toggleUserFlag('sendQuincyEmail'), + toggleNotificationEmail: () => toggleUserFlag('sendNotificationEmail'), + toggleMonthlyEmail: () => toggleUserFlag('sendMonthlyEmail') +}; + +const mapStateToProps = state => { + const { + app: { user: username }, + entities: { user: userMap }, + settingsApp: { isDeleteOpen } + } = state; + const { + email, + isLocked, + isGithubCool, + isTwitter, + isLinkedIn, + sendMonthlyEmail, + sendNotificationEmail, + sendQuincyEmail + } = userMap[username] || {}; + return { + username, + email, + isDeleteOpen, + isLocked, + isGithubCool, + isTwitter, + isLinkedIn, + sendMonthlyEmail, + sendNotificationEmail, + sendQuincyEmail + }; +}; + +export class Settings extends React.Component { static displayName = 'Settings'; static propTypes = { username: PropTypes.string, + isDeleteOpen: PropTypes.bool, isLocked: PropTypes.bool, isGithubCool: PropTypes.bool, isTwitter: PropTypes.bool, @@ -19,12 +68,20 @@ export default class Settings extends React.Component { email: PropTypes.string, sendMonthlyEmail: PropTypes.bool, sendNotificationEmail: PropTypes.bool, - sendQuincyEmail: 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 }; render() { const { username, + isDeleteOpen, isLocked, isGithubCool, isTwitter, @@ -32,7 +89,14 @@ export default class Settings extends React.Component { email, sendMonthlyEmail, sendNotificationEmail, - sendQuincyEmail + sendQuincyEmail, + toggleNightMode, + toggleIsLocked, + toggleQuincyEmail, + toggleMonthlyEmail, + toggleNotificationEmail, + openDeleteModal, + hideDeleteModal } = this.props; return (
@@ -77,6 +141,7 @@ export default class Settings extends React.Component { bsSize='lg' bsStyle='primary' className='btn-link-social' + onClick={ toggleNightMode } > NightMode @@ -116,7 +181,10 @@ export default class Settings extends React.Component { smOffset={ 2 } xs={ 12 } > - +
@@ -134,6 +202,9 @@ export default class Settings extends React.Component { sendMonthlyEmail={ sendMonthlyEmail } sendNotificationEmail={ sendNotificationEmail } sendQuincyEmail={ sendQuincyEmail } + toggleMonthlyEmail={ toggleMonthlyEmail } + toggleNotificationEmail={ toggleNotificationEmail } + toggleQuincyEmail={ toggleQuincyEmail } /> @@ -160,7 +231,11 @@ export default class Settings extends React.Component { smOffset={ 2 } xs={ 12 } > - +
@@ -168,3 +243,4 @@ export default class Settings extends React.Component { } } +export default connect(mapStateToProps, actions)(Settings); diff --git a/common/app/routes/settings/components/Social-Settings.jsx b/common/app/routes/settings/components/Social-Settings.jsx index 53a9414d59..8d9139a888 100644 --- a/common/app/routes/settings/components/Social-Settings.jsx +++ b/common/app/routes/settings/components/Social-Settings.jsx @@ -45,7 +45,7 @@ export default function SocialSettings({ href='/link/linkedin' key='linkedin' > - + Add my LinkedIn to my portfolio )); diff --git a/common/app/routes/settings/redux/actions.js b/common/app/routes/settings/redux/actions.js new file mode 100644 index 0000000000..9023184ae2 --- /dev/null +++ b/common/app/routes/settings/redux/actions.js @@ -0,0 +1,30 @@ +import { createAction, handleActions } from 'redux-actions'; + +import createTypes from '../../../utils/create-types'; + +const initialState = { + showDeleteModal: false +}; +export const types = createTypes([ + 'toggleUserFlag', + 'openDeleteModal', + 'hideDeleteModal' +], 'settings'); + +export const toggleUserFlag = createAction(types.toggleUserFlag); +export const openDeleteModal = createAction(types.openDeleteModal); +export const hideDeleteModal = createAction(types.hideDeleteModal); + +export default handleActions( + { + [openDeleteModal]: state => ({ + ...state, + isDeleteOpen: true + }), + [hideDeleteModal]: state => ({ + ...state, + isDeleteOpen: false + }) + }, + initialState +); diff --git a/common/app/routes/settings/redux/index.js b/common/app/routes/settings/redux/index.js new file mode 100644 index 0000000000..3d1b99889f --- /dev/null +++ b/common/app/routes/settings/redux/index.js @@ -0,0 +1,9 @@ +import userUpdateSaga from './update-user-saga'; + +export { types } from './actions'; +export * as actions from './actions'; +export { default as reducer } from './actions'; + +export const sagas = [ + userUpdateSaga +]; diff --git a/common/app/routes/settings/redux/update-user-saga.js b/common/app/routes/settings/redux/update-user-saga.js new file mode 100644 index 0000000000..2994a9c0ae --- /dev/null +++ b/common/app/routes/settings/redux/update-user-saga.js @@ -0,0 +1,40 @@ +import { Observable } from 'rx'; +import { types } from './actions'; +import { postJSON$ } from '../../../../utils/ajax-stream'; +import { updateUserFlag, createErrorObservable } from '../../../redux/actions'; + +const urlMap = { + isLocked: 'lockdown', + sendQuincyEmail: 'quincy-email', + sendNotificationEmail: 'notification-email', + sendMonthlyEmail: 'announcement-email' +}; +export default function userUpdateSaga(actions$, getState) { + const toggleFlag$ = actions$ + .filter(({ type, payload }) => type === types.toggleUserFlag && payload) + .map(({ payload }) => payload); + const optimistic$ = toggleFlag$.map(flag => { + const { app: { user: username } } = getState(); + return updateUserFlag(username, flag); + }); + const serverUpdate$ = toggleFlag$ + .debounce(500) + .flatMap(flag => { + const url = `/toggle-${urlMap[ flag ]}`; + const { + app: { user: username, csrfToken: _csrf }, + entities: { user: userMap } + } = getState(); + const user = userMap[username]; + const currentValue = user[ flag ]; + return postJSON$(url, { _csrf }) + .map(({ flag, value }) => { + if (currentValue === value) { + return null; + } + return updateUserFlag(username, flag); + }) + .catch(createErrorObservable); + }); + return Observable.merge(optimistic$, serverUpdate$); +} diff --git a/common/app/sagas.js b/common/app/sagas.js index 3b668202f9..f5e1cbf011 100644 --- a/common/app/sagas.js +++ b/common/app/sagas.js @@ -1,7 +1,9 @@ import { sagas as appSagas } from './redux'; import { sagas as challengeSagas } from './routes/challenges/redux'; +import { sagas as settingsSagas } from './routes/settings/redux'; export default [ ...appSagas, - ...challengeSagas + ...challengeSagas, + ...settingsSagas ]; diff --git a/server/boot/settings.js b/server/boot/settings.js new file mode 100644 index 0000000000..680b4d8022 --- /dev/null +++ b/server/boot/settings.js @@ -0,0 +1,38 @@ +import { ifNoUser401 } from '../utils/middleware'; + +export default function settingsController(app) { + const api = app.loopback.Router(); + const toggleUserFlag = flag => (req, res, next) => { + const { user } = req; + const currentValue = user[ flag ]; + return user + .update$({ [ flag ]: !currentValue }) + .subscribe( + () => res.status(200).json({ + flag, + value: !currentValue + }), + next + ); + }; + api.post( + '/toggle-lockdown', + toggleUserFlag('isLocked') + ); + api.post( + '/toggle-announcement-email', + ifNoUser401, + toggleUserFlag('sendMonthlyEmail') + ); + api.post( + '/toggle-notification-email', + ifNoUser401, + toggleUserFlag('sendNotificationEmail') + ); + api.post( + '/toggle-quincy-email', + ifNoUser401, + toggleUserFlag('sendQuincyEmail') + ); + app.use(api); +} diff --git a/server/boot/user.js b/server/boot/user.js index b8c179e7fe..8c134e7a7c 100644 --- a/server/boot/user.js +++ b/server/boot/user.js @@ -155,26 +155,6 @@ module.exports = function(app) { router.get('/email-signin', getEmailSignin); router.get('/deprecated-signin', getDepSignin); router.get('/update-email', getUpdateEmail); - api.get( - '/toggle-lockdown-mode', - sendNonUserToMap, - toggleLockdownMode - ); - api.get( - '/toggle-announcement-email-mode', - sendNonUserToMap, - toggleReceivesAnnouncementEmails - ); - api.get( - '/toggle-notification-email-mode', - sendNonUserToMap, - toggleReceivesNotificationEmails - ); - api.get( - '/toggle-quincy-email-mode', - sendNonUserToMap, - toggleReceivesQuincyEmails - ); api.post( '/account/delete', ifNoUser401, @@ -434,62 +414,6 @@ module.exports = function(app) { ); } - function toggleLockdownMode(req, res, next) { - const { user } = req; - user.update$({ isLocked: !user.isLocked }) - .subscribe( - () => { - req.flash('info', { - msg: 'We\'ve successfully updated your Privacy preferences.' - }); - return res.redirect('/settings'); - }, - next - ); - } - - function toggleReceivesAnnouncementEmails(req, res, next) { - const { user } = req; - return user.update$({ sendMonthlyEmail: !user.sendMonthlyEmail }) - .subscribe( - () => { - req.flash('info', { - msg: 'We\'ve successfully updated your Email preferences.' - }); - return res.redirect('/settings'); - }, - next - ); - } - - function toggleReceivesQuincyEmails(req, res, next) { - const { user } = req; - return user.update$({ sendQuincyEmail: !user.sendQuincyEmail }) - .subscribe( - () => { - req.flash('info', { - msg: 'We\'ve successfully updated your Email preferences.' - }); - return res.redirect('/settings'); - }, - next - ); - } - - function toggleReceivesNotificationEmails(req, res, next) { - const { user } = req; - return user.update$({ sendNotificationEmail: !user.sendNotificationEmail }) - .subscribe( - () => { - req.flash('info', { - msg: 'We\'ve successfully updated your Email preferences.' - }); - return res.redirect('/settings'); - }, - next - ); - } - function postDeleteAccount(req, res, next) { User.destroyById(req.user.id, function(err) { if (err) { return next(err); } diff --git a/server/services/user.js b/server/services/user.js index 378bc6e00f..a094517ecb 100644 --- a/server/services/user.js +++ b/server/services/user.js @@ -9,17 +9,23 @@ const publicUserProps = [ 'theme', 'picture', 'points', + 'email', 'languageTag', 'isCheater', 'isGithubCool', + 'isLocked', 'isFrontEndCert', 'isBackEndCert', 'isDataVisCert', 'isFullStackCert', 'githubURL', + 'sendMonthlyEmail', + 'sendNotificationEmail', + 'sendQuincyEmail', + 'currentChallenge', 'challengeMap' ]; @@ -40,7 +46,11 @@ export default function userServices() { { entities: { user: { - [user.username]: _.pick(user, publicUserProps) + [user.username]: { + ..._.pick(user, publicUserProps), + isTwitter: !!user.twitter, + isLinkedIn: !!user.linkedIn + } } }, result: user.username