Feature(settings): add user flag logic

This also moves a couple of settings to their own controller
This commit is contained in:
Berkeley Martinez
2016-07-16 10:38:06 -07:00
parent 991d86558a
commit 0d3dd75f41
16 changed files with 261 additions and 101 deletions

View File

@ -8,6 +8,7 @@ import {
reducer as challengesApp, reducer as challengesApp,
projectNormalizer projectNormalizer
} from './routes/challenges/redux'; } from './routes/challenges/redux';
import { reducer as settingsApp } from './routes/settings/redux';
export default function createReducer(sideReducers = {}) { export default function createReducer(sideReducers = {}) {
return combineReducers({ return combineReducers({
@ -16,6 +17,7 @@ export default function createReducer(sideReducers = {}) {
app, app,
toasts, toasts,
challengesApp, challengesApp,
settingsApp,
form: formReducer.normalize({ ...projectNormalizer }) form: formReducer.normalize({ ...projectNormalizer })
}); });
} }

View File

@ -25,6 +25,11 @@ export const updateUserPoints = createAction(
types.updateUserPoints, types.updateUserPoints,
(username, points) => ({ username, points }) (username, points) => ({ username, points })
); );
// updateUserPoints(username: String, flag: String) => Action
export const updateUserFlag = createAction(
types.updateUserFlag,
(username, flag) => ({ username, flag })
);
// updateCompletedChallenges(username: String) => Action // updateCompletedChallenges(username: String) => Action
export const updateCompletedChallenges = createAction( export const updateCompletedChallenges = createAction(
types.updateCompletedChallenges types.updateCompletedChallenges

View File

@ -9,7 +9,7 @@ const initialState = {
}; };
export default function entities(state = initialState, action) { export default function entities(state = initialState, action) {
const { type, payload: { username, points } = {} } = action; const { type, payload: { username, points, flag } = {} } = 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 || {};
@ -44,5 +44,17 @@ export default function entities(state = initialState, action) {
...action.meta.entities ...action.meta.entities
}; };
} }
if (action.type === types.updateUserFlag) {
return {
...state,
user: {
...state.user,
[username]: {
...state.user[username],
[flag]: !state.user[username][flag]
}
}
};
}
return state; return state;
} }

View File

