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

View File

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

View File

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

View File

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

View File

@ -10,6 +10,7 @@ export function UpdateEmailButton() {
bsSize='lg'
bsStyle='primary'
className='btn-link-social'
href='/update-email'
>
<FA name='envelope' />
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' }
</Button>
@ -84,6 +89,7 @@ export default function EmailSettings({
className={
classnames('positive-20', { active: sendNotificationEmail })
}
onClick={ toggleNotificationEmail }
>
{ sendNotificationEmail ? 'On' : 'Off' }
</Button>
@ -105,6 +111,7 @@ export default function EmailSettings({
className={
classnames('positive-20', { active: sendQuincyEmail })
}
onClick={ toggleQuincyEmail }
>
{ sendQuincyEmail ? 'On' : 'Off' }
</Button>
@ -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
};

View File

@ -2,7 +2,15 @@ import React, { PropTypes } from 'react';
import { FormControl } from 'react-bootstrap';
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 => {
return (
<option
@ -23,21 +31,12 @@ const langOptions = [
];
export default function LangaugeSettings({ userLang }) {
const options = [(
<option
disabled={ true }
key='default'
selected={ userLang ? false : true }
>
Prefered Langauge
</option>
),
...langOptions
];
return (
<FormControl
className='btn btn-block btn-primary btn-link-social'
componentClass='select'
defaultValue='not-the-momma'
value={ userLang }
>
{ options }
</FormControl>

View File

@ -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' }
</Button>
@ -31,5 +32,6 @@ export default function LockSettings({ isLocked }) {
}
LockSettings.propTypes = {
isLocked: PropTypes.bool
isLocked: PropTypes.bool,
toggle: PropTypes.func.isRequired
};

View File

@ -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 (
<div>
@ -77,6 +141,7 @@ export default class Settings extends React.Component {
bsSize='lg'
bsStyle='primary'
className='btn-link-social'
onClick={ toggleNightMode }
>
NightMode
</Button>
@ -116,7 +181,10 @@ export default class Settings extends React.Component {
smOffset={ 2 }
xs={ 12 }
>
<LockedSettings isLocked={ isLocked } />
<LockedSettings
isLocked={ isLocked }
toggle={ toggleIsLocked }
/>
</Col>
</Row>
<div className='spacer' />
@ -134,6 +202,9 @@ export default class Settings extends React.Component {
sendMonthlyEmail={ sendMonthlyEmail }
sendNotificationEmail={ sendNotificationEmail }
sendQuincyEmail={ sendQuincyEmail }
toggleMonthlyEmail={ toggleMonthlyEmail }
toggleNotificationEmail={ toggleNotificationEmail }
toggleQuincyEmail={ toggleQuincyEmail }
/>
</Col>
</Row>
@ -160,7 +231,11 @@ export default class Settings extends React.Component {
smOffset={ 2 }
xs={ 12 }
>
<DeleteModal />
<DeleteModal
hide={ hideDeleteModal }
isOpen={ isDeleteOpen }
open={ openDeleteModal }
/>
</Col>
</Row>
</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'
key='linkedin'
>
<FA name='linked' />
<FA name='linkedin' />
Add my LinkedIn to my portfolio
</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 challengeSagas } from './routes/challenges/redux';
import { sagas as settingsSagas } from './routes/settings/redux';
export default [
...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('/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); }

View File

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