feat(settings): Expand Settings page functionality (#16664)
* fix(layout): Fix Settings layout in firefox * chore(availableForHire): Remove available for hire setting * feat(helpers): Use helper components for Settings layout * fix(map): Fix undefined lang requested * feat(settings): Expand Settings page functionality * chore(pledge): Remove pledge from Settings * fix(about): Adjust AboutSettings layout * fix(portfolio): Improve PortfolioSettings layout * fix(email): Improve EmailSettings layout * fix(settings): Align save buttons with form fields * fix(AHP): Format AHP * fix(DangerZone): Adjust DangerZone layout * fix(projectSettings): Change Button Copy * fix(CertSettings): Fix certificate claim logic * chore(lint): Lint
This commit is contained in:
committed by
Quincy Larson
parent
9f034f4f79
commit
24ef69cf7a
@@ -37,7 +37,7 @@ export default handleActions(
|
||||
[types.makeToast]: (state, { payload: toast }) => [
|
||||
...state,
|
||||
toast
|
||||
],
|
||||
].filter(toast => !!toast.message),
|
||||
[types.removeToast]: (state, { payload: key }) => state.filter(
|
||||
toast => toast.key !== key
|
||||
)
|
||||
|
@@ -7,7 +7,7 @@
|
||||
// Here we invert the order in which
|
||||
// they are painted using css so the
|
||||
// nav is on top again
|
||||
.grid(@direction: column);
|
||||
.grid(@direction: column; @wrap: nowrap);
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import _ from 'lodash';
|
||||
import { findIndex, invert, pick, property } from 'lodash';
|
||||
import uuid from 'uuid/v4';
|
||||
import {
|
||||
composeReducers,
|
||||
createAction,
|
||||
@@ -8,11 +9,16 @@ import {
|
||||
|
||||
import { themes } from '../../utils/themes';
|
||||
import { types as challenges } from '../routes/Challenges/redux';
|
||||
import { usernameSelector } from '../redux';
|
||||
|
||||
export const ns = 'entities';
|
||||
export const getNS = state => state[ns];
|
||||
export const entitiesSelector = getNS;
|
||||
export const types = createTypes([
|
||||
'addPortfolioItem',
|
||||
'optoUpdatePortfolio',
|
||||
'regresPortfolio',
|
||||
'updateMultipleUserFlags',
|
||||
'updateTheme',
|
||||
'updateUserFlag',
|
||||
'updateUserEmail',
|
||||
@@ -20,6 +26,18 @@ export const types = createTypes([
|
||||
'updateUserCurrentChallenge'
|
||||
], ns);
|
||||
|
||||
// addPortfolioItem(...PortfolioItem) => Action
|
||||
export const addPortfolioItem = createAction(types.addPortfolioItem);
|
||||
// optoUpdatePortfolio(...PortfolioItem) => Action
|
||||
export const optoUpdatePortfolio = createAction(types.optoUpdatePortfolio);
|
||||
// regresPortfolio(id: String) => Action
|
||||
export const regresPortfolio = createAction(types.regresPortfolio);
|
||||
|
||||
// updateMultipleUserFlags({ username: String, flags: { String }) => Action
|
||||
export const updateMultipleUserFlags = createAction(
|
||||
types.updateMultipleUserFlags
|
||||
);
|
||||
|
||||
// updateUserFlag(username: String, flag: String) => Action
|
||||
export const updateUserFlag = createAction(
|
||||
types.updateUserFlag,
|
||||
@@ -41,7 +59,8 @@ export const updateUserCurrentChallenge = createAction(
|
||||
);
|
||||
|
||||
// entity meta creators
|
||||
const getEntityAction = _.property('meta.entitiesAction');
|
||||
const getEntityAction = property('meta.entitiesAction');
|
||||
|
||||
export const updateThemeMetacreator = (username, theme) => ({
|
||||
entitiesAction: {
|
||||
type: types.updateTheme,
|
||||
@@ -52,6 +71,16 @@ export const updateThemeMetacreator = (username, theme) => ({
|
||||
}
|
||||
});
|
||||
|
||||
export function emptyPortfolio() {
|
||||
return {
|
||||
id: uuid(),
|
||||
title: '',
|
||||
description: '',
|
||||
url: '',
|
||||
image: ''
|
||||
};
|
||||
}
|
||||
|
||||
const defaultState = {
|
||||
superBlock: {},
|
||||
block: {},
|
||||
@@ -59,13 +88,56 @@ const defaultState = {
|
||||
user: {}
|
||||
};
|
||||
|
||||
export function selectiveChallengeTitleSelector(state, dashedName) {
|
||||
return getNS(state).challenge[dashedName].title;
|
||||
}
|
||||
|
||||
export function portfolioSelector(state, props) {
|
||||
const username = usernameSelector(state);
|
||||
const { portfolio } = getNS(state).user[username];
|
||||
const pIndex = findIndex(portfolio, p => p.id === props.id);
|
||||
return portfolio[pIndex];
|
||||
}
|
||||
|
||||
export function projectsSelector(state) {
|
||||
const blocks = getNS(state).block;
|
||||
const challengeNameToIdMap = invert(challengeIdToNameMapSelector(state));
|
||||
return Object.keys(blocks)
|
||||
.filter(key =>
|
||||
key.includes('projects') && !key.includes('coding-interview')
|
||||
)
|
||||
.map(key => blocks[key])
|
||||
.map(({ name, challenges, superBlock }) => {
|
||||
const projectChallengeDashNames = challenges
|
||||
// remove any project intros
|
||||
.filter(chal => !chal.includes('get-set-for'));
|
||||
const projectChallenges = projectChallengeDashNames
|
||||
.map(dashedName => selectiveChallengeTitleSelector(state, dashedName));
|
||||
return {
|
||||
projectBlockName: name,
|
||||
superBlock,
|
||||
challenges: projectChallenges,
|
||||
challengeNameIdMap: pick(
|
||||
challengeNameToIdMap,
|
||||
projectChallengeDashNames
|
||||
)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function challengeIdToNameMapSelector(state) {
|
||||
return getNS(state).challengeIdToName || {};
|
||||
}
|
||||
|
||||
export const challengeMapSelector = state => getNS(state).challenge || {};
|
||||
|
||||
export function makeBlockSelector(block) {
|
||||
return state => {
|
||||
const blockMap = getNS(state).block || {};
|
||||
return blockMap[block] || {};
|
||||
};
|
||||
}
|
||||
|
||||
export function makeSuperBlockSelector(name) {
|
||||
return state => {
|
||||
const superBlock = getNS(state).superBlock || {};
|
||||
@@ -121,6 +193,61 @@ export default composeReducers(
|
||||
}
|
||||
}
|
||||
}),
|
||||
[types.addPortfolioItem]: (state, { payload: username }) => ({
|
||||
...state,
|
||||
user: {
|
||||
...state.user,
|
||||
[username]: {
|
||||
...state.user[username],
|
||||
portfolio: [
|
||||
...state.user[username].portfolio,
|
||||
emptyPortfolio()
|
||||
]
|
||||
}
|
||||
}
|
||||
}),
|
||||
[types.optoUpdatePortfolio]: (
|
||||
state,
|
||||
{ payload: { username, portfolio }}
|
||||
) => {
|
||||
const currentPortfolio = state.user[username].portfolio.slice(0);
|
||||
const pIndex = findIndex(currentPortfolio, p => p.id === portfolio.id);
|
||||
const updatedPortfolio = currentPortfolio;
|
||||
updatedPortfolio[pIndex] = portfolio;
|
||||
return {
|
||||
...state,
|
||||
user: {
|
||||
...state.user,
|
||||
[username]: {
|
||||
...state.user[username],
|
||||
portfolio: updatedPortfolio
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
[types.regresPortfolio]: (state, { payload: { username, id } }) => ({
|
||||
...state,
|
||||
user: {
|
||||
...state.user,
|
||||
[username]: {
|
||||
...state.user[username],
|
||||
portfolio: state.user[username].portfolio.filter(p => p.id !== id)
|
||||
}
|
||||
}
|
||||
}),
|
||||
[types.updateMultipleUserFlags]: (
|
||||
state,
|
||||
{ payload: { username, flags }}
|
||||
) => ({
|
||||
...state,
|
||||
user: {
|
||||
...state.user,
|
||||
[username]: {
|
||||
...state.user[username],
|
||||
...flags
|
||||
}
|
||||
}
|
||||
}),
|
||||
[types.updateUserFlag]: (state, { payload: { username, flag } }) => ({
|
||||
...state,
|
||||
user: {
|
||||
|
11
common/app/helperComponents/ButtonSpacer.jsx
Normal file
11
common/app/helperComponents/ButtonSpacer.jsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
|
||||
function ButtonSpacer() {
|
||||
return (
|
||||
<div className='button-spacer' />
|
||||
);
|
||||
}
|
||||
|
||||
ButtonSpacer.displayName = 'ButtonSpacer';
|
||||
|
||||
export default ButtonSpacer;
|
20
common/app/helperComponents/FullWidthRow.jsx
Normal file
20
common/app/helperComponents/FullWidthRow.jsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Row, Col } from 'react-bootstrap';
|
||||
|
||||
function FullWidthRow({ children }) {
|
||||
return (
|
||||
<Row>
|
||||
<Col sm={ 8 } smOffset={ 2 } xs={ 12 }>
|
||||
{ children }
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
FullWidthRow.displayName = 'FullWidthRow';
|
||||
FullWidthRow.propTypes = {
|
||||
children: PropTypes.any
|
||||
};
|
||||
|
||||
export default FullWidthRow;
|
23
common/app/helperComponents/Loader.jsx
Normal file
23
common/app/helperComponents/Loader.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import Helmet from 'react-helmet';
|
||||
|
||||
function Loader() {
|
||||
return (
|
||||
<div className='full-size'>
|
||||
<Helmet>
|
||||
<link href='/css/loader.css' rel='stylesheet' />
|
||||
</Helmet>
|
||||
<div className='loader full-size'>
|
||||
<div className='ball-scale-ripple-multiple'>
|
||||
<div/>
|
||||
<div/>
|
||||
<div/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Loader.displayName = 'Loader';
|
||||
|
||||
export default Loader;
|
11
common/app/helperComponents/Spacer.jsx
Normal file
11
common/app/helperComponents/Spacer.jsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
|
||||
function Spacer() {
|
||||
return (
|
||||
<div className='spacer' />
|
||||
);
|
||||
}
|
||||
|
||||
Spacer.displayName = 'Spacer';
|
||||
|
||||
export default Spacer;
|
4
common/app/helperComponents/index.js
Normal file
4
common/app/helperComponents/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as FullWidthRow } from './FullWidthRow.jsx';
|
||||
export { default as Loader } from './Loader.jsx';
|
||||
export { default as Spacer } from './Spacer.jsx';
|
||||
export { default as ButtonSpacer } from './ButtonSpacer.jsx';
|
@@ -54,11 +54,14 @@ export function fetchChallengesEpic(
|
||||
{ getState },
|
||||
{ services }
|
||||
) {
|
||||
return actions::ofType(types.appMounted)
|
||||
return actions::ofType(
|
||||
types.appMounted,
|
||||
types.updateChallenges
|
||||
)
|
||||
.flatMapLatest(() => {
|
||||
const lang = langSelector(getState());
|
||||
const options = {
|
||||
lang,
|
||||
params: { lang },
|
||||
service: 'map'
|
||||
};
|
||||
return services.readService$(options)
|
||||
|
@@ -40,7 +40,7 @@ export const types = createTypes([
|
||||
|
||||
createAsyncTypes('fetchChallenge'),
|
||||
createAsyncTypes('fetchChallenges'),
|
||||
|
||||
'updateChallenges',
|
||||
createAsyncTypes('fetchUser'),
|
||||
'showSignIn',
|
||||
|
||||
@@ -108,7 +108,7 @@ export const fetchChallengesCompleted = createAction(
|
||||
(entities, result) => ({ entities, result }),
|
||||
entities => ({ entities })
|
||||
);
|
||||
|
||||
export const updateChallenges = createAction(types.updateChallenges);
|
||||
// updateTitle(title: String) => Action
|
||||
export const updateTitle = createAction(types.updateTitle);
|
||||
|
||||
|
@@ -1,126 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { Button, Row, Col } from 'react-bootstrap';
|
||||
import TB from './Toggle-Button';
|
||||
import FA from 'react-fontawesome';
|
||||
|
||||
import ns from './ns.json';
|
||||
import { onRouteUpdateEmail } from './redux';
|
||||
import { Link } from '../../Router';
|
||||
|
||||
const propTypes = {
|
||||
email: PropTypes.string,
|
||||
sendMonthlyEmail: PropTypes.bool,
|
||||
sendNotificationEmail: PropTypes.bool,
|
||||
sendQuincyEmail: PropTypes.bool,
|
||||
toggleMonthlyEmail: PropTypes.func.isRequired,
|
||||
toggleNotificationEmail: PropTypes.func.isRequired,
|
||||
toggleQuincyEmail: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export function UpdateEmailButton() {
|
||||
return (
|
||||
<Link
|
||||
style={{ textDecoration: 'none' }}
|
||||
to={ onRouteUpdateEmail() }
|
||||
>
|
||||
<Button
|
||||
block={ true }
|
||||
bsSize='lg'
|
||||
bsStyle='primary'
|
||||
className='btn-link-social'
|
||||
>
|
||||
<FA name='envelope' />
|
||||
Update my Email
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EmailSettings({
|
||||
email,
|
||||
sendMonthlyEmail,
|
||||
sendNotificationEmail,
|
||||
sendQuincyEmail,
|
||||
toggleMonthlyEmail,
|
||||
toggleNotificationEmail,
|
||||
toggleQuincyEmail
|
||||
}) {
|
||||
if (!email) {
|
||||
return (
|
||||
<div>
|
||||
<Row>
|
||||
<p className='large-p text-center'>
|
||||
You don't have an email id associated to this account.
|
||||
</p>
|
||||
</Row>
|
||||
<Row>
|
||||
<UpdateEmailButton />
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={ `${ns}-email-container` }>
|
||||
<Row>
|
||||
<p className='large-p text-center'>
|
||||
<em>{ email }</em>
|
||||
</p>
|
||||
</Row>
|
||||
<Row>
|
||||
<UpdateEmailButton />
|
||||
</Row>
|
||||
<Row>
|
||||
<Col sm={ 8 }>
|
||||
<p className='large-p'>
|
||||
Send me announcement emails
|
||||
<br />
|
||||
(we'll send you these every Thursday)
|
||||
</p>
|
||||
</Col>
|
||||
<Col sm={ 4 }>
|
||||
<TB
|
||||
name='monthly-email'
|
||||
onChange={ toggleMonthlyEmail }
|
||||
value={ sendMonthlyEmail }
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col sm={ 8 }>
|
||||
<p className='large-p'>
|
||||
Send me notification emails
|
||||
<br />
|
||||
(these will pertain to your account)
|
||||
</p>
|
||||
</Col>
|
||||
<Col sm={ 4 }>
|
||||
<TB
|
||||
name='notifications-email'
|
||||
onChange={ toggleNotificationEmail }
|
||||
value={ sendNotificationEmail }
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col sm={ 8 }>
|
||||
<p className='large-p'>
|
||||
Send me Quincy's weekly email
|
||||
<br />
|
||||
(with new articles every Tuesday)
|
||||
</p>
|
||||
</Col>
|
||||
<Col sm={ 4 }>
|
||||
<TB
|
||||
name='quincy-email'
|
||||
onChange={ toggleQuincyEmail }
|
||||
value={ sendQuincyEmail }
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
EmailSettings.displayName = 'EmailSettings';
|
||||
EmailSettings.propTypes = propTypes;
|
@@ -1,39 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { Button, Row, Col } from 'react-bootstrap';
|
||||
import classnames from 'classnames';
|
||||
|
||||
const propTypes = {
|
||||
isAvailableForHire: PropTypes.bool,
|
||||
toggle: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default function JobSettings({ isAvailableForHire, toggle }) {
|
||||
const className = classnames({
|
||||
active: isAvailableForHire,
|
||||
'btn-toggle': true
|
||||
});
|
||||
return (
|
||||
<Row>
|
||||
<Col xs={ 9 }>
|
||||
<p className='large-p'>
|
||||
Available for hire?
|
||||
</p>
|
||||
</Col>
|
||||
<Col xs={ 3 }>
|
||||
<Button
|
||||
block={ true }
|
||||
bsSize='lg'
|
||||
bsStyle='primary'
|
||||
className={ className }
|
||||
onClick={ toggle }
|
||||
>
|
||||
{ isAvailableForHire ? 'Yes' : 'No' }
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
JobSettings.displayName = 'JobSettings';
|
||||
JobSettings.propTypes = propTypes;
|
@@ -1,42 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { Button, Row, Col } from 'react-bootstrap';
|
||||
import classnames from 'classnames';
|
||||
|
||||
const propTypes = {
|
||||
isLocked: PropTypes.bool,
|
||||
toggle: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default function LockSettings({ isLocked, toggle }) {
|
||||
const className = classnames({
|
||||
'positive-20': true,
|
||||
active: isLocked,
|
||||
'btn-toggle': true
|
||||
});
|
||||
return (
|
||||
<Row>
|
||||
<Col xs={ 9 }>
|
||||
<p className='large-p'>
|
||||
Make all of my solutions private
|
||||
<br />
|
||||
(this disables your certificates)
|
||||
</p>
|
||||
</Col>
|
||||
<Col xs={ 3 }>
|
||||
<Button
|
||||
block={ true }
|
||||
bsSize='lg'
|
||||
bsStyle='primary'
|
||||
className={ className }
|
||||
onClick={ toggle }
|
||||
>
|
||||
{ isLocked ? 'On' : 'Off' }
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
LockSettings.displayName = 'LockSettings';
|
||||
LockSettings.propTypes = propTypes;
|
@@ -1,99 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Button, Row, Col } from 'react-bootstrap';
|
||||
|
||||
import ns from './ns.json';
|
||||
|
||||
// actual chars required to give buttons some height
|
||||
// whitespace alone is no good
|
||||
const placeholderString = (
|
||||
<span className='placeholder-string'>
|
||||
placeholder text of 28 chars
|
||||
</span>
|
||||
);
|
||||
const shortString = (
|
||||
<span className='placeholder-string'>
|
||||
placeholder
|
||||
</span>
|
||||
);
|
||||
|
||||
|
||||
export default function SettingsSkeleton() {
|
||||
return (
|
||||
<div className={ `${ns}-container ${ns}-skeleton` }>
|
||||
<Row>
|
||||
<Col xs={ 12 }>
|
||||
<Button
|
||||
block={ true }
|
||||
bsSize='lg'
|
||||
className='btn-link-social'
|
||||
>
|
||||
{ placeholderString }
|
||||
</Button>
|
||||
<Button
|
||||
block={ true }
|
||||
bsSize='lg'
|
||||
className='btn-link-social'
|
||||
>
|
||||
{ placeholderString }
|
||||
</Button>
|
||||
<Button
|
||||
block={ true }
|
||||
bsSize='lg'
|
||||
className='btn-link-social'
|
||||
>
|
||||
{ placeholderString }
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
<h1 className='text-center'>{ placeholderString }</h1>
|
||||
<h2 className='text-center'>{ shortString }</h2>
|
||||
<Row>
|
||||
<Col xs={ 12 }>
|
||||
<Button
|
||||
block={ true }
|
||||
bsSize='lg'
|
||||
className='btn-link-social'
|
||||
>
|
||||
{ placeholderString }
|
||||
</Button>
|
||||
<Button
|
||||
block={ true }
|
||||
bsSize='lg'
|
||||
className='btn-link-social'
|
||||
>
|
||||
{ placeholderString }
|
||||
</Button>
|
||||
<Button
|
||||
block={ true }
|
||||
bsSize='lg'
|
||||
className='btn-link-social'
|
||||
>
|
||||
{ placeholderString }
|
||||
</Button>
|
||||
<Button
|
||||
block={ true }
|
||||
bsSize='lg'
|
||||
className='btn-link-social'
|
||||
>
|
||||
{ placeholderString }
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
<div className='spacer' />
|
||||
<h2 className='text-center'>{ placeholderString }</h2>
|
||||
<Row>
|
||||
<Col xs={ 12 }>
|
||||
<Button
|
||||
block={ true }
|
||||
bsSize='lg'
|
||||
className='btn-link-social'
|
||||
>
|
||||
{ placeholderString }
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
<div className='spacer' />
|
||||
<h2 className='text-center'>{ placeholderString }</h2>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -3,58 +3,39 @@ import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import { Button, Row, Col } from 'react-bootstrap';
|
||||
import { Button } from 'react-bootstrap';
|
||||
import FA from 'react-fontawesome';
|
||||
|
||||
import ns from './ns.json';
|
||||
import LockedSettings from './Locked-Settings.jsx';
|
||||
import JobSettings from './Job-Settings.jsx';
|
||||
import SocialSettings from './Social-Settings.jsx';
|
||||
import EmailSettings from './Email-Setting.jsx';
|
||||
import LanguageSettings from './Language-Settings.jsx';
|
||||
import SettingsSkeleton from './Settings-Skeleton.jsx';
|
||||
import { FullWidthRow, Spacer, Loader } from '../../helperComponents';
|
||||
import AboutSettings from './components/About-Settings.jsx';
|
||||
import InternetSettings from './components/Internet-Settings.jsx';
|
||||
import EmailSettings from './components/Email-Settings.jsx';
|
||||
import DangerZone from './components/DangerZone.jsx';
|
||||
import LanguageSettings from './components/Language-Settings.jsx';
|
||||
import CertificationSettings from './components/Cert-Settings.jsx';
|
||||
import PortfolioSettings from './components/Portfolio-Settings.jsx';
|
||||
import Honesty from './components/Honesty.jsx';
|
||||
|
||||
import { toggleUserFlag } from './redux';
|
||||
import {
|
||||
toggleNightMode,
|
||||
updateTitle,
|
||||
|
||||
signInLoadingSelector,
|
||||
userSelector,
|
||||
usernameSelector,
|
||||
themeSelector,
|
||||
hardGoTo
|
||||
} from '../../redux';
|
||||
|
||||
const mapStateToProps = createSelector(
|
||||
userSelector,
|
||||
usernameSelector,
|
||||
themeSelector,
|
||||
signInLoadingSelector,
|
||||
(
|
||||
{
|
||||
username,
|
||||
email,
|
||||
isAvailableForHire,
|
||||
isLocked,
|
||||
isGithubCool,
|
||||
isTwitter,
|
||||
isLinkedIn,
|
||||
sendMonthlyEmail,
|
||||
sendNotificationEmail,
|
||||
sendQuincyEmail
|
||||
},
|
||||
username,
|
||||
theme,
|
||||
showLoading,
|
||||
) => ({
|
||||
currentTheme: theme,
|
||||
email,
|
||||
isAvailableForHire,
|
||||
isGithubCool,
|
||||
isLinkedIn,
|
||||
isLocked,
|
||||
isTwitter,
|
||||
sendMonthlyEmail,
|
||||
sendNotificationEmail,
|
||||
sendQuincyEmail,
|
||||
showLoading,
|
||||
username
|
||||
})
|
||||
@@ -62,37 +43,13 @@ const mapStateToProps = createSelector(
|
||||
|
||||
const mapDispatchToProps = {
|
||||
hardGoTo,
|
||||
toggleIsAvailableForHire: () => toggleUserFlag('isAvailableForHire'),
|
||||
toggleIsLocked: () => toggleUserFlag('isLocked'),
|
||||
toggleMonthlyEmail: () => toggleUserFlag('sendMonthlyEmail'),
|
||||
toggleNightMode,
|
||||
toggleNotificationEmail: () => toggleUserFlag('sendNotificationEmail'),
|
||||
toggleQuincyEmail: () => toggleUserFlag('sendQuincyEmail'),
|
||||
updateTitle
|
||||
};
|
||||
|
||||
const propTypes = {
|
||||
children: PropTypes.element,
|
||||
currentTheme: PropTypes.string,
|
||||
email: PropTypes.string,
|
||||
hardGoTo: PropTypes.func.isRequired,
|
||||
initialLang: PropTypes.string,
|
||||
isAvailableForHire: PropTypes.bool,
|
||||
isGithubCool: PropTypes.bool,
|
||||
isLinkedIn: PropTypes.bool,
|
||||
isLocked: PropTypes.bool,
|
||||
isTwitter: PropTypes.bool,
|
||||
lang: PropTypes.string,
|
||||
sendMonthlyEmail: PropTypes.bool,
|
||||
sendNotificationEmail: PropTypes.bool,
|
||||
sendQuincyEmail: PropTypes.bool,
|
||||
showLoading: PropTypes.bool,
|
||||
toggleIsAvailableForHire: PropTypes.func.isRequired,
|
||||
toggleIsLocked: PropTypes.func.isRequired,
|
||||
toggleMonthlyEmail: PropTypes.func.isRequired,
|
||||
toggleNightMode: PropTypes.func.isRequired,
|
||||
toggleNotificationEmail: PropTypes.func.isRequired,
|
||||
toggleQuincyEmail: PropTypes.func.isRequired,
|
||||
updateMyLang: PropTypes.func,
|
||||
updateTitle: PropTypes.func.isRequired,
|
||||
username: PropTypes.string
|
||||
@@ -121,188 +78,51 @@ export class Settings extends React.Component {
|
||||
|
||||
render() {
|
||||
const {
|
||||
currentTheme,
|
||||
email,
|
||||
isAvailableForHire,
|
||||
isGithubCool,
|
||||
isLinkedIn,
|
||||
isLocked,
|
||||
isTwitter,
|
||||
sendMonthlyEmail,
|
||||
sendNotificationEmail,
|
||||
sendQuincyEmail,
|
||||
showLoading,
|
||||
toggleIsAvailableForHire,
|
||||
toggleIsLocked,
|
||||
toggleMonthlyEmail,
|
||||
toggleNightMode,
|
||||
toggleNotificationEmail,
|
||||
toggleQuincyEmail,
|
||||
username
|
||||
} = this.props;
|
||||
if (!username && showLoading) {
|
||||
return <SettingsSkeleton />;
|
||||
return <Loader />;
|
||||
}
|
||||
return (
|
||||
<div className={ `${ns}-container` }>
|
||||
<Row>
|
||||
<Col xs={ 12 }>
|
||||
<Button
|
||||
block={ true }
|
||||
bsSize='lg'
|
||||
bsStyle='primary'
|
||||
className='btn-link-social'
|
||||
href={ `/${username}` }
|
||||
>
|
||||
<FA name='user' />
|
||||
Show me my public profile
|
||||
</Button>
|
||||
<Button
|
||||
block={ true }
|
||||
bsSize='lg'
|
||||
bsStyle='primary'
|
||||
className='btn-link-social'
|
||||
href={ '/signout' }
|
||||
>
|
||||
Sign me out of freeCodeCamp
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
<h1 className='text-center'>Settings for your Account</h1>
|
||||
<h2 className='text-center'>Actions</h2>
|
||||
<Row>
|
||||
<Col xs={ 12 }>
|
||||
<Button
|
||||
block={ true }
|
||||
bsSize='lg'
|
||||
bsStyle='primary'
|
||||
className='btn-link-social'
|
||||
onClick={ () => toggleNightMode(username, currentTheme) }
|
||||
>
|
||||
Toggle Night Mode
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col xs={ 12 }>
|
||||
<SocialSettings
|
||||
isGithubCool={ isGithubCool }
|
||||
isLinkedIn={ isLinkedIn }
|
||||
isTwitter={ isTwitter }
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<div className='spacer' />
|
||||
<h2 className='text-center'>Account Settings</h2>
|
||||
<Row>
|
||||
<Col xs={ 12 }>
|
||||
<Button
|
||||
block={ true }
|
||||
bsSize='lg'
|
||||
bsStyle='primary'
|
||||
className='btn-link-social'
|
||||
href='/commit'
|
||||
>
|
||||
Edit my pledge
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
<div className='spacer' />
|
||||
<h2 className='text-center'>Privacy Settings</h2>
|
||||
<Row>
|
||||
<Col
|
||||
md={ 6 }
|
||||
mdOffset={ 3 }
|
||||
sm={ 8 }
|
||||
smOffset={ 2 }
|
||||
xs={ 12 }
|
||||
<FullWidthRow>
|
||||
<Button
|
||||
block={ true }
|
||||
bsSize='lg'
|
||||
bsStyle='primary'
|
||||
className='btn-link-social'
|
||||
href={ `/${username}` }
|
||||
>
|
||||
<LockedSettings
|
||||
isLocked={ isLocked }
|
||||
toggle={ toggleIsLocked }
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<div className='spacer' />
|
||||
<h2 className='text-center'>Job Search Settings</h2>
|
||||
<Row>
|
||||
<Col
|
||||
md={ 6 }
|
||||
mdOffset={ 3 }
|
||||
sm={ 8 }
|
||||
smOffset={ 2 }
|
||||
xs={ 12 }
|
||||
<FA name='user' />
|
||||
Show me my public profile
|
||||
</Button>
|
||||
<Button
|
||||
block={ true }
|
||||
bsSize='lg'
|
||||
bsStyle='primary'
|
||||
className='btn-link-social'
|
||||
href={ '/signout' }
|
||||
>
|
||||
<JobSettings
|
||||
isAvailableForHire={ isAvailableForHire }
|
||||
toggle={ toggleIsAvailableForHire }
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<div className='spacer' />
|
||||
<h2 className='text-center'>Email Settings</h2>
|
||||
<Row>
|
||||
<Col
|
||||
md={ 6 }
|
||||
mdOffset={ 3 }
|
||||
sm={ 8 }
|
||||
smOffset={ 2 }
|
||||
xs={ 12 }
|
||||
>
|
||||
<EmailSettings
|
||||
email={ email }
|
||||
sendMonthlyEmail={ sendMonthlyEmail }
|
||||
sendNotificationEmail={ sendNotificationEmail }
|
||||
sendQuincyEmail={ sendQuincyEmail }
|
||||
toggleMonthlyEmail={ toggleMonthlyEmail }
|
||||
toggleNotificationEmail={ toggleNotificationEmail }
|
||||
toggleQuincyEmail={ toggleQuincyEmail }
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<div className='spacer' />
|
||||
<h2 className='text-center'>Display challenges in:</h2>
|
||||
<Row>
|
||||
<Col
|
||||
md={ 6 }
|
||||
mdOffset={ 3 }
|
||||
sm={ 8 }
|
||||
smOffset={ 2 }
|
||||
xs={ 12 }
|
||||
>
|
||||
<LanguageSettings />
|
||||
</Col>
|
||||
</Row>
|
||||
<div className='spacer' />
|
||||
<h2 className='text-center'>Danger Zone</h2>
|
||||
<Row>
|
||||
<Col
|
||||
md={ 6 }
|
||||
mdOffset={ 3 }
|
||||
sm={ 8 }
|
||||
smOffset={ 2 }
|
||||
xs={ 12 }
|
||||
>
|
||||
<Button
|
||||
block={ true }
|
||||
bsSize='lg'
|
||||
bsStyle='danger'
|
||||
className='btn-link-social'
|
||||
href='/delete-my-account'
|
||||
>
|
||||
Delete my freeCodeCamp account
|
||||
</Button>
|
||||
<Button
|
||||
block={ true }
|
||||
bsSize='lg'
|
||||
bsStyle='danger'
|
||||
className='btn-link-social'
|
||||
href='/reset-my-progress'
|
||||
>
|
||||
Reset all of my progress and brownie points
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
Sign me out of freeCodeCamp
|
||||
</Button>
|
||||
</FullWidthRow>
|
||||
<h1 className='text-center'>{ `Account Settings for ${username}` }</h1>
|
||||
<AboutSettings />
|
||||
<Spacer />
|
||||
<EmailSettings />
|
||||
<Spacer />
|
||||
<LanguageSettings />
|
||||
<Spacer />
|
||||
<InternetSettings />
|
||||
<Spacer />
|
||||
<PortfolioSettings />
|
||||
<Spacer />
|
||||
<CertificationSettings />
|
||||
<Spacer />
|
||||
<Honesty />
|
||||
<Spacer />
|
||||
<DangerZone />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -1,76 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { Button } from 'react-bootstrap';
|
||||
import FA from 'react-fontawesome';
|
||||
import classnames from 'classnames';
|
||||
|
||||
const propTypes = {
|
||||
isGithubCool: PropTypes.bool,
|
||||
isLinkedIn: PropTypes.bool,
|
||||
isTwitter: PropTypes.bool
|
||||
};
|
||||
|
||||
export default function SocialSettings({
|
||||
isGithubCool,
|
||||
isTwitter,
|
||||
isLinkedIn
|
||||
}) {
|
||||
const githubCopy = isGithubCool ?
|
||||
'Update my profile from GitHub' :
|
||||
'Link my GitHub to enable my public profile';
|
||||
const buttons = [
|
||||
<Button
|
||||
block={ true }
|
||||
bsSize='lg'
|
||||
className='btn-link-social btn-github'
|
||||
href='/link/github'
|
||||
key='github'
|
||||
>
|
||||
<FA name='github' />
|
||||
{ githubCopy }
|
||||
</Button>
|
||||
];
|
||||
const socials = [
|
||||
{
|
||||
isActive: isTwitter,
|
||||
identifier: 'twitter',
|
||||
text: 'Twitter'
|
||||
},
|
||||
{
|
||||
isActive: isLinkedIn,
|
||||
identifier: 'linkedin',
|
||||
text: 'LinkedIn'
|
||||
}
|
||||
];
|
||||
if (isGithubCool) {
|
||||
socials.forEach(({ isActive, identifier, text }) => {
|
||||
const socialClass = classnames(
|
||||
'btn-link-social',
|
||||
`btn-${identifier}`,
|
||||
{ active: isActive }
|
||||
);
|
||||
const socialLink = isActive ?
|
||||
`/account/unlink/${identifier}` :
|
||||
`/link/${identifier}`;
|
||||
const socialText = isTwitter ?
|
||||
`Remove my ${text} from my portfolio` :
|
||||
`Add my ${text} to my portfolio`;
|
||||
buttons.push((
|
||||
<Button
|
||||
block={ true }
|
||||
bsSize='lg'
|
||||
className={ socialClass }
|
||||
href={ socialLink }
|
||||
key={ identifier }
|
||||
>
|
||||
<FA name={ identifier } />
|
||||
{ socialText }
|
||||
</Button>
|
||||
));
|
||||
});
|
||||
}
|
||||
return (<div>{ buttons }</div>);
|
||||
}
|
||||
|
||||
SocialSettings.displayName = 'SocialSettings';
|
||||
SocialSettings.propTypes = propTypes;
|
236
common/app/routes/Settings/components/About-Settings.jsx
Normal file
236
common/app/routes/Settings/components/About-Settings.jsx
Normal file
@@ -0,0 +1,236 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { createSelector } from 'reselect';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { reduxForm } from 'redux-form';
|
||||
import {
|
||||
Nav,
|
||||
NavItem
|
||||
} from 'react-bootstrap';
|
||||
|
||||
import { FullWidthRow, Spacer } from '../../../helperComponents';
|
||||
import LockedSettings from './Locked-Settings.jsx';
|
||||
import ThemeSettings from './ThemeSettings.jsx';
|
||||
import Camper from './Camper.jsx';
|
||||
import UsernameSettings from './UsernameSettings.jsx';
|
||||
import SectionHeader from './SectionHeader.jsx';
|
||||
import { userSelector, toggleNightMode } from '../../../redux';
|
||||
import {
|
||||
updateUserBackend
|
||||
} from '../redux';
|
||||
import {
|
||||
BlockSaveButton,
|
||||
BlockSaveWrapper,
|
||||
FormFields,
|
||||
maxLength,
|
||||
validURL
|
||||
} from '../formHelpers';
|
||||
|
||||
const max288Char = maxLength(288);
|
||||
|
||||
const mapStateToProps = createSelector(
|
||||
userSelector,
|
||||
(
|
||||
{
|
||||
about,
|
||||
isLocked,
|
||||
location,
|
||||
name,
|
||||
picture,
|
||||
points,
|
||||
theme,
|
||||
username
|
||||
},
|
||||
) => ({
|
||||
about,
|
||||
currentTheme: theme,
|
||||
initialValues: { name, location, about, picture },
|
||||
isLocked,
|
||||
location,
|
||||
name,
|
||||
picture,
|
||||
points,
|
||||
username
|
||||
})
|
||||
);
|
||||
|
||||
const formFields = [ 'name', 'location', 'picture', 'about' ];
|
||||
|
||||
function validator(values) {
|
||||
const errors = {};
|
||||
const {
|
||||
about,
|
||||
picture
|
||||
} = values;
|
||||
errors.about = max288Char(about);
|
||||
errors.picutre = validURL(picture);
|
||||
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch) {
|
||||
return bindActionCreators({
|
||||
toggleNightMode,
|
||||
updateUserBackend
|
||||
}, dispatch);
|
||||
}
|
||||
|
||||
const propTypes = {
|
||||
about: PropTypes.string,
|
||||
currentTheme: PropTypes.string,
|
||||
fields: PropTypes.object,
|
||||
handleSubmit: PropTypes.func.isRequired,
|
||||
isLocked: PropTypes.bool,
|
||||
location: PropTypes.string,
|
||||
name: PropTypes.string,
|
||||
picture: PropTypes.string,
|
||||
points: PropTypes.number,
|
||||
toggleNightMode: PropTypes.func.isRequired,
|
||||
updateUserBackend: PropTypes.func.isRequired,
|
||||
username: PropTypes.string
|
||||
};
|
||||
|
||||
class AboutSettings extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
this.handleTabSelect = this.handleTabSelect.bind(this);
|
||||
this.renderEdit = this.renderEdit.bind(this);
|
||||
this.renderPreview = this.renderPreview.bind(this);
|
||||
|
||||
this.state = {
|
||||
view: 'edit'
|
||||
};
|
||||
this.show = {
|
||||
edit: this.renderEdit,
|
||||
preview: this.renderPreview
|
||||
};
|
||||
}
|
||||
|
||||
handleSubmit(values) {
|
||||
this.props.updateUserBackend(values);
|
||||
}
|
||||
|
||||
handleTabSelect(key) {
|
||||
this.setState(state => ({
|
||||
...state,
|
||||
view: key
|
||||
}));
|
||||
}
|
||||
|
||||
renderEdit() {
|
||||
const { fields } = this.props;
|
||||
const options = {
|
||||
types: {
|
||||
about: 'textarea',
|
||||
picture: 'url'
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<FormFields
|
||||
fields={ fields }
|
||||
options={ options }
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderPreview() {
|
||||
const {
|
||||
fields: {
|
||||
picture: { value: picture },
|
||||
name: { value: name },
|
||||
location: { value: location },
|
||||
about: { value: about }
|
||||
},
|
||||
points,
|
||||
username
|
||||
} = this.props;
|
||||
return (
|
||||
<Camper
|
||||
about={ about }
|
||||
location={ location }
|
||||
name={ name }
|
||||
picture={ picture }
|
||||
points={ points }
|
||||
username={ username }
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
currentTheme,
|
||||
fields: { _meta: { allPristine } },
|
||||
handleSubmit,
|
||||
isLocked,
|
||||
toggleNightMode,
|
||||
updateUserBackend,
|
||||
username
|
||||
} = this.props;
|
||||
const { view } = this.state;
|
||||
|
||||
const toggleIsLocked = () => updateUserBackend({ isLocked: !isLocked });
|
||||
const toggleTheme = () => toggleNightMode(username, currentTheme);
|
||||
return (
|
||||
<div className='about-settings'>
|
||||
<SectionHeader>
|
||||
About Settings
|
||||
</SectionHeader>
|
||||
<UsernameSettings username={ username }/>
|
||||
<FullWidthRow>
|
||||
<Nav
|
||||
activeKey={ view }
|
||||
bsStyle='tabs'
|
||||
className='edit-preview-tabs'
|
||||
onSelect={k => this.handleTabSelect(k)}
|
||||
>
|
||||
<NavItem eventKey='edit' title='Edit Bio'>
|
||||
Edit Bio
|
||||
</NavItem>
|
||||
<NavItem eventKey='preview' title='Preview Bio'>
|
||||
Preview Bio
|
||||
</NavItem>
|
||||
</Nav>
|
||||
</FullWidthRow>
|
||||
<br />
|
||||
<FullWidthRow>
|
||||
<form id='camper-identity' onSubmit={ handleSubmit(this.handleSubmit) }>
|
||||
{
|
||||
this.show[view]()
|
||||
}
|
||||
<BlockSaveWrapper>
|
||||
<BlockSaveButton disabled={ allPristine } />
|
||||
</BlockSaveWrapper>
|
||||
</form>
|
||||
</FullWidthRow>
|
||||
<Spacer />
|
||||
<FullWidthRow>
|
||||
<LockedSettings
|
||||
isLocked={ isLocked }
|
||||
toggleIsLocked={ toggleIsLocked }
|
||||
/>
|
||||
</FullWidthRow>
|
||||
<FullWidthRow>
|
||||
<ThemeSettings
|
||||
currentTheme={ currentTheme }
|
||||
toggleNightMode={ toggleTheme }
|
||||
/>
|
||||
</FullWidthRow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AboutSettings.displayName = 'AboutSettings';
|
||||
AboutSettings.propTypes = propTypes;
|
||||
|
||||
export default reduxForm(
|
||||
{
|
||||
form: 'account-settings',
|
||||
fields: formFields,
|
||||
validate: validator
|
||||
},
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(AboutSettings);
|
58
common/app/routes/Settings/components/Camper.jsx
Normal file
58
common/app/routes/Settings/components/Camper.jsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Col, Row } from 'react-bootstrap';
|
||||
|
||||
import SocialIcons from './SocialIcons.jsx';
|
||||
|
||||
const propTypes = {
|
||||
about: PropTypes.string,
|
||||
location: PropTypes.string,
|
||||
name: PropTypes.string,
|
||||
picture: PropTypes.string,
|
||||
points: PropTypes.number,
|
||||
username: PropTypes.string
|
||||
};
|
||||
|
||||
function pluralise(word, condition) {
|
||||
return condition ? word + 's' : word;
|
||||
}
|
||||
|
||||
function Camper({
|
||||
name,
|
||||
username,
|
||||
location,
|
||||
points,
|
||||
picture,
|
||||
about
|
||||
}) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Row>
|
||||
<Col className='avatar-container' xs={ 12 }>
|
||||
<img
|
||||
alt={ username + '\'s profile picture' }
|
||||
className='avatar'
|
||||
src={ picture }
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<br />
|
||||
<SocialIcons />
|
||||
<br/>
|
||||
<h2 className='text-center username'>@{ username }</h2>
|
||||
{ name && <p className='text-center name'>{ name }</p> }
|
||||
{ location && <p className='text-center location'>{ location }</p> }
|
||||
{ about && <p className='bio text-center'>{ about }</p> }
|
||||
<p className='text-center points'>
|
||||
{ `${points} ${pluralise('point', points > 1)}` }
|
||||
</p>
|
||||
<br/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Camper.displayName = 'Camper';
|
||||
Camper.propTypes = propTypes;
|
||||
|
||||
export default Camper;
|
247
common/app/routes/Settings/components/Cert-Settings.jsx
Normal file
247
common/app/routes/Settings/components/Cert-Settings.jsx
Normal file
@@ -0,0 +1,247 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import _ from 'lodash';
|
||||
import { createSelector } from 'reselect';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { connect } from 'react-redux';
|
||||
import { Button } from 'react-bootstrap';
|
||||
|
||||
import { FullWidthRow } from '../../../helperComponents';
|
||||
import { Form } from '../formHelpers';
|
||||
import JSAlgoAndDSForm from './JSAlgoAndDSForm.jsx';
|
||||
import SectionHeader from './SectionHeader.jsx';
|
||||
import { projectsSelector } from '../../../entities';
|
||||
import { claimCert, updateUserBackend } from '../redux';
|
||||
import { fetchChallenges, userSelector, hardGoTo } from '../../../redux';
|
||||
import {
|
||||
buildUserProjectsMap,
|
||||
jsProjectSuperBlock
|
||||
} from '../utils/buildUserProjectsMap';
|
||||
|
||||
const mapStateToProps = createSelector(
|
||||
userSelector,
|
||||
projectsSelector,
|
||||
(
|
||||
{
|
||||
challengeMap,
|
||||
isRespWebDesignCert,
|
||||
is2018DataVisCert,
|
||||
isFrontEndLibsCert,
|
||||
isJsAlgoDataStructCert,
|
||||
isApisMicroservicesCert,
|
||||
isInfosecQaCert,
|
||||
username
|
||||
},
|
||||
projects
|
||||
) => ({
|
||||
projects,
|
||||
userProjects: projects
|
||||
.map(block => buildUserProjectsMap(block, challengeMap))
|
||||
.reduce((projects, current) => ({
|
||||
...projects,
|
||||
...current
|
||||
}), {}),
|
||||
blockNameIsCertMap: {
|
||||
'Applied Responsive Web Design Projects': isRespWebDesignCert,
|
||||
/* eslint-disable max-len */
|
||||
'JavaScript Algorithms and Data Structures Projects': isJsAlgoDataStructCert,
|
||||
/* eslint-enable max-len */
|
||||
'Front End Libraries Projects': isFrontEndLibsCert,
|
||||
'Data Visualization Projects': is2018DataVisCert,
|
||||
'API and Microservice Projects': isApisMicroservicesCert,
|
||||
'Information Security and Quality Assurance Projects': isInfosecQaCert
|
||||
},
|
||||
username
|
||||
})
|
||||
);
|
||||
|
||||
function mapDispatchToProps(dispatch) {
|
||||
return bindActionCreators({
|
||||
claimCert,
|
||||
fetchChallenges,
|
||||
hardGoTo,
|
||||
updateUserBackend
|
||||
}, dispatch);
|
||||
}
|
||||
|
||||
const propTypes = {
|
||||
blockNameIsCertMap: PropTypes.objectOf(PropTypes.bool),
|
||||
claimCert: PropTypes.func.isRequired,
|
||||
fetchChallenges: PropTypes.func.isRequired,
|
||||
hardGoTo: PropTypes.func.isRequired,
|
||||
projects: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
projectBlockName: PropTypes.string,
|
||||
challenges: PropTypes.arrayOf(PropTypes.string)
|
||||
})
|
||||
),
|
||||
superBlock: PropTypes.string,
|
||||
updateUserBackend: PropTypes.func.isRequired,
|
||||
userProjects: PropTypes.objectOf(
|
||||
PropTypes.objectOf(PropTypes.oneOfType(
|
||||
[
|
||||
PropTypes.string,
|
||||
PropTypes.object
|
||||
]
|
||||
))
|
||||
),
|
||||
username: PropTypes.string
|
||||
};
|
||||
|
||||
class CertificationSettings extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { projects } = this.props;
|
||||
if (!projects.length) {
|
||||
this.props.fetchChallenges();
|
||||
}
|
||||
}
|
||||
|
||||
handleSubmit(values) {
|
||||
const { id } = values;
|
||||
const fullForm = _.values(values)
|
||||
.filter(Boolean)
|
||||
.filter(_.isString)
|
||||
// 5 projects + 1 id prop
|
||||
.length === 6;
|
||||
const valuesSaved = _.values(this.props.userProjects[id])
|
||||
.filter(Boolean)
|
||||
.filter(_.isString)
|
||||
.length === 6;
|
||||
if (fullForm && valuesSaved) {
|
||||
return this.props.claimCert(id);
|
||||
}
|
||||
const { projects } = this.props;
|
||||
const pIndex = _.findIndex(projects, p => p.superBlock === id);
|
||||
values.nameToIdMap = projects[pIndex].challengeNameIdMap;
|
||||
return this.props.updateUserBackend({
|
||||
projects: {
|
||||
[id]: values
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
blockNameIsCertMap,
|
||||
claimCert,
|
||||
hardGoTo,
|
||||
projects,
|
||||
userProjects,
|
||||
username
|
||||
} = this.props;
|
||||
if (!projects.length) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader>
|
||||
Certification Settings
|
||||
</SectionHeader>
|
||||
<FullWidthRow>
|
||||
<p>
|
||||
Add links to the live demos of your projects as you finish them.
|
||||
Then, once you have added all 5 projects required for a certificate,
|
||||
you can claim it.
|
||||
</p>
|
||||
</FullWidthRow>
|
||||
{
|
||||
projects.map(({
|
||||
projectBlockName,
|
||||
challenges,
|
||||
superBlock
|
||||
}) => {
|
||||
const isCertClaimed = blockNameIsCertMap[projectBlockName];
|
||||
if (superBlock === jsProjectSuperBlock) {
|
||||
return (
|
||||
<JSAlgoAndDSForm
|
||||
challenges={ challenges }
|
||||
claimCert={ claimCert }
|
||||
hardGoTo={ hardGoTo }
|
||||
isCertClaimed={ isCertClaimed }
|
||||
jsProjects={ userProjects[superBlock] }
|
||||
key={ superBlock }
|
||||
projectBlockName={ projectBlockName }
|
||||
superBlock={ superBlock }
|
||||
username={ username }
|
||||
/>
|
||||
);
|
||||
}
|
||||
const options = challenges
|
||||
.reduce((options, current) => {
|
||||
options.types[current] = 'url';
|
||||
return options;
|
||||
}, { types: {} });
|
||||
|
||||
options.types.id = 'hidden';
|
||||
options.placeholder = false;
|
||||
|
||||
const userValues = userProjects[superBlock] || {};
|
||||
|
||||
if (!userValues.id) {
|
||||
userValues.id = superBlock;
|
||||
}
|
||||
|
||||
const initialValues = challenges
|
||||
.reduce((accu, current) => ({
|
||||
...accu,
|
||||
[current]: ''
|
||||
}), {});
|
||||
|
||||
const completedProjects = _.values(userValues)
|
||||
.filter(Boolean)
|
||||
.filter(_.isString)
|
||||
// minus 1 to account for the id
|
||||
.length - 1;
|
||||
|
||||
const fullForm = completedProjects === challenges.length;
|
||||
return (
|
||||
<FullWidthRow key={superBlock}>
|
||||
<h3 className='project-heading'>{ projectBlockName }</h3>
|
||||
<Form
|
||||
buttonText={ fullForm ? 'Claim Certificate' : 'Save Progress' }
|
||||
enableSubmit={ fullForm }
|
||||
formFields={ challenges.concat([ 'id' ]) }
|
||||
hideButton={isCertClaimed}
|
||||
id={ superBlock }
|
||||
initialValues={{
|
||||
...initialValues,
|
||||
...userValues
|
||||
}}
|
||||
options={ options }
|
||||
submit={ this.handleSubmit }
|
||||
/>
|
||||
{
|
||||
isCertClaimed ?
|
||||
<Button
|
||||
block={ true }
|
||||
bsSize='lg'
|
||||
bsStyle='primary'
|
||||
href={ `/c/${username}/${superBlock}`}
|
||||
>
|
||||
Show Certificate
|
||||
</Button> :
|
||||
null
|
||||
}
|
||||
<hr />
|
||||
</FullWidthRow>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CertificationSettings.displayName = 'CertificationSettings';
|
||||
CertificationSettings.propTypes = propTypes;
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(CertificationSettings);
|
106
common/app/routes/Settings/components/DangerZone.jsx
Normal file
106
common/app/routes/Settings/components/DangerZone.jsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Panel, Alert, Button } from 'react-bootstrap';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { ButtonSpacer, FullWidthRow } from '../../../helperComponents';
|
||||
import ResetModal from './ResetModal.jsx';
|
||||
import DeleteModal from './DeleteModal.jsx';
|
||||
import { resetProgress, deleteAccount } from '../redux';
|
||||
|
||||
const propTypes = {
|
||||
deleteAccount: PropTypes.func.isRequired,
|
||||
resetProgress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
const mapStateToProps = () => ({});
|
||||
const mapDispatchToProps = {
|
||||
deleteAccount,
|
||||
resetProgress
|
||||
};
|
||||
|
||||
class DangerZone extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
delete: false,
|
||||
reset: false
|
||||
};
|
||||
|
||||
this.toggleDeleteModal = this.toggleDeleteModal.bind(this);
|
||||
this.toggleResetModal = this.toggleResetModal.bind(this);
|
||||
}
|
||||
|
||||
toggleDeleteModal() {
|
||||
return this.setState(state => ({
|
||||
...state,
|
||||
delete: !state.delete
|
||||
}));
|
||||
}
|
||||
|
||||
toggleResetModal() {
|
||||
return this.setState(state => ({
|
||||
...state,
|
||||
reset: !state.reset
|
||||
}));
|
||||
}
|
||||
|
||||
render() {
|
||||
const { resetProgress, deleteAccount } = this.props;
|
||||
return (
|
||||
<div>
|
||||
<FullWidthRow>
|
||||
<Panel
|
||||
bsStyle='danger'
|
||||
className='danger-zone-panel'
|
||||
header={<h2><strong>Danger Zone</strong></h2>}
|
||||
>
|
||||
<Alert
|
||||
bsStyle='danger'
|
||||
>
|
||||
<p>
|
||||
Tread carefully, changes made in this area are permanent.
|
||||
They cannot be undone.
|
||||
</p>
|
||||
</Alert>
|
||||
<FullWidthRow>
|
||||
<Button
|
||||
block={ true }
|
||||
bsSize='lg'
|
||||
bsStyle='danger'
|
||||
onClick={ this.toggleResetModal }
|
||||
>
|
||||
Reset all of my progress
|
||||
</Button>
|
||||
<ButtonSpacer />
|
||||
<Button
|
||||
block={ true }
|
||||
bsSize='lg'
|
||||
bsStyle='danger'
|
||||
onClick={ this.toggleDeleteModal }
|
||||
>
|
||||
Delete my account
|
||||
</Button>
|
||||
</FullWidthRow>
|
||||
</Panel>
|
||||
<ResetModal
|
||||
onHide={ this.toggleResetModal }
|
||||
reset={ resetProgress }
|
||||
show={ this.state.reset }
|
||||
/>
|
||||
<DeleteModal
|
||||
delete={ deleteAccount }
|
||||
onHide={ this.toggleDeleteModal }
|
||||
show={ this.state.delete }
|
||||
/>
|
||||
</FullWidthRow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
DangerZone.displayName = 'DangerZone';
|
||||
DangerZone.propTypes = propTypes;
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(DangerZone);
|
71
common/app/routes/Settings/components/DeleteModal.jsx
Normal file
71
common/app/routes/Settings/components/DeleteModal.jsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Modal, Button } from 'react-bootstrap';
|
||||
|
||||
const propTypes = {
|
||||
delete: PropTypes.func.isRequired,
|
||||
onHide: PropTypes.func.isRequired,
|
||||
show: PropTypes.bool
|
||||
};
|
||||
|
||||
function DeleteModal(props) {
|
||||
const { show, onHide } = props;
|
||||
return (
|
||||
<Modal
|
||||
aria-labelledby='modal-title'
|
||||
autoFocus={ true }
|
||||
backdrop={ true }
|
||||
bsSize='lg'
|
||||
keyboard={ true }
|
||||
onHide={ onHide }
|
||||
show={ show }
|
||||
>
|
||||
<Modal.Header closeButton={ true }>
|
||||
<Modal.Title id='modal-title'>Delete My Account</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<p>
|
||||
This will really delete all your data, including all your progress
|
||||
and account information.
|
||||
</p>
|
||||
<p>
|
||||
We won't be able to recover any of it for you later,
|
||||
even if you change your mind.
|
||||
</p>
|
||||
<p>
|
||||
If there's something we could do better, send us an email instead and
|
||||
we'll do our best:  
|
||||
<a href='mailto:team@freecodecamp.org' title='team@freecodecamp.org'>
|
||||
team@freecodecamp.org
|
||||
</a>
|
||||
</p>
|
||||
<hr />
|
||||
<Button
|
||||
block={ true }
|
||||
bsSize='lg'
|
||||
bsStyle='success'
|
||||
onClick={ props.onHide }
|
||||
>
|
||||
Nevermind, I don't want to delete my account
|
||||
</Button>
|
||||
<div className='button-spacer' />
|
||||
<Button
|
||||
block={ true }
|
||||
bsSize='lg'
|
||||
bsStyle='danger'
|
||||
onClick={ props.delete }
|
||||
>
|
||||
I am 100% certain. Delete everything related to this account
|
||||
</Button>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button onClick={ props.onHide }>Close</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
DeleteModal.displayName = 'DeleteModal';
|
||||
DeleteModal.propTypes = propTypes;
|
||||
|
||||
export default DeleteModal;
|
168
common/app/routes/Settings/components/Email-Settings.jsx
Normal file
168
common/app/routes/Settings/components/Email-Settings.jsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { createSelector } from 'reselect';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { reduxForm } from 'redux-form';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Col,
|
||||
ControlLabel,
|
||||
HelpBlock,
|
||||
Row
|
||||
} from 'react-bootstrap';
|
||||
|
||||
import TB from '../Toggle-Button';
|
||||
import EmailForm from './EmailForm.jsx';
|
||||
import { Link } from '../../../Router';
|
||||
import { FullWidthRow, Spacer } from '../../../helperComponents';
|
||||
import SectionHeader from './SectionHeader.jsx';
|
||||
import { userSelector } from '../../../redux';
|
||||
import { onRouteUpdateEmail, updateMyEmail, updateUserBackend } from '../redux';
|
||||
|
||||
const mapStateToProps = createSelector(
|
||||
userSelector,
|
||||
({
|
||||
email,
|
||||
isEmailVerified,
|
||||
sendQuincyEmail
|
||||
}) => ({
|
||||
email,
|
||||
initialValues: { email },
|
||||
isEmailVerified,
|
||||
sendQuincyEmail
|
||||
})
|
||||
);
|
||||
|
||||
function mapDispatchToProps(dispatch) {
|
||||
return bindActionCreators({
|
||||
updateMyEmail,
|
||||
updateUserBackend
|
||||
}, dispatch);
|
||||
}
|
||||
|
||||
const propTypes = {
|
||||
email: PropTypes.string,
|
||||
isEmailVerified: PropTypes.bool,
|
||||
options: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
flag: PropTypes.string,
|
||||
label: PropTypes.string,
|
||||
bool: PropTypes.bool
|
||||
})
|
||||
),
|
||||
sendQuincyEmail: PropTypes.bool,
|
||||
updateMyEmail: PropTypes.func.isRequired,
|
||||
updateUserBackend: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export function UpdateEmailButton() {
|
||||
return (
|
||||
<Link
|
||||
style={{ textDecoration: 'none' }}
|
||||
to={ onRouteUpdateEmail() }
|
||||
>
|
||||
<Button
|
||||
block={ true }
|
||||
bsSize='lg'
|
||||
bsStyle='primary'
|
||||
className='btn-link-social'
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
class EmailSettings extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
}
|
||||
|
||||
handleSubmit({ email }) {
|
||||
|
||||
this.props.updateMyEmail(email);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
email,
|
||||
isEmailVerified,
|
||||
sendQuincyEmail
|
||||
} = this.props;
|
||||
if (!email) {
|
||||
return (
|
||||
<div>
|
||||
<FullWidthRow>
|
||||
<p className='large-p text-center'>
|
||||
You do not have an email associated with this account.
|
||||
</p>
|
||||
</FullWidthRow>
|
||||
<FullWidthRow>
|
||||
<UpdateEmailButton />
|
||||
</FullWidthRow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className='email-settings'>
|
||||
<SectionHeader>
|
||||
Email Settings
|
||||
</SectionHeader>
|
||||
{
|
||||
isEmailVerified ? null :
|
||||
<FullWidthRow>
|
||||
<HelpBlock>
|
||||
<Alert bsStyle='info'>
|
||||
A change of email adress has not been verified.
|
||||
To use your new email, you must verify it first using the link
|
||||
we sent you.
|
||||
</Alert>
|
||||
</HelpBlock>
|
||||
</FullWidthRow>
|
||||
}
|
||||
<FullWidthRow>
|
||||
<EmailForm
|
||||
initialValues={{ email, confrimEmail: ''}}
|
||||
/>
|
||||
</FullWidthRow>
|
||||
<Spacer />
|
||||
<FullWidthRow>
|
||||
<Row className='inline-form-field' key='sendQuincyEmail'>
|
||||
<Col sm={ 8 }>
|
||||
<ControlLabel htmlFor='sendQuincyEmail'>
|
||||
Send me Quincy's weekly email
|
||||
</ControlLabel>
|
||||
</Col>
|
||||
<Col sm={ 4 }>
|
||||
<TB
|
||||
id='sendQuincyEmail'
|
||||
name='sendQuincyEmail'
|
||||
onChange={
|
||||
() => updateUserBackend({
|
||||
sendQuincyEmail: !sendQuincyEmail
|
||||
})
|
||||
}
|
||||
value={ sendQuincyEmail }
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</FullWidthRow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
EmailSettings.displayName = 'EmailSettings';
|
||||
EmailSettings.propTypes = propTypes;
|
||||
|
||||
export default reduxForm(
|
||||
{
|
||||
form: 'email-settings',
|
||||
fields: [ 'email' ]
|
||||
},
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(EmailSettings);
|
160
common/app/routes/Settings/components/EmailForm.jsx
Normal file
160
common/app/routes/Settings/components/EmailForm.jsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import React, { PureComponent} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { reduxForm } from 'redux-form';
|
||||
import {
|
||||
Row,
|
||||
Col,
|
||||
ControlLabel,
|
||||
FormControl,
|
||||
HelpBlock,
|
||||
Alert
|
||||
} from 'react-bootstrap';
|
||||
import { updateUserBackend } from '../redux';
|
||||
import { FullWidthRow, Spacer } from '../../../helperComponents';
|
||||
import { BlockSaveButton, BlockSaveWrapper, validEmail } from '../formHelpers';
|
||||
|
||||
const propTypes = {
|
||||
email: PropTypes.string,
|
||||
errors: PropTypes.object,
|
||||
fields: PropTypes.objectOf(
|
||||
PropTypes.shape({
|
||||
name: PropTypes.string.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
value: PropTypes.string.isRequired
|
||||
})
|
||||
),
|
||||
handleSubmit: PropTypes.func.isRequired,
|
||||
updateUserBackend: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
function mapStateToProps() {
|
||||
return {};
|
||||
}
|
||||
|
||||
const mapDispatchtoProps = { updateUserBackend };
|
||||
|
||||
function validator(values) {
|
||||
const errors = {};
|
||||
const { email = '', confirmEmail = '' } = values;
|
||||
|
||||
errors.email = validEmail(email);
|
||||
if (errors.email || errors.confirmEmail) {
|
||||
return errors;
|
||||
}
|
||||
errors.confirmEmail = email.toLowerCase() === confirmEmail.toLowerCase() ?
|
||||
null :
|
||||
'Emails should be the same';
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
class EmailForm extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.options = {
|
||||
required: [ 'confirmEmail', 'email' ],
|
||||
types: { confirmemail: 'email', email: 'email' }
|
||||
};
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
}
|
||||
|
||||
handleSubmit(values) {
|
||||
const { updateUserBackend } = this.props;
|
||||
const update = {
|
||||
email: values.email
|
||||
};
|
||||
updateUserBackend(update);
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
const {
|
||||
fields: { email, confirmEmail },
|
||||
handleSubmit
|
||||
} = this.props;
|
||||
|
||||
const disableForm = (email.pristine && confirmEmail.pristine) ||
|
||||
(!!email.error || !!confirmEmail.error);
|
||||
|
||||
return (
|
||||
<form id='email-form' onSubmit={ handleSubmit(this.handleSubmit) }>
|
||||
<Row className='inline-form-field'>
|
||||
<Col sm={ 3 } xs={ 12 }>
|
||||
<ControlLabel htmlFor='email'>
|
||||
Email
|
||||
</ControlLabel>
|
||||
</Col>
|
||||
<Col sm={ 9 } xs={ 12 }>
|
||||
<FormControl
|
||||
bsSize='lg'
|
||||
id='email'
|
||||
name='email'
|
||||
onChange={ email.onChange }
|
||||
required={ true }
|
||||
type='email'
|
||||
value={ email.value }
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<FullWidthRow>
|
||||
{
|
||||
!email.pristine && email.error ?
|
||||
<HelpBlock>
|
||||
<Alert bsStyle='danger'>
|
||||
{ email.error }
|
||||
</Alert>
|
||||
</HelpBlock> :
|
||||
null
|
||||
}
|
||||
</FullWidthRow>
|
||||
<Row className='inline-form-field'>
|
||||
<Col sm={ 3 } xs={ 12 }>
|
||||
<ControlLabel htmlFor='confirm-email'>
|
||||
Confirm Email
|
||||
</ControlLabel>
|
||||
</Col>
|
||||
<Col sm={ 9 } xs={ 12 }>
|
||||
<FormControl
|
||||
bsSize='lg'
|
||||
id='confirm-email'
|
||||
name='confirm-email'
|
||||
onChange={ confirmEmail.onChange }
|
||||
required={ true }
|
||||
type='email'
|
||||
value={ confirmEmail.value }
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<FullWidthRow>
|
||||
{
|
||||
!confirmEmail.pristine && confirmEmail.error ?
|
||||
<HelpBlock>
|
||||
<Alert bsStyle='danger'>
|
||||
{ confirmEmail.error }
|
||||
</Alert>
|
||||
</HelpBlock> :
|
||||
null
|
||||
}
|
||||
</FullWidthRow>
|
||||
<Spacer />
|
||||
<BlockSaveWrapper>
|
||||
<BlockSaveButton disabled={ disableForm } />
|
||||
</BlockSaveWrapper>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
EmailForm.displayName = 'EmailForm';
|
||||
EmailForm.propTypes = propTypes;
|
||||
|
||||
export default reduxForm(
|
||||
{
|
||||
form: 'email-form',
|
||||
fields: [ 'confirmEmail', 'email' ],
|
||||
validate: validator
|
||||
},
|
||||
mapStateToProps,
|
||||
mapDispatchtoProps
|
||||
)(EmailForm);
|
95
common/app/routes/Settings/components/Honesty.jsx
Normal file
95
common/app/routes/Settings/components/Honesty.jsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Button, Panel } from 'react-bootstrap';
|
||||
import { createSelector } from 'reselect';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { FullWidthRow } from '../../../helperComponents';
|
||||
import SectionHeader from './SectionHeader.jsx';
|
||||
import { userSelector } from '../../../redux';
|
||||
import academicPolicy from '../../../../resource/academicPolicy';
|
||||
import { updateUserBackend } from '../redux';
|
||||
|
||||
const propTypes = {
|
||||
isHonest: PropTypes.bool,
|
||||
policy: PropTypes.arrayOf(PropTypes.string),
|
||||
updateUserBackend: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
const mapStateToProps = createSelector(
|
||||
userSelector,
|
||||
({ isHonest }) => ({
|
||||
policy: academicPolicy,
|
||||
isHonest
|
||||
})
|
||||
);
|
||||
|
||||
const mapDispatchToProps = { updateUserBackend };
|
||||
|
||||
class Honesty extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
showHonesty: false
|
||||
};
|
||||
|
||||
this.handleAgreeClick = this.handleAgreeClick.bind(this);
|
||||
}
|
||||
|
||||
handleAgreeClick() {
|
||||
this.props.updateUserBackend({ isHonest: true });
|
||||
}
|
||||
|
||||
render() {
|
||||
const { policy, isHonest } = this.props;
|
||||
const isHonestAgreed = (
|
||||
<Panel bsStyle='info'>
|
||||
<p>
|
||||
You have already accepted our Academic Honesty Policy
|
||||
</p>
|
||||
</Panel>
|
||||
);
|
||||
const agreeButton = (
|
||||
<Button
|
||||
block={ true }
|
||||
bsStyle='primary'
|
||||
onClick={ this.handleAgreeClick }
|
||||
>
|
||||
Agree
|
||||
</Button>
|
||||
);
|
||||
return (
|
||||
<div className='honesty-policy'>
|
||||
<SectionHeader>
|
||||
Academic Honesty Policy
|
||||
</SectionHeader>
|
||||
<FullWidthRow>
|
||||
<Panel>
|
||||
{
|
||||
policy.map(
|
||||
(line, i) => (
|
||||
<p
|
||||
dangerouslySetInnerHTML={{ __html: line }}
|
||||
key={ '' + i + line.slice(0, 10) }
|
||||
/>
|
||||
)
|
||||
)
|
||||
}
|
||||
<br />
|
||||
{
|
||||
isHonest ?
|
||||
isHonestAgreed :
|
||||
agreeButton
|
||||
}
|
||||
</Panel>
|
||||
</FullWidthRow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Honesty.displayName = 'Honesty';
|
||||
Honesty.propTypes = propTypes;
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(Honesty);
|
109
common/app/routes/Settings/components/Internet-Settings.jsx
Normal file
109
common/app/routes/Settings/components/Internet-Settings.jsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { createSelector } from 'reselect';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { reduxForm } from 'redux-form';
|
||||
|
||||
import { FullWidthRow, Spacer } from '../../../helperComponents';
|
||||
import { BlockSaveButton, BlockSaveWrapper, FormFields } from '../formHelpers';
|
||||
import SectionHeader from './SectionHeader.jsx';
|
||||
import { userSelector } from '../../../redux';
|
||||
import { updateUserBackend } from '../redux';
|
||||
|
||||
const mapStateToProps = createSelector(
|
||||
userSelector,
|
||||
({
|
||||
githubURL = '',
|
||||
linkedin = '',
|
||||
twitter = '',
|
||||
website = ''
|
||||
}) => ({
|
||||
initialValues: {
|
||||
githubURL,
|
||||
linkedin,
|
||||
twitter,
|
||||
website
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const formFields = [ 'githubURL', 'linkedin', 'twitter', 'website' ];
|
||||
|
||||
function mapDispatchToProps(dispatch) {
|
||||
return bindActionCreators({
|
||||
updateUserBackend
|
||||
}, dispatch);
|
||||
}
|
||||
|
||||
const propTypes = {
|
||||
fields: PropTypes.object,
|
||||
githubURL: PropTypes.string,
|
||||
handleSubmit: PropTypes.func.isRequired,
|
||||
linkedin: PropTypes.string,
|
||||
twitter: PropTypes.string,
|
||||
updateUserBackend: PropTypes.func.isRequired,
|
||||
username: PropTypes.string,
|
||||
website: PropTypes.string
|
||||
};
|
||||
|
||||
class InternetSettings extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
}
|
||||
|
||||
handleSubmit(values) {
|
||||
console.log(values);
|
||||
this.props.updateUserBackend(values);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
fields,
|
||||
fields: { _meta: { allPristine } },
|
||||
handleSubmit
|
||||
} = this.props;
|
||||
const options = {
|
||||
types: formFields.reduce(
|
||||
(all, current) => ({ ...all, [current]: 'url' }),
|
||||
{}
|
||||
),
|
||||
placeholder: false
|
||||
};
|
||||
return (
|
||||
<div className='internet-settings'>
|
||||
<SectionHeader>
|
||||
Your Internet Presence
|
||||
</SectionHeader>
|
||||
<FullWidthRow>
|
||||
<form
|
||||
id='internet-handle-settings'
|
||||
onSubmit={ handleSubmit(this.handleSubmit) }
|
||||
>
|
||||
<FormFields
|
||||
fields={ fields }
|
||||
options={ options }
|
||||
/>
|
||||
<Spacer />
|
||||
<BlockSaveWrapper>
|
||||
<BlockSaveButton disabled={ allPristine }/>
|
||||
</BlockSaveWrapper>
|
||||
</form>
|
||||
</FullWidthRow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
InternetSettings.displayName = 'InternetSettings';
|
||||
InternetSettings.propTypes = propTypes;
|
||||
|
||||
export default reduxForm(
|
||||
{
|
||||
form: 'internet-settings',
|
||||
fields: formFields
|
||||
},
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(InternetSettings);
|
120
common/app/routes/Settings/components/JSAlgoAndDSForm.jsx
Normal file
120
common/app/routes/Settings/components/JSAlgoAndDSForm.jsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { kebabCase } from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Button } from 'react-bootstrap';
|
||||
|
||||
import { FullWidthRow } from '../../../helperComponents';
|
||||
import { BlockSaveButton } from '../formHelpers';
|
||||
import { Link } from '../../../Router';
|
||||
import SolutionViewer from './SolutionViewer.jsx';
|
||||
|
||||
const jsFormPropTypes = {
|
||||
challenges: PropTypes.arrayOf(PropTypes.string),
|
||||
claimCert: PropTypes.func.isRequired,
|
||||
hardGoTo: PropTypes.func.isRequired,
|
||||
isCertClaimed: PropTypes.bool,
|
||||
jsProjects: PropTypes.objectOf(PropTypes.object),
|
||||
projectBlockName: PropTypes.string,
|
||||
superBlock: PropTypes.string,
|
||||
username: PropTypes.string
|
||||
};
|
||||
|
||||
const jsProjectPath = '/challenges/javascript-algorithms-and-data-structures-' +
|
||||
'projects/';
|
||||
|
||||
class JSAlgoAndDSForm extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {};
|
||||
this.handleSolutionToggle = this.handleSolutionToggle.bind(this);
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
}
|
||||
|
||||
handleSolutionToggle(e) {
|
||||
e.persist();
|
||||
return this.setState(state => ({
|
||||
...state,
|
||||
[e.target.id]: !state[e.target.id]
|
||||
}));
|
||||
}
|
||||
|
||||
handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
const { username, superBlock, isCertClaimed } = this.props;
|
||||
if (isCertClaimed) {
|
||||
return this.props.hardGoTo(`/c/${username}/${superBlock}`);
|
||||
}
|
||||
return this.props.claimCert(superBlock);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
projectBlockName,
|
||||
challenges = [],
|
||||
jsProjects = {},
|
||||
isCertClaimed
|
||||
} = this.props;
|
||||
return (
|
||||
<FullWidthRow>
|
||||
<h3 className='project-heading'>{ projectBlockName }</h3>
|
||||
<p>
|
||||
To complete this certification, you must first complete the
|
||||
JavaScript Algorithms and Data Structures project challenges
|
||||
</p>
|
||||
<ul className='solution-list'>
|
||||
{
|
||||
challenges.map(challenge => (
|
||||
<div key={ challenge }>
|
||||
<li className='solution-list-item'>
|
||||
<p>{ challenge }</p>
|
||||
{
|
||||
Object.keys(jsProjects[challenge]).length ?
|
||||
<div>
|
||||
<Button
|
||||
bsSize='lg'
|
||||
bsStyle='primary'
|
||||
id={ challenge }
|
||||
onClick={ this.handleSolutionToggle }
|
||||
>
|
||||
{ this.state[challenge] ? 'Hide' : 'Show' } Solution
|
||||
</Button>
|
||||
</div> :
|
||||
<Link to={`${jsProjectPath}${kebabCase(challenge)}`}>
|
||||
<Button
|
||||
bsSize='lg'
|
||||
bsStyle='primary'
|
||||
>
|
||||
Complete Project
|
||||
</Button>
|
||||
</Link>
|
||||
}
|
||||
</li>
|
||||
{
|
||||
this.state[challenge] ?
|
||||
<SolutionViewer files={ jsProjects[challenge] } /> :
|
||||
null
|
||||
}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
{
|
||||
Object.keys(jsProjects).length === 6 ?
|
||||
<form onSubmit={ this.handleSubmit }>
|
||||
<BlockSaveButton>
|
||||
{ isCertClaimed ? 'Show' : 'Claim'} Certificate
|
||||
</BlockSaveButton>
|
||||
</form> :
|
||||
null
|
||||
}
|
||||
<hr />
|
||||
</FullWidthRow>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
JSAlgoAndDSForm.displayName = 'JSAlgoAndDSForm';
|
||||
JSAlgoAndDSForm.propTypes = jsFormPropTypes;
|
||||
|
||||
export default JSAlgoAndDSForm;
|
@@ -2,11 +2,19 @@ import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { createSelector } from 'reselect';
|
||||
import { reduxForm } from 'redux-form';
|
||||
import { FormControl, FormGroup } from 'react-bootstrap';
|
||||
import {
|
||||
FormControl,
|
||||
FormGroup,
|
||||
ControlLabel,
|
||||
Row,
|
||||
Col
|
||||
} from 'react-bootstrap';
|
||||
|
||||
import { updateMyLang } from './redux';
|
||||
import { userSelector } from '../../redux';
|
||||
import langs from '../../../utils/supported-languages';
|
||||
import { updateMyLang } from '../redux';
|
||||
import { userSelector } from '../../../redux';
|
||||
import langs from '../../../../utils/supported-languages';
|
||||
import { FullWidthRow } from '../../../helperComponents';
|
||||
import SectionHeader from './SectionHeader.jsx';
|
||||
|
||||
const propTypes = {
|
||||
fields: PropTypes.object,
|
||||
@@ -85,18 +93,35 @@ export class LanguageSettings extends React.Component {
|
||||
fields: { lang: { name, value } }
|
||||
} = this.props;
|
||||
return (
|
||||
<FormGroup>
|
||||
<FormControl
|
||||
className='btn btn-block btn-primary btn-link-social btn-lg'
|
||||
componentClass='select'
|
||||
name={ name }
|
||||
onChange={ this.handleChange }
|
||||
style={{ height: '45px' }}
|
||||
value={ value }
|
||||
>
|
||||
{ options }
|
||||
</FormControl>
|
||||
</FormGroup>
|
||||
<div>
|
||||
<SectionHeader>
|
||||
Language Settings
|
||||
</SectionHeader>
|
||||
<FullWidthRow>
|
||||
<FormGroup>
|
||||
<Row>
|
||||
<Col sm={ 4 } xs={ 12 }>
|
||||
<ControlLabel htmlFor={ name }>
|
||||
Prefered Language for Challenges
|
||||
</ControlLabel>
|
||||
</Col>
|
||||
<Col sm={ 8 } xs={ 12 }>
|
||||
<FormControl
|
||||
className='btn btn-block btn-primary btn-lg'
|
||||
componentClass='select'
|
||||
id={ name }
|
||||
name={ name }
|
||||
onChange={ this.handleChange }
|
||||
style={{ height: '45px' }}
|
||||
value={ value }
|
||||
>
|
||||
{ options }
|
||||
</FormControl>
|
||||
</Col>
|
||||
</Row>
|
||||
</FormGroup>
|
||||
</FullWidthRow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
42
common/app/routes/Settings/components/Locked-Settings.jsx
Normal file
42
common/app/routes/Settings/components/Locked-Settings.jsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import {
|
||||
Row,
|
||||
Col,
|
||||
ControlLabel
|
||||
} from 'react-bootstrap';
|
||||
|
||||
import TB from '../Toggle-Button';
|
||||
|
||||
const propTypes = {
|
||||
isLocked: PropTypes.bool,
|
||||
toggleIsLocked: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default function LockSettings({ isLocked, toggleIsLocked }) {
|
||||
return (
|
||||
<Row className='inline-form'>
|
||||
<Col sm={ 8 } xs={ 12 }>
|
||||
<ControlLabel htmlFor='isLocked'>
|
||||
<p>
|
||||
<strong>
|
||||
Make all of my solutions private
|
||||
<br />
|
||||
<em>(this disables your certificates)</em>
|
||||
</strong>
|
||||
</p>
|
||||
</ControlLabel>
|
||||
</Col>
|
||||
<Col sm={ 4 } xs={ 12 }>
|
||||
<TB
|
||||
name='isLocked'
|
||||
onChange={ toggleIsLocked }
|
||||
value={ isLocked }
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
LockSettings.displayName = 'LockSettings';
|
||||
LockSettings.propTypes = propTypes;
|
178
common/app/routes/Settings/components/Portfolio-Settings.jsx
Normal file
178
common/app/routes/Settings/components/Portfolio-Settings.jsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { createSelector } from 'reselect';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { connect } from 'react-redux';
|
||||
import { Button } from 'react-bootstrap';
|
||||
|
||||
import { FullWidthRow, ButtonSpacer } from '../../../helperComponents';
|
||||
import SectionHeader from './SectionHeader.jsx';
|
||||
import { userSelector } from '../../../redux';
|
||||
import { addPortfolioItem } from '../../../entities';
|
||||
import { updateMyPortfolio, deletePortfolio } from '../redux';
|
||||
import {
|
||||
Form,
|
||||
maxLength,
|
||||
minLength,
|
||||
validURL
|
||||
} from '../formHelpers';
|
||||
|
||||
const minTwoChar = minLength(2);
|
||||
const max288Char = maxLength(288);
|
||||
|
||||
const propTypes = {
|
||||
addPortfolioItem: PropTypes.func.isRequired,
|
||||
deletePortfolio: PropTypes.func.isRequired,
|
||||
picture: PropTypes.string,
|
||||
portfolio: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
description: PropTypes.string,
|
||||
image: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
url: PropTypes.string
|
||||
})
|
||||
),
|
||||
updateMyPortfolio: PropTypes.func.isRequired,
|
||||
username: PropTypes.string
|
||||
};
|
||||
|
||||
const mapStateToProps = createSelector(
|
||||
userSelector,
|
||||
({ portfolio, username }) => ({
|
||||
portfolio,
|
||||
username
|
||||
})
|
||||
);
|
||||
|
||||
function mapDispatchToProps(dispatch) {
|
||||
return bindActionCreators({
|
||||
addPortfolioItem,
|
||||
deletePortfolio,
|
||||
updateMyPortfolio
|
||||
}, dispatch);
|
||||
}
|
||||
|
||||
const formFields = [ 'title', 'url', 'image', 'description', 'id' ];
|
||||
const options = {
|
||||
types: {
|
||||
id: 'hidden',
|
||||
url: 'url',
|
||||
image: 'url',
|
||||
description: 'textarea'
|
||||
},
|
||||
required: [ 'url', 'title', 'id' ]
|
||||
};
|
||||
|
||||
function validator(values) {
|
||||
const errors = {};
|
||||
const {
|
||||
title = '',
|
||||
url = '',
|
||||
description = '',
|
||||
image = ''
|
||||
} = values;
|
||||
errors.title = minTwoChar(title);
|
||||
errors.description = max288Char(description);
|
||||
errors.url = url && validURL(url);
|
||||
errors.image = image && validURL(image);
|
||||
return errors;
|
||||
}
|
||||
|
||||
class PortfolioSettings extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.handleAdd = this.handleAdd.bind(this);
|
||||
this.handleDelete = this.handleDelete.bind(this);
|
||||
this.handleSave = this.handleSave.bind(this);
|
||||
this.renderPortfolio = this.renderPortfolio.bind(this);
|
||||
}
|
||||
|
||||
handleAdd() {
|
||||
this.props.addPortfolioItem(this.props.username);
|
||||
}
|
||||
|
||||
handleDelete(id) {
|
||||
const { deletePortfolio } = this.props;
|
||||
deletePortfolio({ portfolio: { id } });
|
||||
}
|
||||
|
||||
handleSave(portfolio) {
|
||||
const { updateMyPortfolio } = this.props;
|
||||
updateMyPortfolio(portfolio);
|
||||
}
|
||||
|
||||
renderPortfolio(portfolio, index, arr) {
|
||||
const {
|
||||
id
|
||||
} = portfolio;
|
||||
return (
|
||||
<div key={ id }>
|
||||
<FullWidthRow>
|
||||
<Form
|
||||
buttonText='Save portfolio item'
|
||||
formFields={ formFields }
|
||||
id={ id }
|
||||
initialValues={ portfolio }
|
||||
options={ options }
|
||||
submit={ this.handleSave }
|
||||
validate={ validator }
|
||||
/>
|
||||
<ButtonSpacer />
|
||||
<Button
|
||||
block={ true }
|
||||
bsSize='lg'
|
||||
bsStyle='danger'
|
||||
id={`delete-${id}`}
|
||||
onClick={ () => this.handleDelete(id) }
|
||||
type='button'
|
||||
>
|
||||
Remove this portfolio item
|
||||
</Button>
|
||||
{
|
||||
index + 1 !== arr.length && <hr />
|
||||
}
|
||||
</FullWidthRow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { portfolio = [] } = this.props;
|
||||
return (
|
||||
<section id='portfolio-settings' >
|
||||
<SectionHeader>
|
||||
Portfolio Settings
|
||||
</SectionHeader>
|
||||
<FullWidthRow>
|
||||
<div className='portfolio-settings-intro'>
|
||||
<p className='p-intro'>
|
||||
Share your non-FreeCodeCamp projects, articles or accepted
|
||||
pull requests.
|
||||
</p>
|
||||
</div>
|
||||
</FullWidthRow>
|
||||
{
|
||||
portfolio.length ? portfolio.map(this.renderPortfolio) : null
|
||||
}
|
||||
<FullWidthRow>
|
||||
<ButtonSpacer />
|
||||
<Button
|
||||
block={ true }
|
||||
bsSize='lg'
|
||||
bsStyle='primary'
|
||||
onClick={ this.handleAdd }
|
||||
type='button'
|
||||
>
|
||||
Add a new portfolio Item
|
||||
</Button>
|
||||
</FullWidthRow>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
PortfolioSettings.displayName = 'PortfolioSettings';
|
||||
PortfolioSettings.propTypes = propTypes;
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(PortfolioSettings);
|
65
common/app/routes/Settings/components/ResetModal.jsx
Normal file
65
common/app/routes/Settings/components/ResetModal.jsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Modal, Button } from 'react-bootstrap';
|
||||
|
||||
const propTypes = {
|
||||
onHide: PropTypes.func.isRequired,
|
||||
reset: PropTypes.func.isRequired,
|
||||
show: PropTypes.bool
|
||||
};
|
||||
|
||||
function ResetModal(props) {
|
||||
const { show, onHide } = props;
|
||||
return (
|
||||
<Modal
|
||||
aria-labelledby='modal-title'
|
||||
autoFocus={ true }
|
||||
backdrop={ true }
|
||||
bsSize='lg'
|
||||
keyboard={ true }
|
||||
onHide={ onHide }
|
||||
show={ show }
|
||||
>
|
||||
<Modal.Header closeButton={ true }>
|
||||
<Modal.Title id='modal-title'>Reset My Progress</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<p>
|
||||
This will really delete all of your progress, points, completed
|
||||
challenges, our records of your projects, any certificates you have,
|
||||
everything.
|
||||
</p>
|
||||
<p>
|
||||
We won't be able to recover any of it for you later, even if you
|
||||
change your mind.
|
||||
</p>
|
||||
<hr />
|
||||
<Button
|
||||
block={ true }
|
||||
bsSize='lg'
|
||||
bsStyle='success'
|
||||
onClick={ props.onHide }
|
||||
>
|
||||
Nevermind, I don't want to delete all of my progress
|
||||
</Button>
|
||||
<div className='button-spacer' />
|
||||
<Button
|
||||
block={ true }
|
||||
bsSize='lg'
|
||||
bsStyle='danger'
|
||||
onClick={ () =>{ props.reset(); return props.onHide(); } }
|
||||
>
|
||||
Reset everything. I want to start from the beginning
|
||||
</Button>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button onClick={ props.onHide }>Close</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
ResetModal.displayName = 'ResetModal';
|
||||
ResetModal.propTypes = propTypes;
|
||||
|
||||
export default ResetModal;
|
25
common/app/routes/Settings/components/SectionHeader.jsx
Normal file
25
common/app/routes/Settings/components/SectionHeader.jsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FullWidthRow } from '../../../helperComponents';
|
||||
|
||||
const propTypes = {
|
||||
children: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.element,
|
||||
PropTypes.node
|
||||
])
|
||||
};
|
||||
|
||||
function SectionHeader({ children }) {
|
||||
return (
|
||||
<FullWidthRow>
|
||||
<h2>{ children }</h2>
|
||||
<hr />
|
||||
</FullWidthRow>
|
||||
);
|
||||
}
|
||||
|
||||
SectionHeader.displayName = 'SectionHeader';
|
||||
SectionHeader.propTypes = propTypes;
|
||||
|
||||
export default SectionHeader;
|
134
common/app/routes/Settings/components/SocialIcons.jsx
Normal file
134
common/app/routes/Settings/components/SocialIcons.jsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { createSelector } from 'reselect';
|
||||
import { connect } from 'react-redux';
|
||||
import {
|
||||
Row,
|
||||
Col
|
||||
} from 'react-bootstrap';
|
||||
import FontAwesome from 'react-fontawesome';
|
||||
|
||||
import { userSelector } from '../../../redux';
|
||||
|
||||
const propTypes = {
|
||||
email: PropTypes.string,
|
||||
githubURL: PropTypes.string,
|
||||
isGithub: PropTypes.bool,
|
||||
isLinkedIn: PropTypes.bool,
|
||||
isTwitter: PropTypes.bool,
|
||||
isWebsite: PropTypes.bool,
|
||||
linkedIn: PropTypes.string,
|
||||
twitter: PropTypes.string,
|
||||
website: PropTypes.string
|
||||
};
|
||||
|
||||
const mapStateToProps = createSelector(
|
||||
userSelector,
|
||||
({
|
||||
githubURL,
|
||||
isLinkedIn,
|
||||
isGithub,
|
||||
isTwitter,
|
||||
isWebsite,
|
||||
linkedIn,
|
||||
twitter,
|
||||
website
|
||||
}) => ({
|
||||
githubURL,
|
||||
isLinkedIn,
|
||||
isGithub,
|
||||
isTwitter,
|
||||
isWebsite,
|
||||
linkedIn,
|
||||
twitter,
|
||||
website
|
||||
})
|
||||
);
|
||||
|
||||
function mapDispatchToProps() {
|
||||
return {};
|
||||
}
|
||||
|
||||
function LinkedInIcon(linkedIn) {
|
||||
return (
|
||||
<a href={ linkedIn } rel='no-follow' target='_blank'>
|
||||
<FontAwesome
|
||||
name='linkedin'
|
||||
size='2x'
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function githubIcon(ghURL) {
|
||||
return (
|
||||
<a href={ ghURL } rel='no-follow' target='_blank'>
|
||||
<FontAwesome
|
||||
name='github'
|
||||
size='2x'
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function WebsiteIcon(website) {
|
||||
return (
|
||||
<a href={ website } rel='no-follow' target='_blank'>
|
||||
<FontAwesome
|
||||
name='link'
|
||||
size='2x'
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function TwitterIcon(handle) {
|
||||
return (
|
||||
<a href={ handle } rel='no-follow' target='_blank' >
|
||||
<FontAwesome
|
||||
name='twitter'
|
||||
size='2x'
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function SocialIcons(props) {
|
||||
const {
|
||||
githubURL,
|
||||
isLinkedIn,
|
||||
isGithub,
|
||||
isTwitter,
|
||||
isWebsite,
|
||||
linkedIn,
|
||||
twitter,
|
||||
website
|
||||
} = props;
|
||||
return (
|
||||
<Row>
|
||||
<Col
|
||||
className='text-center social-media-icons'
|
||||
sm={ 6 }
|
||||
smOffset={ 3 }
|
||||
>
|
||||
{
|
||||
isLinkedIn ? LinkedInIcon(linkedIn) : null
|
||||
}
|
||||
{
|
||||
isGithub ? githubIcon(githubURL) : null
|
||||
}
|
||||
{
|
||||
isWebsite ? WebsiteIcon(website) : null
|
||||
}
|
||||
{
|
||||
isTwitter ? TwitterIcon(twitter) : null
|
||||
}
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
SocialIcons.displayName = 'SocialIcons';
|
||||
SocialIcons.propTypes = propTypes;
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(SocialIcons);
|
53
common/app/routes/Settings/components/SolutionViewer.jsx
Normal file
53
common/app/routes/Settings/components/SolutionViewer.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Panel } from 'react-bootstrap';
|
||||
import Prism from 'prismjs';
|
||||
import Helmet from 'react-helmet';
|
||||
|
||||
const prismLang = {
|
||||
css: 'css',
|
||||
js: 'javascript',
|
||||
jsx: 'javascript',
|
||||
html: 'markup'
|
||||
};
|
||||
|
||||
function SolutionViewer({ files }) {
|
||||
return (
|
||||
<div>
|
||||
<Helmet>
|
||||
<link href='/css/prism.css' rel='stylesheet' />
|
||||
</Helmet>
|
||||
{
|
||||
Object.keys(files)
|
||||
.map(key => files[key])
|
||||
.map(file => (
|
||||
<Panel
|
||||
bsStyle='primary'
|
||||
className='solution-viewer'
|
||||
header={ file.ext.toUpperCase() }
|
||||
key={ file.ext }
|
||||
>
|
||||
<pre>
|
||||
<code
|
||||
className={ `language-${prismLang[file.ext]}` }
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Prism.highlight(
|
||||
file.contents.trim(),
|
||||
Prism.languages[prismLang[file.ext]]
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</pre>
|
||||
</Panel>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
SolutionViewer.displayName = 'SolutionViewer';
|
||||
SolutionViewer.propTypes = {
|
||||
files: PropTypes.object
|
||||
};
|
||||
|
||||
export default SolutionViewer;
|
38
common/app/routes/Settings/components/ThemeSettings.jsx
Normal file
38
common/app/routes/Settings/components/ThemeSettings.jsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import {
|
||||
Row,
|
||||
Col,
|
||||
ControlLabel
|
||||
} from 'react-bootstrap';
|
||||
|
||||
import TB from '../Toggle-Button';
|
||||
|
||||
const propTypes = {
|
||||
currentTheme: PropTypes.string.isRequired,
|
||||
toggleNightMode: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default function ThemeSettings({ currentTheme, toggleNightMode }) {
|
||||
return (
|
||||
<Row className='inline-form'>
|
||||
<Col sm={ 8 } xs={ 12 }>
|
||||
<ControlLabel htmlFor='night-mode'>
|
||||
<p className='settings-title'>
|
||||
<strong>Night Mode</strong>
|
||||
</p>
|
||||
</ControlLabel>
|
||||
</Col>
|
||||
<Col sm={ 4 } xs={ 12 }>
|
||||
<TB
|
||||
name='night-mode'
|
||||
onChange={ () => toggleNightMode(currentTheme) }
|
||||
value={ currentTheme === 'night' }
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
ThemeSettings.displayName = 'ThemeSettings';
|
||||
ThemeSettings.propTypes = propTypes;
|
187
common/app/routes/Settings/components/UsernameSettings.jsx
Normal file
187
common/app/routes/Settings/components/UsernameSettings.jsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
Col,
|
||||
ControlLabel,
|
||||
FormControl,
|
||||
Alert
|
||||
} from 'react-bootstrap';
|
||||
import { reduxForm } from 'redux-form';
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import {
|
||||
settingsSelector,
|
||||
updateUserBackend,
|
||||
validateUsername
|
||||
} from '../redux';
|
||||
import { userSelector } from '../../../redux';
|
||||
import { BlockSaveButton, minLength } from '../formHelpers';
|
||||
import { FullWidthRow } from '../../../helperComponents';
|
||||
|
||||
const minTwoChar = minLength(2);
|
||||
|
||||
const propTypes = {
|
||||
fields: PropTypes.objectOf(
|
||||
PropTypes.shape({
|
||||
error: PropTypes.string,
|
||||
name: PropTypes.string.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
pristine: PropTypes.bool.isRequired,
|
||||
value: PropTypes.string.isRequired
|
||||
})
|
||||
),
|
||||
handleSubmit: PropTypes.func.isRequired,
|
||||
isValidUsername: PropTypes.bool,
|
||||
submitAction: PropTypes.func.isRequired,
|
||||
username: PropTypes.string,
|
||||
validateUsername: PropTypes.func.isRequired,
|
||||
validating: PropTypes.bool
|
||||
};
|
||||
|
||||
const mapStateToProps = createSelector(
|
||||
userSelector,
|
||||
settingsSelector,
|
||||
({ username }, { isValidUsername, validating }) => ({
|
||||
initialValues: { username },
|
||||
isValidUsername,
|
||||
validate: validator,
|
||||
validating
|
||||
})
|
||||
);
|
||||
|
||||
const mapDispatchToProps = {
|
||||
validateUsername,
|
||||
submitAction: updateUserBackend
|
||||
};
|
||||
function normalise(str = '') {
|
||||
return str.toLowerCase().trim();
|
||||
}
|
||||
|
||||
function makeHandleChange(changeFn, validationAction, valid) {
|
||||
return function handleChange(e) {
|
||||
const { value } = e.target;
|
||||
e.target.value = normalise(value);
|
||||
if (e.target.value && valid) {
|
||||
validationAction(value);
|
||||
}
|
||||
return changeFn(e);
|
||||
};
|
||||
}
|
||||
|
||||
function validator(values) {
|
||||
const errors = {};
|
||||
const { username } = values;
|
||||
const minWarn = minTwoChar(username);
|
||||
if (minWarn) {
|
||||
errors.username = minWarn;
|
||||
return errors;
|
||||
}
|
||||
if (username.length === 0) {
|
||||
errors.username = 'Username cannot be empty';
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function renderAlerts(validating, error, isValidUsername) {
|
||||
if (!validating && error) {
|
||||
return (
|
||||
<FullWidthRow>
|
||||
<Alert bsStyle='danger'>
|
||||
{ error }
|
||||
</Alert>
|
||||
</FullWidthRow>
|
||||
);
|
||||
}
|
||||
if (!validating && !isValidUsername) {
|
||||
return (
|
||||
<FullWidthRow>
|
||||
<Alert bsStyle='danger'>
|
||||
Username not available
|
||||
</Alert>
|
||||
</FullWidthRow>
|
||||
);
|
||||
}
|
||||
if (validating) {
|
||||
return (
|
||||
<FullWidthRow>
|
||||
<Alert bsStyle='info'>
|
||||
Validating username
|
||||
</Alert>
|
||||
</FullWidthRow>
|
||||
);
|
||||
}
|
||||
if (!validating && isValidUsername) {
|
||||
return (
|
||||
<FullWidthRow>
|
||||
<Alert bsStyle='success'>
|
||||
Username is available
|
||||
</Alert>
|
||||
</FullWidthRow>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function UsernameSettings(props) {
|
||||
const {
|
||||
fields: {
|
||||
username: {
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
pristine,
|
||||
valid
|
||||
}
|
||||
},
|
||||
handleSubmit,
|
||||
isValidUsername,
|
||||
submitAction,
|
||||
validateUsername,
|
||||
validating
|
||||
} = props;
|
||||
return (
|
||||
<div>
|
||||
{
|
||||
!pristine && renderAlerts(validating, error, isValidUsername)
|
||||
}
|
||||
<FullWidthRow>
|
||||
<form
|
||||
className='inline-form-field'
|
||||
id='usernameSettings'
|
||||
onSubmit={ handleSubmit(submitAction) }
|
||||
>
|
||||
<Col className='inline-form' sm={ 3 } xs={ 12 }>
|
||||
<ControlLabel htmlFor='username-settings'>
|
||||
<strong>Username</strong>
|
||||
</ControlLabel>
|
||||
</Col>
|
||||
<Col sm={ 7 } xs={ 12 }>
|
||||
<FormControl
|
||||
name='username-settings'
|
||||
onChange={ makeHandleChange(onChange, validateUsername, valid) }
|
||||
value={ value }
|
||||
/>
|
||||
</Col>
|
||||
<Col sm={ 2 } xs={ 12 }>
|
||||
<BlockSaveButton disabled={
|
||||
!(isValidUsername && valid && !pristine)
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</form>
|
||||
</FullWidthRow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
UsernameSettings.displayName = 'UsernameSettings';
|
||||
UsernameSettings.propTypes = propTypes;
|
||||
|
||||
export default reduxForm(
|
||||
{
|
||||
form: 'usernameSettings',
|
||||
fields: [ 'username' ]
|
||||
},
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(UsernameSettings);
|
24
common/app/routes/Settings/formHelpers/BlockSaveButton.jsx
Normal file
24
common/app/routes/Settings/formHelpers/BlockSaveButton.jsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Button } from 'react-bootstrap';
|
||||
|
||||
function BlockSaveButton(props) {
|
||||
return (
|
||||
<Button
|
||||
block={ true }
|
||||
bsSize='lg'
|
||||
bsStyle='primary'
|
||||
{...props}
|
||||
type='submit'
|
||||
>
|
||||
{ props.children || 'Save' }
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
BlockSaveButton.displayName = 'BlockSaveButton';
|
||||
BlockSaveButton.propTypes = {
|
||||
children: PropTypes.any
|
||||
};
|
||||
|
||||
export default BlockSaveButton;
|
23
common/app/routes/Settings/formHelpers/BlockSaveWrapper.jsx
Normal file
23
common/app/routes/Settings/formHelpers/BlockSaveWrapper.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const propTypes = {
|
||||
children: PropTypes.node
|
||||
};
|
||||
|
||||
const style = {
|
||||
padding: '0 15px'
|
||||
};
|
||||
|
||||
function BlockSaveWrapper({ children }) {
|
||||
return (
|
||||
<div style={ style }>
|
||||
{ children }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
BlockSaveWrapper.displayName = 'BlockSaveWrapper';
|
||||
BlockSaveWrapper.propTypes = propTypes;
|
||||
|
||||
export default BlockSaveWrapper;
|
88
common/app/routes/Settings/formHelpers/Form.jsx
Normal file
88
common/app/routes/Settings/formHelpers/Form.jsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { reduxForm } from 'redux-form';
|
||||
|
||||
import { FormFields, BlockSaveButton, BlockSaveWrapper } from './';
|
||||
|
||||
const propTypes = {
|
||||
buttonText: PropTypes.string,
|
||||
enableSubmit: PropTypes.bool,
|
||||
errors: PropTypes.object,
|
||||
fields: PropTypes.objectOf(
|
||||
PropTypes.shape({
|
||||
name: PropTypes.string.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
value: PropTypes.string.isRequired
|
||||
})
|
||||
),
|
||||
formFields: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
handleSubmit: PropTypes.func,
|
||||
hideButton: PropTypes.bool,
|
||||
id: PropTypes.string.isRequired,
|
||||
initialValues: PropTypes.object,
|
||||
options: PropTypes.shape({
|
||||
ignored: PropTypes.arrayOf(PropTypes.string),
|
||||
required: PropTypes.arrayOf(PropTypes.string),
|
||||
types: PropTypes.objectOf(PropTypes.string)
|
||||
}),
|
||||
submit: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
function DynamicForm({
|
||||
// redux-form
|
||||
errors,
|
||||
fields,
|
||||
handleSubmit,
|
||||
fields: { _meta: { allPristine }},
|
||||
|
||||
// HOC
|
||||
buttonText,
|
||||
enableSubmit,
|
||||
hideButton,
|
||||
id,
|
||||
options,
|
||||
submit
|
||||
}) {
|
||||
return (
|
||||
<form id={`dynamic-${id}`} onSubmit={ handleSubmit(submit) }>
|
||||
<FormFields
|
||||
errors={ errors }
|
||||
fields={ fields }
|
||||
options={ options }
|
||||
/>
|
||||
<BlockSaveWrapper>
|
||||
{
|
||||
hideButton ?
|
||||
null :
|
||||
<BlockSaveButton
|
||||
disabled={
|
||||
allPristine && !enableSubmit ||
|
||||
(!!Object.keys(errors).filter(key => errors[key]).length)
|
||||
}
|
||||
>
|
||||
{
|
||||
buttonText ? buttonText : null
|
||||
}
|
||||
</BlockSaveButton>
|
||||
}
|
||||
</BlockSaveWrapper>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
DynamicForm.displayName = 'DynamicForm';
|
||||
DynamicForm.propTypes = propTypes;
|
||||
|
||||
const DynamicFormWithRedux = reduxForm()(DynamicForm);
|
||||
|
||||
export default function Form(props) {
|
||||
return (
|
||||
<DynamicFormWithRedux
|
||||
{...props}
|
||||
fields={ props.formFields }
|
||||
form={ props.id }
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Form.propTypes = propTypes;
|
97
common/app/routes/Settings/formHelpers/FormFields.jsx
Normal file
97
common/app/routes/Settings/formHelpers/FormFields.jsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import React from 'react';
|
||||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
Alert,
|
||||
Col,
|
||||
ControlLabel,
|
||||
FormControl,
|
||||
HelpBlock,
|
||||
Row
|
||||
} from 'react-bootstrap';
|
||||
|
||||
const propTypes = {
|
||||
errors: PropTypes.objectOf(PropTypes.string),
|
||||
fields: PropTypes.objectOf(
|
||||
PropTypes.shape({
|
||||
name: PropTypes.string.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
value: PropTypes.string.isRequired
|
||||
})
|
||||
).isRequired,
|
||||
options: PropTypes.shape({
|
||||
errors: PropTypes.objectOf(
|
||||
PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.instanceOf(null)
|
||||
])
|
||||
),
|
||||
ignored: PropTypes.arrayOf(PropTypes.string),
|
||||
placeholder: PropTypes.bool,
|
||||
required: PropTypes.arrayOf(PropTypes.string),
|
||||
types: PropTypes.objectOf(PropTypes.string)
|
||||
})
|
||||
};
|
||||
|
||||
function FormFields(props) {
|
||||
const { errors = {}, fields, options = {} } = props;
|
||||
const {
|
||||
ignored = [],
|
||||
placeholder = true,
|
||||
required = [],
|
||||
types = {}
|
||||
} = options;
|
||||
return (
|
||||
<div>
|
||||
{
|
||||
Object.keys(fields)
|
||||
.filter(field => !ignored.includes(field))
|
||||
.map(key => fields[key])
|
||||
.map(({ name, onChange, value, pristine }) => {
|
||||
const key = _.kebabCase(name);
|
||||
const type = name in types ? types[name] : 'text';
|
||||
return (
|
||||
<Row className='inline-form-field' key={ key }>
|
||||
<Col sm={ 3 } xs={ 12 }>
|
||||
{ type === 'hidden' ?
|
||||
null :
|
||||
<ControlLabel htmlFor={ key }>
|
||||
{ _.startCase(name) }
|
||||
</ControlLabel>
|
||||
}
|
||||
</Col>
|
||||
<Col sm={ 9 } xs={ 12 }>
|
||||
<FormControl
|
||||
bsSize='lg'
|
||||
componentClass={ type === 'textarea' ? type : 'input' }
|
||||
id={ key }
|
||||
name={ name }
|
||||
onChange={ onChange }
|
||||
placeholder={ placeholder ? name : '' }
|
||||
required={ !!required[name] }
|
||||
rows={ 4 }
|
||||
type={ type }
|
||||
value={ value }
|
||||
/>
|
||||
{
|
||||
name in errors && !pristine ?
|
||||
<HelpBlock>
|
||||
<Alert bsStyle='danger'>
|
||||
{ errors[name] }
|
||||
</Alert>
|
||||
</HelpBlock> :
|
||||
null
|
||||
}
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
FormFields.displayName = 'FormFields';
|
||||
FormFields.propTypes = propTypes;
|
||||
|
||||
export default FormFields;
|
35
common/app/routes/Settings/formHelpers/index.js
Normal file
35
common/app/routes/Settings/formHelpers/index.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import { isEmail, isURL } from 'validator';
|
||||
|
||||
/** Components **/
|
||||
|
||||
export { default as BlockSaveButton } from './BlockSaveButton.jsx';
|
||||
export { default as BlockSaveWrapper } from './BlockSaveWrapper.jsx';
|
||||
export { default as Form } from './Form.jsx';
|
||||
export { default as FormFields } from './FormFields.jsx';
|
||||
|
||||
/** Normalise **/
|
||||
|
||||
export function lowerAndTrim(str = '') {
|
||||
return str.toLowerCase().trim();
|
||||
}
|
||||
|
||||
/** Validation **/
|
||||
|
||||
export function maxLength(max) {
|
||||
return value => value && value.length > max ?
|
||||
`Must be ${max} characters or less` :
|
||||
null;
|
||||
}
|
||||
|
||||
export function minLength(min) {
|
||||
return value => value && value.length < min ?
|
||||
`Must be ${min} characters or more` :
|
||||
null;
|
||||
}
|
||||
export function validEmail(email) {
|
||||
return isEmail(email) ? null : 'Must be a valid email';
|
||||
}
|
||||
|
||||
export function validURL(str) {
|
||||
return isURL(str) ? null : 'Must be a valid URL';
|
||||
}
|
42
common/app/routes/Settings/redux/certificate-epic.js
Normal file
42
common/app/routes/Settings/redux/certificate-epic.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Observable } from 'rx';
|
||||
import { ofType } from 'redux-epic';
|
||||
|
||||
import { doActionOnError, fetchUser } from '../../../redux';
|
||||
import { makeToast } from '../../../Toasts/redux';
|
||||
import { postJSON$ } from '../../../../utils/ajax-stream';
|
||||
import {
|
||||
types,
|
||||
claimCertComplete,
|
||||
claimCertError
|
||||
} from '../redux';
|
||||
|
||||
function certificateEpic(actions$, { getState }) {
|
||||
const start = actions$::ofType(types.claimCert.start)
|
||||
.flatMap(({ payload: superBlock }) => {
|
||||
const {
|
||||
app: { csrfToken: _csrf }
|
||||
} = getState();
|
||||
return postJSON$('/certificate/verify', { _csrf, superBlock });
|
||||
})
|
||||
.map(claimCertComplete)
|
||||
.catch(doActionOnError(error => claimCertError(error)));
|
||||
|
||||
const complete = actions$::ofType(types.claimCert.complete)
|
||||
.flatMap(({ meta: { message, success }}) => Observable.if(
|
||||
() => success,
|
||||
Observable.of(fetchUser(), makeToast({ message })),
|
||||
Observable.of(makeToast({ message }))
|
||||
));
|
||||
|
||||
const error = actions$::ofType(types.claimCert.error)
|
||||
.flatMap(error => {
|
||||
return Observable.of(
|
||||
makeToast({ message: 'Something went wrong updating your account' }),
|
||||
{ type: 'error', error}
|
||||
);
|
||||
});
|
||||
|
||||
return Observable.merge(start, complete, error);
|
||||
}
|
||||
|
||||
export default certificateEpic;
|
53
common/app/routes/Settings/redux/danger-zone-epic.js
Normal file
53
common/app/routes/Settings/redux/danger-zone-epic.js
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Observable } from 'rx';
|
||||
import { ofType } from 'redux-epic';
|
||||
|
||||
import {
|
||||
types,
|
||||
resetProgressError,
|
||||
deleteAccountError,
|
||||
deleteAccountComplete
|
||||
} from './';
|
||||
import { postJSON$ } from '../../../../utils/ajax-stream';
|
||||
import {
|
||||
doActionOnError,
|
||||
hardGoTo,
|
||||
createErrorObservable
|
||||
} from '../../../redux';
|
||||
|
||||
function dangerZoneEpic(actions$, { getState }) {
|
||||
/** Reset Progress **/
|
||||
const resetStart = actions$::ofType(types.resetProgress.start)
|
||||
.flatMap(() => {
|
||||
const { csrfToken: _csrf } = getState().app;
|
||||
return postJSON$('/account/reset-progress', { _csrf })
|
||||
.map(() => hardGoTo('/'))
|
||||
.catch(doActionOnError(error => resetProgressError(error)));
|
||||
});
|
||||
const resetError = actions$::ofType(types.resetProgress.error)
|
||||
.flatMap(createErrorObservable);
|
||||
|
||||
/** Delete Account **/
|
||||
const deleteStart = actions$::ofType(types.deleteAccount.start)
|
||||
.flatMap(() => {
|
||||
const { csrfToken: _csrf } = getState().app;
|
||||
return postJSON$('/account/delete', { _csrf })
|
||||
.map(deleteAccountComplete)
|
||||
.catch(doActionOnError(error => deleteAccountError(error)));
|
||||
});
|
||||
|
||||
const deleteComplete = actions$::ofType(types.deleteAccount.complete)
|
||||
.map(() => hardGoTo('/'));
|
||||
|
||||
const deleteError = actions$::ofType(types.deleteAccount.error)
|
||||
.flatMap(createErrorObservable);
|
||||
|
||||
return Observable.merge(
|
||||
resetStart,
|
||||
resetError,
|
||||
deleteStart,
|
||||
deleteComplete,
|
||||
deleteError
|
||||
).filter(Boolean);
|
||||
}
|
||||
|
||||
export default dangerZoneEpic;
|
@@ -1,23 +1,46 @@
|
||||
import { isLocationAction } from 'redux-first-router';
|
||||
import {
|
||||
addNS,
|
||||
composeReducers,
|
||||
createAction,
|
||||
createAsyncTypes,
|
||||
createTypes
|
||||
createTypes,
|
||||
handleActions
|
||||
} from 'berkeleys-redux-utils';
|
||||
import { identity } from 'lodash';
|
||||
|
||||
import userUpdateEpic from './update-user-epic.js';
|
||||
import certificateEpic from './certificate-epic';
|
||||
import dangerZoneEpic from './danger-zone-epic';
|
||||
import userUpdateEpic from './update-user-epic';
|
||||
import newUsernameEpic from './new-username-epic';
|
||||
import ns from '../ns.json';
|
||||
import { utils } from '../../../Flash/redux';
|
||||
|
||||
export const epics = [
|
||||
certificateEpic,
|
||||
dangerZoneEpic,
|
||||
newUsernameEpic,
|
||||
userUpdateEpic
|
||||
];
|
||||
|
||||
const createActionWithFlash = type => createAction(
|
||||
type,
|
||||
null,
|
||||
utils.createFlashMetaAction
|
||||
);
|
||||
|
||||
export const types = createTypes([
|
||||
'toggleUserFlag',
|
||||
createAsyncTypes('claimCert'),
|
||||
createAsyncTypes('updateMyEmail'),
|
||||
createAsyncTypes('updateUserBackend'),
|
||||
createAsyncTypes('deletePortfolio'),
|
||||
createAsyncTypes('updateMyPortfolio'),
|
||||
'updateMyLang',
|
||||
'updateNewUsernameValidity',
|
||||
createAsyncTypes('validateUsername'),
|
||||
createAsyncTypes('refetchChallengeMap'),
|
||||
createAsyncTypes('deleteAccount'),
|
||||
createAsyncTypes('resetProgress'),
|
||||
|
||||
'onRouteSettings',
|
||||
'onRouteUpdateEmail'
|
||||
], 'settings');
|
||||
@@ -25,34 +48,92 @@ export const types = createTypes([
|
||||
|
||||
export const onRouteSettings = createAction(types.onRouteSettings);
|
||||
export const onRouteUpdateEmail = createAction(types.onRouteUpdateEmail);
|
||||
export const toggleUserFlag = createAction(types.toggleUserFlag);
|
||||
|
||||
export const claimCert = createAction(types.claimCert.start);
|
||||
export const claimCertComplete = createAction(
|
||||
types.claimCert.complete,
|
||||
({ result }) => result,
|
||||
identity
|
||||
);
|
||||
export const claimCertError = createAction(
|
||||
types.claimCert.error,
|
||||
identity
|
||||
);
|
||||
|
||||
export const updateUserBackend = createAction(types.updateUserBackend.start);
|
||||
export const updateUserBackendComplete = createActionWithFlash(
|
||||
types.updateUserBackend.complete
|
||||
);
|
||||
export const updateUserBackendError = createActionWithFlash(
|
||||
types.updateUserBackend.error
|
||||
);
|
||||
|
||||
export const updateMyEmail = createAction(types.updateMyEmail.start);
|
||||
export const updateMyEmailComplete = createAction(
|
||||
types.updateMyEmail.complete,
|
||||
null,
|
||||
utils.createFlashMetaAction
|
||||
export const updateMyEmailComplete = createActionWithFlash(
|
||||
types.updateMyEmail.complete
|
||||
);
|
||||
export const updateMyEmailError = createActionWithFlash(
|
||||
types.updateMyEmail.error
|
||||
);
|
||||
|
||||
export const updateMyEmailError = createAction(
|
||||
types.updateMyEmail.error,
|
||||
null,
|
||||
utils.createFlashMetaAction
|
||||
export const updateMyPortfolio = createAction(types.updateMyPortfolio.start);
|
||||
export const updateMyPortfolioComplete = createAction(
|
||||
types.updateMyPortfolio.complete
|
||||
);
|
||||
|
||||
export const updateMyPortfolioError = createAction(
|
||||
types.updateMyPortfolio.error
|
||||
);
|
||||
export const deletePortfolio = createAction(types.deletePortfolio.start);
|
||||
export const deletePortfolioError = createAction(types.deletePortfolio.error);
|
||||
export const updateMyLang = createAction(
|
||||
types.updateMyLang,
|
||||
(values) => values.lang
|
||||
);
|
||||
|
||||
export const resetProgress = createAction(types.resetProgress.start);
|
||||
export const resetProgressComplete = createAction(types.resetProgress.complete);
|
||||
export const resetProgressError = createAction(
|
||||
types.resetProgress.error,
|
||||
identity
|
||||
);
|
||||
|
||||
export const deleteAccount = createAction(types.deleteAccount.start);
|
||||
export const deleteAccountComplete = createAction(types.deleteAccount.complete);
|
||||
export const deleteAccountError = createAction(
|
||||
types.deleteAccount.error,
|
||||
identity
|
||||
);
|
||||
|
||||
export const updateNewUsernameValidity = createAction(
|
||||
types.updateNewUsernameValidity
|
||||
);
|
||||
|
||||
export const refetchChallengeMap = createAction(
|
||||
types.refetchChallengeMap.start
|
||||
);
|
||||
|
||||
export const validateUsername = createAction(types.validateUsername.start);
|
||||
export const validateUsernameError = createAction(
|
||||
types.validateUsername.error,
|
||||
identity
|
||||
);
|
||||
|
||||
const defaultState = {
|
||||
showUpdateEmailView: false
|
||||
showUpdateEmailView: false,
|
||||
isValidUsername: false,
|
||||
validating: false
|
||||
};
|
||||
|
||||
const getNS = state => state[ns];
|
||||
|
||||
export function settingsSelector(state) {
|
||||
return getNS(state);
|
||||
}
|
||||
|
||||
export const showUpdateEmailViewSelector =
|
||||
state => getNS(state).showUpdateEmailView;
|
||||
|
||||
export default addNS(
|
||||
export default composeReducers(
|
||||
ns,
|
||||
function settingsRouteReducer(state = defaultState, action) {
|
||||
if (isLocationAction(action)) {
|
||||
@@ -71,5 +152,20 @@ export default addNS(
|
||||
}
|
||||
}
|
||||
return state;
|
||||
}
|
||||
},
|
||||
handleActions(() => ({
|
||||
[types.updateNewUsernameValidity]: (state, { payload }) => ({
|
||||
...state,
|
||||
isValidUsername: payload,
|
||||
validating: false
|
||||
}),
|
||||
[types.validateUsername.start]: state => ({
|
||||
...state,
|
||||
isValidUsername: false,
|
||||
validating: true
|
||||
}),
|
||||
[types.validateUsername.error]: state => ({ ...state, validating: false })
|
||||
}),
|
||||
defaultState
|
||||
)
|
||||
);
|
||||
|
30
common/app/routes/Settings/redux/new-username-epic.js
Normal file
30
common/app/routes/Settings/redux/new-username-epic.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Observable } from 'rx';
|
||||
import { ofType } from 'redux-epic';
|
||||
|
||||
import {
|
||||
types,
|
||||
updateNewUsernameValidity,
|
||||
validateUsernameError
|
||||
} from './';
|
||||
import { getJSON$ } from '../../../../utils/ajax-stream';
|
||||
import {
|
||||
doActionOnError,
|
||||
createErrorObservable
|
||||
} from '../../../redux';
|
||||
|
||||
function validateUsernameEpic(actions$) {
|
||||
const start = actions$::ofType(types.validateUsername.start)
|
||||
.debounce(500)
|
||||
.flatMap(({ payload }) =>
|
||||
getJSON$(`/api/users/exists?username=${payload}`)
|
||||
.map(({ exists }) => updateNewUsernameValidity(!exists))
|
||||
.catch(error => doActionOnError(() => validateUsernameError(error)))
|
||||
);
|
||||
|
||||
const error = actions$::ofType(types.validateUsername.error)
|
||||
.flatMap(createErrorObservable);
|
||||
|
||||
return Observable.merge(start, error);
|
||||
}
|
||||
|
||||
export default validateUsernameEpic;
|
@@ -1,31 +1,176 @@
|
||||
import { Observable } from 'rx';
|
||||
import { combineEpics, ofType } from 'redux-epic';
|
||||
|
||||
import { types, onRouteSettings } from './';
|
||||
import { pick } from 'lodash';
|
||||
import {
|
||||
types,
|
||||
onRouteSettings,
|
||||
refetchChallengeMap,
|
||||
updateUserBackendComplete,
|
||||
updateMyPortfolioComplete
|
||||
} from './';
|
||||
import { makeToast } from '../../../Toasts/redux';
|
||||
import {
|
||||
fetchChallenges,
|
||||
updateChallenges,
|
||||
doActionOnError,
|
||||
userSelector
|
||||
usernameSelector,
|
||||
userSelector,
|
||||
createErrorObservable
|
||||
} from '../../../redux';
|
||||
import {
|
||||
updateUserFlag,
|
||||
updateUserEmail,
|
||||
updateUserLang
|
||||
updateUserLang,
|
||||
updateMultipleUserFlags,
|
||||
regresPortfolio,
|
||||
optoUpdatePortfolio
|
||||
} from '../../../entities';
|
||||
|
||||
import { postJSON$ } from '../../../../utils/ajax-stream';
|
||||
import langs from '../../../../utils/supported-languages';
|
||||
|
||||
const urlMap = {
|
||||
isLocked: 'lockdown',
|
||||
isAvailableForHire: 'available-for-hire',
|
||||
sendQuincyEmail: 'quincy-email',
|
||||
sendNotificationEmail: 'notification-email',
|
||||
sendMonthlyEmail: 'announcement-email'
|
||||
const endpoints = {
|
||||
email: '/update-my-email',
|
||||
projects: '/update-my-projects',
|
||||
username: '/update-my-username'
|
||||
};
|
||||
|
||||
export function updateUserEmailEpic(actions, { getState }) {
|
||||
function backendUserUpdateEpic(actions$, { getState }) {
|
||||
const start = actions$::ofType(types.updateUserBackend.start);
|
||||
const server = start
|
||||
.flatMap(({ payload }) => {
|
||||
const userMap = userSelector(getState());
|
||||
const { username } = userMap;
|
||||
const flagsToCheck = Object.keys(payload);
|
||||
const valuesToCheck = pick(userMap, flagsToCheck);
|
||||
const oldValues = {
|
||||
...flagsToCheck.reduce((accu, current) => ({ ...accu, [current]: '' })),
|
||||
...valuesToCheck
|
||||
};
|
||||
const valuesToUpdate = flagsToCheck.reduce((accu, current) => {
|
||||
if (payload[current] !== valuesToCheck[current]) {
|
||||
return { ...accu, [current]: payload[current] };
|
||||
}
|
||||
return accu;
|
||||
}, {});
|
||||
if (!Object.keys(valuesToUpdate).length) {
|
||||
return Observable.of(
|
||||
makeToast({ message: 'No changes in settings detected' })
|
||||
);
|
||||
}
|
||||
const {
|
||||
app: { csrfToken: _csrf }
|
||||
} = getState();
|
||||
let body = { _csrf };
|
||||
let endpoint = '/update-flags';
|
||||
const updateKeys = Object.keys(valuesToUpdate);
|
||||
if (updateKeys.length === 1 && updateKeys[0] in endpoints) {
|
||||
// there is a specific route for this update
|
||||
const flag = updateKeys[0];
|
||||
endpoint = endpoints[flag];
|
||||
body = {
|
||||
...body,
|
||||
[flag]: valuesToUpdate[flag]
|
||||
};
|
||||
} else {
|
||||
body = {
|
||||
...body,
|
||||
values: valuesToUpdate
|
||||
};
|
||||
}
|
||||
return postJSON$(endpoint, body)
|
||||
.map(updateUserBackendComplete)
|
||||
.catch(
|
||||
doActionOnError(
|
||||
() => updateMultipleUserFlags({ username, flags: oldValues })
|
||||
)
|
||||
);
|
||||
});
|
||||
const optimistic = start
|
||||
.flatMap(({ payload }) => {
|
||||
const username = usernameSelector(getState());
|
||||
return Observable.of(
|
||||
updateMultipleUserFlags({ username, flags: payload })
|
||||
);
|
||||
});
|
||||
const complete = actions$::ofType(types.updateUserBackend.complete)
|
||||
.flatMap(({ payload: { message } }) => Observable.if(
|
||||
() => message.includes('project'),
|
||||
Observable.of(refetchChallengeMap(), makeToast({ message })),
|
||||
Observable.of(makeToast({ message }))
|
||||
)
|
||||
);
|
||||
|
||||
return Observable.merge(server, optimistic, complete);
|
||||
}
|
||||
|
||||
function refetchChallengeMapEpic(actions$, { getState }) {
|
||||
return actions$::ofType(types.refetchChallengeMap.start)
|
||||
.flatMap(() => {
|
||||
const {
|
||||
app: { csrfToken: _csrf }
|
||||
} = getState();
|
||||
const username = usernameSelector(getState());
|
||||
return postJSON$('/refetch-user-challenge-map', { _csrf })
|
||||
.map(({ challengeMap }) =>
|
||||
updateMultipleUserFlags({ username, flags: { challengeMap } })
|
||||
)
|
||||
.catch(createErrorObservable);
|
||||
});
|
||||
}
|
||||
|
||||
function updateMyPortfolioEpic(actions$, { getState }) {
|
||||
const edit = actions$::ofType(types.updateMyPortfolio.start);
|
||||
const remove = actions$::ofType(types.deletePortfolio.start);
|
||||
const serverEdit = edit
|
||||
.flatMap(({ payload }) => {
|
||||
const { id } = payload;
|
||||
const {
|
||||
app: { csrfToken: _csrf, username }
|
||||
} = getState();
|
||||
return postJSON$('/update-my-portfolio', { _csrf, portfolio: payload })
|
||||
.map(updateMyPortfolioComplete)
|
||||
.catch(doActionOnError(() => regresPortfolio({ username, id })));
|
||||
});
|
||||
const optimisticEdit = edit
|
||||
.map(({ payload }) => {
|
||||
const username = usernameSelector(getState());
|
||||
return optoUpdatePortfolio({ username, portfolio: payload });
|
||||
});
|
||||
const complete = actions$::ofType(types.updateMyPortfolio.complete)
|
||||
.flatMap(({ payload: { message } }) =>
|
||||
Observable.of(makeToast({ message }))
|
||||
);
|
||||
|
||||
const serverRemove = remove
|
||||
.flatMap(({ payload: { portfolio } }) => {
|
||||
const {
|
||||
app: { csrfToken: _csrf }
|
||||
} = getState();
|
||||
return postJSON$('/update-my-portfolio', { _csrf, portfolio })
|
||||
.map(updateMyPortfolioComplete)
|
||||
.catch(
|
||||
doActionOnError(
|
||||
() => makeToast({
|
||||
message: 'Something went wrong removing a portfolio item.'
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
const optimisticRemove = remove
|
||||
.flatMap(({ payload: { portfolio: { id } } }) => {
|
||||
const username = usernameSelector(getState());
|
||||
return Observable.of(regresPortfolio({ username, id }));
|
||||
});
|
||||
|
||||
return Observable.merge(
|
||||
serverEdit,
|
||||
optimisticEdit,
|
||||
complete,
|
||||
serverRemove,
|
||||
optimisticRemove
|
||||
);
|
||||
}
|
||||
|
||||
function updateUserEmailEpic(actions, { getState }) {
|
||||
return actions::ofType(types.updateMyEmail)
|
||||
.flatMap(({ payload: email }) => {
|
||||
const {
|
||||
@@ -38,7 +183,6 @@ export function updateUserEmailEpic(actions, { getState }) {
|
||||
updateUserEmail(username, email)
|
||||
);
|
||||
const ajaxUpdate = postJSON$('/update-my-email', body)
|
||||
.map(({ message }) => makeToast({ message }))
|
||||
.catch(doActionOnError(() => oldEmail ?
|
||||
updateUserEmail(username, oldEmail) :
|
||||
null
|
||||
@@ -71,7 +215,7 @@ export function updateUserLangEpic(actions, { getState }) {
|
||||
// update url to reflect change
|
||||
onRouteSettings({ lang }),
|
||||
// refetch challenges in new language
|
||||
fetchChallenges()
|
||||
updateChallenges()
|
||||
);
|
||||
})
|
||||
.catch(doActionOnError(() => {
|
||||
@@ -85,41 +229,11 @@ export function updateUserLangEpic(actions, { getState }) {
|
||||
});
|
||||
return Observable.merge(ajaxUpdate, optimistic);
|
||||
}
|
||||
export function updateUserFlagEpic(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);
|
||||
})
|
||||
.filter(Boolean)
|
||||
.catch(doActionOnError(() => {
|
||||
return updateUserFlag(username, currentValue);
|
||||
}));
|
||||
});
|
||||
return Observable.merge(optimistic, serverUpdate);
|
||||
}
|
||||
|
||||
export default combineEpics(
|
||||
updateUserFlagEpic,
|
||||
backendUserUpdateEpic,
|
||||
refetchChallengeMapEpic,
|
||||
updateMyPortfolioEpic,
|
||||
updateUserEmailEpic,
|
||||
updateUserLangEpic
|
||||
);
|
||||
|
@@ -3,20 +3,46 @@
|
||||
|
||||
@skeleton-gray: #b0bdb7;
|
||||
|
||||
@keyframes pulsingOverlay {
|
||||
0% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.5;
|
||||
.night .@{ns}-container {
|
||||
|
||||
.btn-group {
|
||||
|
||||
label:disabled, label[disabled] {
|
||||
border: 1px solid #999;
|
||||
background-color: #999;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.@{ns}-container {
|
||||
.center(@value: @container-xl, @padding: @grid-gutter-width);
|
||||
|
||||
button:disabled, button[disabled] {
|
||||
border: 1px solid #999;
|
||||
background-color: #999;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.solution-list {
|
||||
padding-left: 0px;
|
||||
}
|
||||
|
||||
.solution-list-item {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
p {
|
||||
font-weight: bold;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.@{ns}-email-container {
|
||||
@@ -25,23 +51,41 @@
|
||||
})
|
||||
}
|
||||
|
||||
.@{ns}-skeleton {
|
||||
background-color: #fff;
|
||||
z-index: 10;
|
||||
animation-name: pulsingOverlay;
|
||||
animation-duration: 2.5s;
|
||||
animation-timing-function: linear;
|
||||
animation-iteration-count: infinite;
|
||||
animation-direction: normal;
|
||||
.inline-form-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 5px 0;
|
||||
|
||||
.placeholder-string {
|
||||
background-color: @skeleton-gray;
|
||||
box-shadow: 0px 0px 12px 6px @skeleton-gray;
|
||||
color: @skeleton-gray;
|
||||
}
|
||||
.btn-link-social {
|
||||
background-color: @skeleton-gray;
|
||||
border-color: @skeleton-gray;
|
||||
box-shadow: 0px 0px 12px 6px @skeleton-gray;
|
||||
input, textarea {
|
||||
background-color: #fff;
|
||||
}
|
||||
}
|
||||
.edit-preview-tabs {
|
||||
|
||||
li {
|
||||
padding-bottom: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.portfolio-settings-intro {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
|
||||
.p-intro {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.danger-zone-panel {
|
||||
background-color: #fff;
|
||||
|
||||
.panel-heading {
|
||||
background-color: #880000;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
32
common/app/routes/Settings/utils/buildUserProjectsMap.js
Normal file
32
common/app/routes/Settings/utils/buildUserProjectsMap.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import { dasherize } from '../../../../../server/utils/index';
|
||||
|
||||
export const jsProjectSuperBlock = 'javascript-algorithms-and-data-structures';
|
||||
|
||||
export function buildUserProjectsMap(projectBlock, challengeMap) {
|
||||
const {
|
||||
challengeNameIdMap,
|
||||
challenges,
|
||||
superBlock
|
||||
} = projectBlock;
|
||||
return {
|
||||
[superBlock]: challenges.reduce((solutions, current) => {
|
||||
const dashedName = dasherize(current)
|
||||
.replace('java-script', 'javascript')
|
||||
.replace('metric-imperial', 'metricimperial');
|
||||
const completed = challengeMap[challengeNameIdMap[dashedName]];
|
||||
let solution = '';
|
||||
if (superBlock === jsProjectSuperBlock) {
|
||||
solution = {};
|
||||
}
|
||||
if (completed) {
|
||||
solution = 'solution' in completed ?
|
||||
completed.solution :
|
||||
completed.files;
|
||||
}
|
||||
return {
|
||||
...solutions,
|
||||
[current]: solution
|
||||
};
|
||||
}, {})
|
||||
};
|
||||
}
|
@@ -6,17 +6,16 @@ import debugFactory from 'debug';
|
||||
import { isEmail } from 'validator';
|
||||
import path from 'path';
|
||||
import loopback from 'loopback';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { themes } from '../utils/themes';
|
||||
import { dasherize } from '../../server/utils';
|
||||
import { saveUser, observeMethod } from '../../server/utils/rx.js';
|
||||
import { blacklistedUsernames } from '../../server/utils/constants.js';
|
||||
import { wrapHandledError } from '../../server/utils/create-handled-error.js';
|
||||
import {
|
||||
getServerFullURL,
|
||||
getEmailSender,
|
||||
getProtocol,
|
||||
getHost,
|
||||
getPort
|
||||
getEmailSender
|
||||
} from '../../server/utils/url-utils.js';
|
||||
|
||||
const debug = debugFactory('fcc:models:user');
|
||||
@@ -38,6 +37,61 @@ function destroyAll(id, Model) {
|
||||
)({ userId: id });
|
||||
}
|
||||
|
||||
function buildChallengeMapUpdate(challengeMap, project) {
|
||||
const currentChallengeMap = { ...challengeMap };
|
||||
const { nameToIdMap } = _.values(project)[0];
|
||||
const incomingUpdate = _.pickBy(
|
||||
_.omit(_.values(project)[0], [ 'id', 'nameToIdMap' ]),
|
||||
Boolean
|
||||
);
|
||||
const currentCompletedProjects = _.pick(challengeMap, _.values(nameToIdMap));
|
||||
const now = Date.now();
|
||||
const update = Object.keys(incomingUpdate).reduce((update, current) => {
|
||||
const dashedName = dasherize(current)
|
||||
.replace('java-script', 'javascript')
|
||||
.replace('metric-imperial', 'metricimperial');
|
||||
const currentId = nameToIdMap[dashedName];
|
||||
if (
|
||||
currentId in currentCompletedProjects &&
|
||||
currentCompletedProjects[currentId].solution !== incomingUpdate[current]
|
||||
) {
|
||||
return {
|
||||
...update,
|
||||
[currentId]: {
|
||||
...currentCompletedProjects[currentId],
|
||||
solution: incomingUpdate[current],
|
||||
numOfAttempts: currentCompletedProjects[currentId].numOfAttempts + 1
|
||||
}
|
||||
};
|
||||
}
|
||||
if (!(currentId in currentCompletedProjects)) {
|
||||
return {
|
||||
...update,
|
||||
[currentId]: {
|
||||
id: currentId,
|
||||
solution: incomingUpdate[current],
|
||||
challengeType: 3,
|
||||
completedDate: now,
|
||||
numOfAttempts: 1
|
||||
}
|
||||
};
|
||||
}
|
||||
return update;
|
||||
}, {});
|
||||
const updatedExisting = {
|
||||
...currentCompletedProjects,
|
||||
...update
|
||||
};
|
||||
return {
|
||||
...currentChallengeMap,
|
||||
...updatedExisting
|
||||
};
|
||||
}
|
||||
|
||||
function isTheSame(val1, val2) {
|
||||
return val1 === val2;
|
||||
}
|
||||
|
||||
const renderSignUpEmail = loopback.template(path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
@@ -58,6 +112,16 @@ const renderSignInEmail = loopback.template(path.join(
|
||||
'user-request-sign-in.ejs'
|
||||
));
|
||||
|
||||
const renderEmailChangeEmail = loopback.template(path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'server',
|
||||
'views',
|
||||
'emails',
|
||||
'user-request-update-email.ejs'
|
||||
));
|
||||
|
||||
function getAboutProfile({
|
||||
username,
|
||||
githubProfile: github,
|
||||
@@ -285,7 +349,12 @@ module.exports = function(User) {
|
||||
});
|
||||
};
|
||||
|
||||
User.prototype.loginByRequest = function login(req, res) {
|
||||
User.prototype.loginByRequest = function loginByRequest(req, res) {
|
||||
const {
|
||||
query: {
|
||||
emailChange
|
||||
}
|
||||
} = req;
|
||||
const createToken = this.createAccessToken$()
|
||||
.do(accessToken => {
|
||||
const config = {
|
||||
@@ -297,11 +366,19 @@ module.exports = function(User) {
|
||||
res.cookie('userId', accessToken.userId, config);
|
||||
}
|
||||
});
|
||||
const updateUser = this.update$({
|
||||
let data = {
|
||||
emailVerified: true,
|
||||
emailAuthLinkTTL: null,
|
||||
emailVerifyTTL: null
|
||||
});
|
||||
};
|
||||
if (emailChange && this.newEmail) {
|
||||
data = {
|
||||
...data,
|
||||
email: this.newEmail,
|
||||
newEmail: null
|
||||
};
|
||||
}
|
||||
const updateUser = this.update$(data);
|
||||
return Observable.combineLatest(
|
||||
createToken,
|
||||
updateUser,
|
||||
@@ -425,7 +502,7 @@ module.exports = function(User) {
|
||||
|
||||
User.decodeEmail = email => Buffer(email, 'base64').toString();
|
||||
|
||||
User.prototype.requestAuthEmail = function requestAuthEmail(isSignUp) {
|
||||
function requestAuthEmail(isSignUp, newEmail) {
|
||||
return Observable.defer(() => {
|
||||
const messageOrNull = getWaitMessage(this.emailAuthLinkTTL);
|
||||
if (messageOrNull) {
|
||||
@@ -448,22 +525,26 @@ module.exports = function(User) {
|
||||
renderAuthEmail = renderSignUpEmail;
|
||||
subject = 'Account Created - freeCodeCamp';
|
||||
}
|
||||
if (newEmail) {
|
||||
renderAuthEmail = renderEmailChangeEmail;
|
||||
subject = 'Email Change Request - freeCodeCamp';
|
||||
}
|
||||
const { id: loginToken, created: emailAuthLinkTTL } = token;
|
||||
const loginEmail = this.getEncodedEmail();
|
||||
const loginEmail = this.getEncodedEmail(newEmail ? newEmail : null);
|
||||
const host = getServerFullURL();
|
||||
const mailOptions = {
|
||||
type: 'email',
|
||||
to: this.email,
|
||||
to: newEmail ? newEmail : this.email,
|
||||
from: getEmailSender(),
|
||||
subject,
|
||||
text: renderAuthEmail({
|
||||
host,
|
||||
loginEmail,
|
||||
loginToken
|
||||
loginToken,
|
||||
emailChange: !!newEmail
|
||||
})
|
||||
};
|
||||
|
||||
return Observable.combineLatest(
|
||||
return Observable.forkJoin(
|
||||
User.email.send$(mailOptions),
|
||||
this.update$({ emailAuthLinkTTL })
|
||||
);
|
||||
@@ -479,17 +560,19 @@ module.exports = function(User) {
|
||||
Please follow that link to sign in.
|
||||
`
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
User.prototype.requestAuthEmail = requestAuthEmail;
|
||||
|
||||
User.prototype.requestUpdateEmail = function requestUpdateEmail(newEmail) {
|
||||
const currentEmail = this.email;
|
||||
return Observable.defer(() => {
|
||||
const ownEmail = newEmail === this.email;
|
||||
if (!isEmail('' + newEmail)) {
|
||||
throw createEmailError();
|
||||
}
|
||||
// email is already associated and verified with this account
|
||||
if (ownEmail) {
|
||||
const isOwnEmail = isTheSame(newEmail, currentEmail);
|
||||
const sameUpdate = isTheSame(newEmail, this.newEmail);
|
||||
const messageOrNull = getWaitMessage(this.emailVerifyTTL);
|
||||
if (isOwnEmail) {
|
||||
if (this.emailVerified) {
|
||||
// email is already associated and verified with this account
|
||||
throw wrapHandledError(
|
||||
new Error('email is already verified'),
|
||||
{
|
||||
@@ -497,10 +580,8 @@ module.exports = function(User) {
|
||||
message: `${newEmail} is already associated with this account.`
|
||||
}
|
||||
);
|
||||
} else {
|
||||
const messageOrNull = getWaitMessage(this.emailVerifyTTL);
|
||||
// email is already associated but unverified
|
||||
if (messageOrNull) {
|
||||
} else if (!this.emailVerified && messageOrNull) {
|
||||
// email is associated but unverified and
|
||||
// email is within time limit
|
||||
throw wrapHandledError(
|
||||
new Error(),
|
||||
@@ -510,69 +591,175 @@ module.exports = function(User) {
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// at this point email is not associated with the account
|
||||
// or has not been verified but user is requesting another token
|
||||
// outside of the time limit
|
||||
if (sameUpdate && messageOrNull) {
|
||||
// trying to update with the same newEmail and
|
||||
// confirmation email is still valid
|
||||
throw wrapHandledError(
|
||||
new Error(),
|
||||
{
|
||||
type: 'info',
|
||||
message: dedent`
|
||||
We have already sent an email change request to ${newEmail}.
|
||||
Please check your inbox`
|
||||
}
|
||||
);
|
||||
}
|
||||
if (!isEmail('' + newEmail)) {
|
||||
throw createEmailError();
|
||||
}
|
||||
// newEmail is not associated with this user, and
|
||||
// this attempt to change email is the first or
|
||||
// previous attempts have expired
|
||||
return Observable.if(
|
||||
() => ownEmail,
|
||||
() => isOwnEmail || (sameUpdate && messageOrNull),
|
||||
Observable.empty(),
|
||||
// defer prevents the promise from firing prematurely (before subscribe)
|
||||
Observable.defer(() => User.doesExist(null, newEmail))
|
||||
)
|
||||
.do(exists => {
|
||||
// not associated with this account, but is associated with another
|
||||
if (exists) {
|
||||
throw wrapHandledError(
|
||||
new Error('email already in use'),
|
||||
{
|
||||
type: 'info',
|
||||
message:
|
||||
`${newEmail} is already associated with another account.`
|
||||
}
|
||||
);
|
||||
}
|
||||
})
|
||||
.defaultIfEmpty();
|
||||
})
|
||||
.flatMap(() => {
|
||||
const emailVerified = false;
|
||||
const data = {
|
||||
newEmail,
|
||||
emailVerified,
|
||||
emailVerifyTTL: new Date()
|
||||
};
|
||||
return this.update$(data).do(() => Object.assign(this, data));
|
||||
.do(exists => {
|
||||
if (exists) {
|
||||
// newEmail is not associated with this account,
|
||||
// but is associated with different account
|
||||
throw wrapHandledError(
|
||||
new Error('email already in use'),
|
||||
{
|
||||
type: 'info',
|
||||
message:
|
||||
`${newEmail} is already associated with another account.`
|
||||
}
|
||||
);
|
||||
}
|
||||
})
|
||||
.flatMap(() => {
|
||||
const mailOptions = {
|
||||
type: 'email',
|
||||
to: newEmail,
|
||||
from: getEmailSender(),
|
||||
subject: 'freeCodeCamp - Email Update Requested',
|
||||
protocol: getProtocol(),
|
||||
host: getHost(),
|
||||
port: getPort(),
|
||||
template: path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'server',
|
||||
'views',
|
||||
'emails',
|
||||
'user-request-update-email.ejs'
|
||||
)
|
||||
};
|
||||
return this.verify(mailOptions);
|
||||
const update = {
|
||||
newEmail,
|
||||
emailVerified: false,
|
||||
emailVerifyTTL: new Date()
|
||||
};
|
||||
return this.update$(update)
|
||||
.do(() => Object.assign(this, update))
|
||||
.flatMap(() => this.requestAuthEmail(false, newEmail));
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
User.prototype.requestChallengeMap = function requestChallengeMap() {
|
||||
return this.getChallengeMap$();
|
||||
};
|
||||
|
||||
User.prototype.requestUpdateFlags = function requestUpdateFlags(values) {
|
||||
const flagsToCheck = Object.keys(values);
|
||||
const valuesToCheck = _.pick({ ...this }, flagsToCheck);
|
||||
const valuesToUpdate = flagsToCheck
|
||||
.filter(flag => !isTheSame(values[flag], valuesToCheck[flag]));
|
||||
if (!valuesToUpdate.length) {
|
||||
return Observable.of(dedent`
|
||||
No property in
|
||||
${JSON.stringify(flagsToCheck, null, 2)}
|
||||
will introduce a change in this user.
|
||||
`
|
||||
)
|
||||
.do(console.log)
|
||||
.map(() => dedent`Your settings have not been changed`);
|
||||
}
|
||||
return Observable.from(valuesToUpdate)
|
||||
.flatMap(flag => Observable.of({ flag, newValue: values[flag] }))
|
||||
.toArray()
|
||||
.flatMap(updates => {
|
||||
return Observable.forkJoin(
|
||||
Observable.from(updates)
|
||||
.flatMap(({ flag, newValue }) => {
|
||||
return Observable.fromPromise(User.doesExist(null, this.email))
|
||||
.flatMap(() => {
|
||||
return this.update$({ [flag]: newValue })
|
||||
.do(() => {
|
||||
this[flag] = newValue;
|
||||
});
|
||||
});
|
||||
})
|
||||
);
|
||||
})
|
||||
.map(() => dedent`
|
||||
Please check your email.
|
||||
We sent you a link that you can click to verify your email address.
|
||||
We have successfully updated your account.
|
||||
`);
|
||||
};
|
||||
|
||||
User.prototype.updateMyPortfolio =
|
||||
function updateMyPortfolio(portfolioItem, deleteRequest) {
|
||||
const currentPortfolio = this.portfolio.slice(0);
|
||||
const pIndex = _.findIndex(
|
||||
currentPortfolio,
|
||||
p => p.id === portfolioItem.id
|
||||
);
|
||||
let updatedPortfolio = [];
|
||||
if (deleteRequest) {
|
||||
updatedPortfolio = currentPortfolio.filter(
|
||||
p => p.id !== portfolioItem.id
|
||||
);
|
||||
} else if (pIndex === -1) {
|
||||
updatedPortfolio = currentPortfolio.concat([ portfolioItem ]);
|
||||
} else {
|
||||
updatedPortfolio = [ ...currentPortfolio ];
|
||||
updatedPortfolio[pIndex] = { ...portfolioItem };
|
||||
}
|
||||
return this.update$({ portfolio: updatedPortfolio })
|
||||
.do(() => {
|
||||
this.portfolio = updatedPortfolio;
|
||||
})
|
||||
.map(() => dedent`
|
||||
Your portfolio has been updated
|
||||
`);
|
||||
};
|
||||
|
||||
User.prototype.updateMyProjects = function updateMyProjects(project) {
|
||||
const updateData = {};
|
||||
return this.getChallengeMap$()
|
||||
.flatMap(challengeMap => {
|
||||
updateData.challengeMap = buildChallengeMapUpdate(
|
||||
challengeMap,
|
||||
project
|
||||
);
|
||||
return this.update$(updateData);
|
||||
})
|
||||
.do(() => Object.assign(this, updateData))
|
||||
.map(() => dedent`
|
||||
Your projects have been updated
|
||||
`);
|
||||
};
|
||||
|
||||
User.prototype.updateMyUsername = function updateMyUsername(newUsername) {
|
||||
return Observable.defer(
|
||||
() => {
|
||||
const isOwnUsername = isTheSame(newUsername, this.username);
|
||||
if (isOwnUsername) {
|
||||
return Observable.of(dedent`
|
||||
${newUsername} is already associated with this account
|
||||
`);
|
||||
}
|
||||
return Observable.fromPromise(User.doesExist(newUsername));
|
||||
}
|
||||
)
|
||||
.flatMap(boolOrMessage => {
|
||||
if (typeof boolOrMessage === 'string') {
|
||||
return Observable.of(boolOrMessage);
|
||||
}
|
||||
if (boolOrMessage) {
|
||||
return Observable.of(dedent`
|
||||
${newUsername} is associated with a different account
|
||||
`);
|
||||
}
|
||||
|
||||
return this.update$({ username: newUsername })
|
||||
.do(() => {
|
||||
this.username = newUsername;
|
||||
})
|
||||
.map(() => dedent`
|
||||
Username updated successfully
|
||||
`);
|
||||
});
|
||||
};
|
||||
|
||||
User.giveBrowniePoints =
|
||||
function giveBrowniePoints(receiver, giver, data = {}, dev = false, cb) {
|
||||
const findUser = observeMethod(User, 'findOne');
|
||||
|
@@ -89,6 +89,10 @@
|
||||
"type": "string",
|
||||
"default": ""
|
||||
},
|
||||
"about": {
|
||||
"type": "string",
|
||||
"default": ""
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"default": ""
|
||||
@@ -139,11 +143,6 @@
|
||||
"description": "Campers profile does not show challenges/certificates to the public",
|
||||
"default": false
|
||||
},
|
||||
"isAvailableForHire": {
|
||||
"type": "boolean",
|
||||
"description": "Camper is available for hire",
|
||||
"default": false
|
||||
},
|
||||
"currentChallengeId": {
|
||||
"type": "string",
|
||||
"description": "The challenge last visited by the user",
|
||||
@@ -185,12 +184,12 @@
|
||||
},
|
||||
"isRespWebDesignCert": {
|
||||
"type": "boolean",
|
||||
"description": "Camper is data visualization certified",
|
||||
"description": "Camper is responsive web design certified",
|
||||
"default": false
|
||||
},
|
||||
"isNewDataVisCert": {
|
||||
"is2018DataVisCert": {
|
||||
"type": "boolean",
|
||||
"description": "Camper is responsive web design certified",
|
||||
"description": "Camper is data visualization certified (2018)",
|
||||
"default": false
|
||||
},
|
||||
"isFrontEndLibsCert": {
|
||||
@@ -243,6 +242,10 @@
|
||||
],
|
||||
"default": []
|
||||
},
|
||||
"portfolio": {
|
||||
"type": "array",
|
||||
"default": []
|
||||
},
|
||||
"rand": {
|
||||
"type": "number",
|
||||
"index": true
|
||||
|
27
common/resource/academicPolicy.js
Normal file
27
common/resource/academicPolicy.js
Normal file
@@ -0,0 +1,27 @@
|
||||
const policy = [
|
||||
'Before you can claim a verified certificate, you must accept the ' +
|
||||
'Academic Honesty Policy below.',
|
||||
'I understand that plagiarism means copying someone else’s work and ' +
|
||||
'presenting the work as if it were my own, without clearly attributing ' +
|
||||
'the original author.',
|
||||
'I understand that plagiarism is an act of intellectual dishonesty, and ' +
|
||||
'that people usually get kicked out of university or fired from their ' +
|
||||
'jobs if they get caught plagiarizing.',
|
||||
'Aside from using open source libraries such as jQuery and Bootstrap, ' +
|
||||
'and short snippets of code which are clearly attributed to their ' +
|
||||
'original author, 100% of the code in my projects was written by me, or ' +
|
||||
'along with another camper with whom I was pair programming in real time.',
|
||||
'I pledge that I did not plagiarize any of my freeCodeCamp work. ' +
|
||||
'I understand that freeCodeCamp’s team will audit my projects ' +
|
||||
'to confirm this.',
|
||||
'In the situations where we discover instances of unambiguous plagiarism, ' +
|
||||
'we will replace the camper in question’s certification with a message ' +
|
||||
'that "Upon review, this account has been flagged for academic dishonesty."',
|
||||
'As an academic institution that grants achievement-based certifications, ' +
|
||||
'we take academic honesty very seriously. If you have any questions about ' +
|
||||
'this policy, or suspect that someone has violated it, you can email ' +
|
||||
'<a href="mailto:team@freecodecamp.org">team@freecodecamp.org</a> and we ' +
|
||||
'will investigate.'
|
||||
];
|
||||
|
||||
export default policy;
|
Reference in New Issue
Block a user