@ -7,6 +7,7 @@ export default createTypes([
'addUser', 'addUser',
'updateThisUser', 'updateThisUser',
'updateUserPoints', 'updateUserPoints',
'updateUserFlag',
'updateCompletedChallenges', 'updateCompletedChallenges',
'showSignIn', 'showSignIn',

View File

@ -10,6 +10,7 @@ export function UpdateEmailButton() {
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
@ -21,7 +22,10 @@ export default function EmailSettings({
email, email,
sendMonthlyEmail, sendMonthlyEmail,
sendNotificationEmail, sendNotificationEmail,
sendQuincyEmail sendQuincyEmail,
toggleMonthlyEmail,
toggleNotificationEmail,
toggleQuincyEmail
}) { }) {
if (!email) { if (!email) {
return ( return (
@ -63,6 +67,7 @@ export default function EmailSettings({
className={ className={
classnames('positive-20', { active: sendMonthlyEmail }) classnames('positive-20', { active: sendMonthlyEmail })
} }
onClick={ toggleMonthlyEmail }
> >
{ sendMonthlyEmail ? 'On' : 'Off' } { sendMonthlyEmail ? 'On' : 'Off' }
</Button> </Button>
@ -84,6 +89,7 @@ export default function EmailSettings({
className={ className={
classnames('positive-20', { active: sendNotificationEmail }) classnames('positive-20', { active: sendNotificationEmail })
} }
onClick={ toggleNotificationEmail }
> >
{ sendNotificationEmail ? 'On' : 'Off' } { sendNotificationEmail ? 'On' : 'Off' }
</Button> </Button>
@ -105,6 +111,7 @@ export default function EmailSettings({
className={ className={
classnames('positive-20', { active: sendQuincyEmail }) classnames('positive-20', { active: sendQuincyEmail })
} }
onClick={ toggleQuincyEmail }
> >
{ sendQuincyEmail ? 'On' : 'Off' } { sendQuincyEmail ? 'On' : 'Off' }
</Button> </Button>
@ -118,5 +125,8 @@ EmailSettings.propTypes = {
email: PropTypes.string, email: PropTypes.string,
sendMonthlyEmail: PropTypes.bool, sendMonthlyEmail: PropTypes.bool,
sendNotificationEmail: PropTypes.bool, sendNotificationEmail: PropTypes.bool,
sendQuincyEmail: PropTypes.bool sendQuincyEmail: PropTypes.bool,
toggleMonthlyEmail: PropTypes.func.isRequired,
toggleNotificationEmail: PropTypes.func.isRequired,
toggleQuincyEmail: PropTypes.func.isRequired
}; };

View File

@ -2,7 +2,15 @@ import React, { PropTypes } from 'react';
import { FormControl } from 'react-bootstrap'; import { FormControl } from 'react-bootstrap';
import langs from '../../../../utils/supported-languages'; import langs from '../../../../utils/supported-languages';
const langOptions = [ const options = [(
<option
disabled={ true }
key='default'
value='not-the-momma'
>
Prefered Langauge
</option>
),
...Object.keys(langs).map(tag => { ...Object.keys(langs).map(tag => {
return ( return (
<option <option
@ -23,21 +31,12 @@ const langOptions = [
]; ];
export default function LangaugeSettings({ userLang }) { export default function LangaugeSettings({ userLang }) {
const options = [(
<option
disabled={ true }
key='default'
selected={ userLang ? false : true }
>
Prefered Langauge
</option>
),
...langOptions
];
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'
value={ userLang }
> >
{ options } { options }
</FormControl> </FormControl>

View File

@ -2,7 +2,7 @@ import React, { PropTypes } from 'react';
import { Button, Row, Col } from 'react-bootstrap'; import { Button, Row, Col } from 'react-bootstrap';
import classnames from 'classnames'; import classnames from 'classnames';
export default function LockSettings({ isLocked }) { export default function LockSettings({ isLocked, toggle }) {
const className = classnames({ const className = classnames({
'positive-20': true, 'positive-20': true,
active: isLocked active: isLocked
@ -22,6 +22,7 @@ export default function LockSettings({ isLocked }) {
bsSize='lg' bsSize='lg'
bsStyle='primary' bsStyle='primary'
className={ className } className={ className }
onClick={ toggle }
> >
{ isLocked ? 'On' : 'Off' } { isLocked ? 'On' : 'Off' }
</Button> </Button>
@ -31,5 +32,6 @@ export default function LockSettings({ isLocked }) {
} }
LockSettings.propTypes = { LockSettings.propTypes = {
isLocked: PropTypes.bool isLocked: PropTypes.bool,
toggle: PropTypes.func.isRequired
}; };

View File

@ -1,4 +1,5 @@
import React, { PropTypes } from 'react'; import React, { PropTypes } from 'react';
import { connect } from 'react-redux';
import { Button, Row, Col } from 'react-bootstrap'; import { Button, Row, Col } from 'react-bootstrap';
import FA from 'react-fontawesome'; import FA from 'react-fontawesome';
@ -8,10 +9,58 @@ import EmailSettings from './Email-Setting.jsx';
import LangaugeSettings from './Language-Settings.jsx'; import LangaugeSettings from './Language-Settings.jsx';
import DeleteModal from './Delete-Modal.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 displayName = 'Settings';
static propTypes = { static propTypes = {
username: PropTypes.string, username: PropTypes.string,
isDeleteOpen: PropTypes.bool,
isLocked: PropTypes.bool, isLocked: PropTypes.bool,
isGithubCool: PropTypes.bool, isGithubCool: PropTypes.bool,
isTwitter: PropTypes.bool, isTwitter: PropTypes.bool,
@ -19,12 +68,20 @@ export default class Settings extends React.Component {
email: PropTypes.string, email: PropTypes.string,
sendMonthlyEmail: PropTypes.bool, sendMonthlyEmail: PropTypes.bool,
sendNotificationEmail: 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() { render() {
const { const {
username, username,
isDeleteOpen,
isLocked, isLocked,
isGithubCool, isGithubCool,
isTwitter, isTwitter,
@ -32,7 +89,14 @@ export default class Settings extends React.Component {
email, email,
sendMonthlyEmail, sendMonthlyEmail,
sendNotificationEmail, sendNotificationEmail,
sendQuincyEmail sendQuincyEmail,
toggleNightMode,
toggleIsLocked,
toggleQuincyEmail,
toggleMonthlyEmail,
toggleNotificationEmail,
openDeleteModal,
hideDeleteModal
} = this.props; } = this.props;
return ( return (
<div> <div>
@ -77,6 +141,7 @@ export default class Settings extends React.Component {
bsSize='lg' bsSize='lg'
bsStyle='primary' bsStyle='primary'
className='btn-link-social' className='btn-link-social'
onClick={ toggleNightMode }
> >
NightMode NightMode
</Button> </Button>
@ -116,7 +181,10 @@ export default class Settings extends React.Component {
smOffset={ 2 } smOffset={ 2 }
xs={ 12 } xs={ 12 }
> >
<LockedSettings isLocked={ isLocked } /> <LockedSettings
isLocked={ isLocked }
toggle={ toggleIsLocked }
/>
</Col> </Col>
</Row> </Row>
<div className='spacer' /> <div className='spacer' />
@ -134,6 +202,9 @@ export default class Settings extends React.Component {
sendMonthlyEmail={ sendMonthlyEmail } sendMonthlyEmail={ sendMonthlyEmail }
sendNotificationEmail={ sendNotificationEmail } sendNotificationEmail={ sendNotificationEmail }
sendQuincyEmail={ sendQuincyEmail } sendQuincyEmail={ sendQuincyEmail }
toggleMonthlyEmail={ toggleMonthlyEmail }
toggleNotificationEmail={ toggleNotificationEmail }
toggleQuincyEmail={ toggleQuincyEmail }
/> />
</Col> </Col>
</Row> </Row>
@ -160,7 +231,11 @@ export default class Settings extends React.Component {
smOffset={ 2 } smOffset={ 2 }
xs={ 12 } xs={ 12 }
> >
<DeleteModal /> <DeleteModal
hide={ hideDeleteModal }
isOpen={ isDeleteOpen }
open={ openDeleteModal }
/>
</Col> </Col>
</Row> </Row>
</div> </div>
@ -168,3 +243,4 @@ export default class Settings extends React.Component {
} }
} }
export default connect(mapStateToProps, actions)(Settings);

View File

@ -45,7 +45,7 @@ export default function SocialSettings({
href='/link/linkedin' href='/link/linkedin'
key='linkedin' key='linkedin'
> >
<FA name='linked' /> <FA name='linkedin' />
Add my LinkedIn to my portfolio Add my LinkedIn to my portfolio
</Button> </Button>
)); ));

View File

@ -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
);

View File

@ -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
];

View File

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

View File

@ -1,7 +1,9 @@
import { sagas as appSagas } from './redux'; import { sagas as appSagas } from './redux';
import { sagas as challengeSagas } from './routes/challenges/redux'; import { sagas as challengeSagas } from './routes/challenges/redux';
import { sagas as settingsSagas } from './routes/settings/redux';
export default [ export default [
...appSagas, ...appSagas,
...challengeSagas ...challengeSagas,
...settingsSagas
]; ];

38
server/boot/settings.js Normal file
View File

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

View File

@ -155,26 +155,6 @@ module.exports = function(app) {
router.get('/email-signin', getEmailSignin); router.get('/email-signin', getEmailSignin);
router.get('/deprecated-signin', getDepSignin); router.get('/deprecated-signin', getDepSignin);
router.get('/update-email', getUpdateEmail); 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( api.post(
'/account/delete', '/account/delete',
ifNoUser401, 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) { function postDeleteAccount(req, res, next) {
User.destroyById(req.user.id, function(err) { User.destroyById(req.user.id, function(err) {
if (err) { return next(err); } if (err) { return next(err); }

View File

@ -9,17 +9,23 @@ const publicUserProps = [
'theme', 'theme',
'picture', 'picture',
'points', 'points',
'email',
'languageTag', 'languageTag',
'isCheater', 'isCheater',
'isGithubCool', 'isGithubCool',
'isLocked',
'isFrontEndCert', 'isFrontEndCert',
'isBackEndCert', 'isBackEndCert',
'isDataVisCert', 'isDataVisCert',
'isFullStackCert', 'isFullStackCert',
'githubURL', 'githubURL',
'sendMonthlyEmail',
'sendNotificationEmail',
'sendQuincyEmail',
'currentChallenge', 'currentChallenge',
'challengeMap' 'challengeMap'
]; ];
@ -40,7 +46,11 @@ export default function userServices() {
{ {
entities: { entities: {
user: { user: {
[user.username]: _.pick(user, publicUserProps) [user.username]: {
..._.pick(user, publicUserProps),
isTwitter: !!user.twitter,
isLinkedIn: !!user.linkedIn
}
} }
}, },
result: user.username result: user.username