Compare commits
21 Commits
v0.1
...
djoy/updat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f46db1f1d4 | ||
|
|
20b0701a64 | ||
|
|
d754db2ed9 | ||
|
|
d04a33b311 | ||
|
|
f17101de1b | ||
|
|
884906ac06 | ||
|
|
02c55b6e59 | ||
|
|
f1de3d7f94 | ||
|
|
617e867b01 | ||
|
|
3b02893c65 | ||
|
|
5e9b3d9cd5 | ||
|
|
12fd62ffa8 | ||
|
|
b7049c1567 | ||
|
|
cbcaf3d3a6 | ||
|
|
49488f9386 | ||
|
|
8bec2721b1 | ||
|
|
d4fd7acbd6 | ||
|
|
53aaba4f13 | ||
|
|
ece8b6d007 | ||
|
|
b62f3cae70 | ||
|
|
eb05d5ca0a |
@@ -13,6 +13,7 @@ before_script: greenkeeper-lockfile-update
|
||||
after_script: greenkeeper-lockfile-upload
|
||||
script:
|
||||
- make validate-no-uncommitted-package-lock-changes
|
||||
- npm run i18n_extract
|
||||
- npm run lint
|
||||
- npm run test
|
||||
- npm run build
|
||||
|
||||
32
package-lock.json
generated
32
package-lock.json
generated
@@ -1026,10 +1026,11 @@
|
||||
}
|
||||
},
|
||||
"@edx/frontend-auth": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@edx/frontend-auth/-/frontend-auth-4.0.0.tgz",
|
||||
"integrity": "sha512-nWbdq9c3WDzNyIA16MI9jRMaSbUn4lMUA1gLi1xmxxviR9Kppm6Ne6vZpCpo0p18X2kcWsfEnEprTfRg1i+5Ew==",
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@edx/frontend-auth/-/frontend-auth-5.1.1.tgz",
|
||||
"integrity": "sha512-bkPTzSr4EO4VuceM+2Aks/fqoSfnUr/MWxkRS2WNNCrvLRhW26aG5eK3z6UtPCYS2CiUpflt6lJc5CQXBrAksA==",
|
||||
"requires": {
|
||||
"@edx/frontend-logging": "^2.0.0",
|
||||
"axios": "^0.18.0",
|
||||
"camelcase-keys": "^5.0.0",
|
||||
"jwt-decode": "^2.2.0",
|
||||
@@ -1054,9 +1055,9 @@
|
||||
}
|
||||
},
|
||||
"@edx/frontend-logging": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@edx/frontend-logging/-/frontend-logging-1.0.2.tgz",
|
||||
"integrity": "sha512-1djcoIfT2rqqnGDTBPwuQOekgtKyk9lwriKAC2oFJeqvDhmMmqSnWUTEJde4/UiZbV7zhEMJ6GPnF9LH5bsadg=="
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@edx/frontend-logging/-/frontend-logging-2.0.0.tgz",
|
||||
"integrity": "sha512-/nzaWXSD93YNhpXCwn9pjjoAMX69Jch4yU2qEkSJYEoQAwvHMlkIDLHsazwi3l1TqFBbeGulGCV8fi/GF6Uvqw=="
|
||||
},
|
||||
"@edx/paragon": {
|
||||
"version": "4.1.3",
|
||||
@@ -4310,9 +4311,9 @@
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="
|
||||
},
|
||||
"camelcase-keys": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-5.1.0.tgz",
|
||||
"integrity": "sha512-eZJ8mFhctds8vW/Lo/a2CXFrCEzJurUU05Tx2ReiXaW4aVBJBNxOvtGHf/GQdjHMQTZE11FCRhrdRlM4Se5umA==",
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-5.2.0.tgz",
|
||||
"integrity": "sha512-mSM/OQKD1HS5Ll2AXxeaHSdqCGC/QQ8IrgTbKYA/rxnC36thBKysfIr9+OVBWuW17jyZF4swHkjtglawgBmVFg==",
|
||||
"requires": {
|
||||
"camelcase": "^5.3.1",
|
||||
"map-obj": "^3.0.0",
|
||||
@@ -12291,9 +12292,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"map-obj": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/map-obj/-/map-obj-3.0.0.tgz",
|
||||
"integrity": "sha512-Ot+2wruG8WqTbJngDxz0Ifm03y2pO4iL+brq/l+yEkGjUza03BnMQqX2XT//Jls8MOOl2VTHviAoLX+/nq/HXw=="
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/map-obj/-/map-obj-3.1.0.tgz",
|
||||
"integrity": "sha512-Xg1iyYz/+iIW6YoMldux47H/e5QZyDSB41Kb0ev+YYHh3FJnyyzY0vTk/WbVeWcCvdXd70cOriUBmhP8alUFBA=="
|
||||
},
|
||||
"map-visit": {
|
||||
"version": "1.0.0",
|
||||
@@ -17617,6 +17618,13 @@
|
||||
"requires": {
|
||||
"map-obj": "~3.0.0",
|
||||
"to-snake-case": "~1.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"map-obj": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/map-obj/-/map-obj-3.0.0.tgz",
|
||||
"integrity": "sha512-Ot+2wruG8WqTbJngDxz0Ifm03y2pO4iL+brq/l+yEkGjUza03BnMQqX2XT//Jls8MOOl2VTHviAoLX+/nq/HXw=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"snapdragon": {
|
||||
|
||||
@@ -27,15 +27,15 @@
|
||||
"@cospired/i18n-iso-languages": "^2.0.2",
|
||||
"@edx/edx-bootstrap": "^2.0.1",
|
||||
"@edx/frontend-analytics": "^1.0.0",
|
||||
"@edx/frontend-auth": "^4.0.0",
|
||||
"@edx/frontend-auth": "^5.0.0",
|
||||
"@edx/frontend-component-footer": "^2.0.3",
|
||||
"@edx/frontend-component-site-header": "^2.1.4",
|
||||
"@edx/frontend-logging": "^1.0.2",
|
||||
"@edx/frontend-logging": "^2.0.0",
|
||||
"@edx/paragon": "^4.1.3",
|
||||
"@fortawesome/fontawesome-svg-core": "^1.2.14",
|
||||
"@fortawesome/fontawesome-svg-core": "^1.2.17",
|
||||
"@fortawesome/free-brands-svg-icons": "^5.7.2",
|
||||
"@fortawesome/free-regular-svg-icons": "^5.7.1",
|
||||
"@fortawesome/free-solid-svg-icons": "^5.7.1",
|
||||
"@fortawesome/free-solid-svg-icons": "^5.8.1",
|
||||
"@fortawesome/react-fontawesome": "^0.1.4",
|
||||
"babel-polyfill": "^6.26.0",
|
||||
"classnames": "^2.2.6",
|
||||
|
||||
187
src/account-settings/AccountSettingsPage.jsx
Normal file
187
src/account-settings/AccountSettingsPage.jsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
import {
|
||||
injectIntl,
|
||||
intlShape,
|
||||
getLocale,
|
||||
getCountryList,
|
||||
getLanguageList,
|
||||
} from '@edx/frontend-i18n'; // eslint-disable-line
|
||||
|
||||
import messages from './AccountSettingsPage.messages';
|
||||
|
||||
import { fetchAccount, fetchThirdPartyAuthProviders } from './actions';
|
||||
import { accountSettingsSelector } from './selectors';
|
||||
|
||||
import { PageLoading } from '../common';
|
||||
import EditableField from './components/EditableField';
|
||||
import PasswordReset from './components/PasswordReset';
|
||||
import ThirdPartyAuth from './components/ThirdPartyAuth';
|
||||
import EmailField from './components/EmailField';
|
||||
|
||||
import {
|
||||
YEAR_OF_BIRTH_OPTIONS,
|
||||
EDUCATION_LEVELS,
|
||||
GENDER_OPTIONS,
|
||||
} from './constants';
|
||||
|
||||
|
||||
class AccountSettingsPage extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.countryOptions = getCountryList(getLocale())
|
||||
.map(({ code, name }) => ({ value: code, label: name }));
|
||||
this.languageProficiencyOptions = getLanguageList(getLocale())
|
||||
.map(({ code, name }) => ({ value: code, label: name }));
|
||||
this.educationLevels = EDUCATION_LEVELS.map(key => ({
|
||||
value: key,
|
||||
label: props.intl.formatMessage(messages[`account.settings.field.education.levels.${key}`]),
|
||||
}));
|
||||
this.genderOptions = GENDER_OPTIONS.map(key => ({
|
||||
value: key,
|
||||
label: props.intl.formatMessage(messages[`account.settings.field.gender.options.${key}`]),
|
||||
}));
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.fetchAccount();
|
||||
this.props.fetchThirdPartyAuthProviders();
|
||||
}
|
||||
|
||||
renderContent() {
|
||||
return (
|
||||
<div>
|
||||
<div className="row">
|
||||
<div className="col-md-8 col-lg-6">
|
||||
<h2>{this.props.intl.formatMessage(messages['account.settings.section.account.information'])}</h2>
|
||||
<p>{this.props.intl.formatMessage(messages['account.settings.section.account.information.description'])}</p>
|
||||
|
||||
<EditableField
|
||||
name="username"
|
||||
type="text"
|
||||
label={this.props.intl.formatMessage(messages['account.settings.field.username'])}
|
||||
isEditable={false}
|
||||
/>
|
||||
<EditableField
|
||||
name="name"
|
||||
type="text"
|
||||
label={this.props.intl.formatMessage(messages['account.settings.field.full.name'])}
|
||||
/>
|
||||
<EmailField
|
||||
name="email"
|
||||
label={this.props.intl.formatMessage(messages['account.settings.field.email'])}
|
||||
confirmationMessageDefinition={messages['account.settings.field.email.confirmation']}
|
||||
/>
|
||||
<EditableField
|
||||
name="year_of_birth"
|
||||
type="select"
|
||||
label={this.props.intl.formatMessage(messages['account.settings.field.dob'])}
|
||||
options={YEAR_OF_BIRTH_OPTIONS}
|
||||
/>
|
||||
<PasswordReset />
|
||||
<EditableField
|
||||
name="country"
|
||||
type="select"
|
||||
options={this.countryOptions}
|
||||
label={this.props.intl.formatMessage(messages['account.settings.field.country'])}
|
||||
/>
|
||||
<EditableField
|
||||
name="level_of_education"
|
||||
type="select"
|
||||
options={this.educationLevels}
|
||||
label={this.props.intl.formatMessage(messages['account.settings.field.education'])}
|
||||
/>
|
||||
<EditableField
|
||||
name="gender"
|
||||
type="select"
|
||||
options={this.genderOptions}
|
||||
label={this.props.intl.formatMessage(messages['account.settings.field.gender'])}
|
||||
/>
|
||||
<EditableField
|
||||
name="language_proficiencies"
|
||||
type="select"
|
||||
options={this.languageProficiencyOptions}
|
||||
label={this.props.intl.formatMessage(messages['account.settings.field.language.proficiencies'])}
|
||||
/>
|
||||
<ThirdPartyAuth />
|
||||
|
||||
<h2>{this.props.intl.formatMessage(messages['account.settings.section.social.media'])}</h2>
|
||||
<p>{this.props.intl.formatMessage(messages['account.settings.section.social.media.description'])}</p>
|
||||
<EditableField
|
||||
name="social_link_linkedIn"
|
||||
type="text"
|
||||
label={this.props.intl.formatMessage(messages['account.settings.field.social.platform.name.linkedin'])}
|
||||
/>
|
||||
<EditableField
|
||||
name="social_link_facebook"
|
||||
type="text"
|
||||
label={this.props.intl.formatMessage(messages['account.settings.field.social.platform.name.facebook'])}
|
||||
/>
|
||||
<EditableField
|
||||
name="social_link_twitter"
|
||||
type="text"
|
||||
label={this.props.intl.formatMessage(messages['account.settings.field.social.platform.name.twitter'])}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderError() {
|
||||
return (
|
||||
<div>
|
||||
{this.props.intl.formatMessage(messages['account.settings.loading.error'], {
|
||||
error: this.props.loadingError,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderLoading() {
|
||||
return (
|
||||
<PageLoading srMessage={this.props.intl.formatMessage(messages['account.settings.loading.message'])} />
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
loading,
|
||||
loaded,
|
||||
loadingError,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<div className="page__account-settings container-fluid py-5">
|
||||
<h1>
|
||||
{this.props.intl.formatMessage(messages['account.settings.page.heading'])}
|
||||
</h1>
|
||||
{loading ? this.renderLoading() : null}
|
||||
{loaded ? this.renderContent() : null}
|
||||
{loadingError ? this.renderError() : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AccountSettingsPage.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
loading: PropTypes.bool,
|
||||
loaded: PropTypes.bool,
|
||||
loadingError: PropTypes.string,
|
||||
fetchAccount: PropTypes.func.isRequired,
|
||||
fetchThirdPartyAuthProviders: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
AccountSettingsPage.defaultProps = {
|
||||
loading: false,
|
||||
loaded: false,
|
||||
loadingError: null,
|
||||
};
|
||||
|
||||
|
||||
export default connect(accountSettingsSelector, {
|
||||
fetchAccount,
|
||||
fetchThirdPartyAuthProviders,
|
||||
})(injectIntl(AccountSettingsPage));
|
||||
201
src/account-settings/AccountSettingsPage.messages.jsx
Normal file
201
src/account-settings/AccountSettingsPage.messages.jsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { defineMessages } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
'account.settings.page.heading': {
|
||||
id: 'account.settings.page.heading',
|
||||
defaultMessage: 'Account Settings',
|
||||
description: 'The page heading for the account settings page.',
|
||||
},
|
||||
'account.settings.loading.message': {
|
||||
id: 'account.settings.loading.message',
|
||||
defaultMessage: 'Loading',
|
||||
description: 'Message when data is being loaded',
|
||||
},
|
||||
'account.settings.loading.error': {
|
||||
id: 'account.settings.loading.error',
|
||||
defaultMessage: 'Error: {error}',
|
||||
description: 'Message when data failed to load',
|
||||
},
|
||||
'account.settings.section.account.information': {
|
||||
id: 'account.settings.section.account.information',
|
||||
defaultMessage: 'Account Information',
|
||||
description: 'The basic account information section heading.',
|
||||
},
|
||||
'account.settings.section.account.information.description': {
|
||||
id: 'account.settings.section.account.information.description',
|
||||
defaultMessage: 'These settings include basic information about your account.',
|
||||
description: 'The basic account information section heading description.',
|
||||
},
|
||||
'account.settings.field.username': {
|
||||
id: 'account.settings.field.username',
|
||||
defaultMessage: 'Username',
|
||||
description: 'Label for account settings username field.',
|
||||
},
|
||||
'account.settings.field.full.name': {
|
||||
id: 'account.settings.field.full.name',
|
||||
defaultMessage: 'Full name',
|
||||
description: 'Label for account settings name field.',
|
||||
},
|
||||
'account.settings.field.email': {
|
||||
id: 'account.settings.field.email',
|
||||
defaultMessage: 'Email address (Sign in)',
|
||||
description: 'Label for account settings email field.',
|
||||
},
|
||||
'account.settings.field.email.confirmation': {
|
||||
id: 'account.settings.field.email.confirmation',
|
||||
defaultMessage: 'We’ve sent a confirmation message to {value}. Click the link in the message to update your email address.',
|
||||
description: 'Confirmation message for saving the account settings email field.',
|
||||
},
|
||||
'account.settings.email.field.confirmation.header': {
|
||||
id: 'account.settings.email.field.confirmation.header',
|
||||
defaultMessage: 'One more step!',
|
||||
description: 'The header of the confirmation alert saying we\'ve sent a confirmation email',
|
||||
},
|
||||
'account.settings.field.dob': {
|
||||
id: 'account.settings.field.dob',
|
||||
defaultMessage: 'Year of birth',
|
||||
description: 'Label for account settings year of birth field.',
|
||||
},
|
||||
'account.settings.field.country': {
|
||||
id: 'account.settings.field.country',
|
||||
defaultMessage: 'Country',
|
||||
description: 'Label for account settings country field.',
|
||||
},
|
||||
|
||||
'account.settings.field.education': {
|
||||
id: 'account.settings.field.education',
|
||||
defaultMessage: 'Education',
|
||||
description: 'Label for account settings education field.',
|
||||
},
|
||||
'account.settings.field.education.levels.null': {
|
||||
id: 'account.settings.field.education.levels.null',
|
||||
defaultMessage: 'Select a level of education',
|
||||
description: 'Placeholder for the education levels dropdown.',
|
||||
},
|
||||
'account.settings.field.education.levels.p': {
|
||||
id: 'account.settings.field.education.levels.p',
|
||||
defaultMessage: 'Doctorate',
|
||||
description: 'Selected by the user if their highest level of education is a doctorate degree.',
|
||||
},
|
||||
'account.settings.field.education.levels.m': {
|
||||
id: 'account.settings.field.education.levels.m',
|
||||
defaultMessage: "Master's or professional degree",
|
||||
description: "Selected by the user if their highest level of education is a master's or professional degree from a college or university.",
|
||||
},
|
||||
'account.settings.field.education.levels.b': {
|
||||
id: 'account.settings.field.education.levels.b',
|
||||
defaultMessage: "Bachelor's Degree",
|
||||
description: "Selected by the user if their highest level of education is a four year college or university bachelor's degree.",
|
||||
},
|
||||
'account.settings.field.education.levels.a': {
|
||||
id: 'account.settings.field.education.levels.a',
|
||||
defaultMessage: "Associate's degree",
|
||||
description: "Selected by the user if their highest level of education is an associate's degree. 1-2 years of college or university.",
|
||||
},
|
||||
'account.settings.field.education.levels.hs': {
|
||||
id: 'account.settings.field.education.levels.hs',
|
||||
defaultMessage: 'Secondary/high school',
|
||||
description: 'Selected by the user if their highest level of education is secondary or high school. 9-12 years of education.',
|
||||
},
|
||||
'account.settings.field.education.levels.jhs': {
|
||||
id: 'account.settings.field.education.levels.jhs',
|
||||
defaultMessage: 'Junior secondary/junior high/middle school',
|
||||
description: 'Selected by the user if their highest level of education is junior or middle school. 6-8 years of education.',
|
||||
},
|
||||
'account.settings.field.education.levels.el': {
|
||||
id: 'account.settings.field.education.levels.el',
|
||||
defaultMessage: 'Elementary/primary school',
|
||||
description: 'Selected by the user if their highest level of education is elementary or primary school. 1-5 years of education.',
|
||||
},
|
||||
'account.settings.field.education.levels.none': {
|
||||
id: 'account.settings.field.education.levels.none',
|
||||
defaultMessage: 'No formal education',
|
||||
description: 'Selected by the user to describe their education.',
|
||||
},
|
||||
'account.settings.field.education.levels.o': {
|
||||
id: 'account.settings.field.education.levels.o',
|
||||
defaultMessage: 'Other education',
|
||||
description: 'Selected by the user if they have a type of education not described by the other choices.',
|
||||
},
|
||||
|
||||
'account.settings.field.gender': {
|
||||
id: 'account.settings.field.gender',
|
||||
defaultMessage: 'Gender',
|
||||
description: 'Label for account settings gender field.',
|
||||
},
|
||||
'account.settings.field.gender.options.null': {
|
||||
id: 'account.settings.field.gender.options.null',
|
||||
defaultMessage: 'Select a gender',
|
||||
description: 'Placeholder for the gender options dropdown.',
|
||||
},
|
||||
'account.settings.field.gender.options.f': {
|
||||
id: 'account.settings.field.gender.options.f',
|
||||
defaultMessage: 'Female',
|
||||
description: 'The label for the female gender option.',
|
||||
},
|
||||
'account.settings.field.gender.options.m': {
|
||||
id: 'account.settings.field.gender.options.m',
|
||||
defaultMessage: 'Male',
|
||||
description: 'The label for the male gender option.',
|
||||
},
|
||||
'account.settings.field.gender.options.o': {
|
||||
id: 'account.settings.field.gender.options.o',
|
||||
defaultMessage: 'Other',
|
||||
description: 'The label for catch-all gender option.',
|
||||
},
|
||||
|
||||
'account.settings.field.language.proficiencies': {
|
||||
id: 'account.settings.field.language.proficiencies',
|
||||
defaultMessage: 'Spoken Languages',
|
||||
description: 'Label for account settings spoken languages field.',
|
||||
},
|
||||
|
||||
'account.settings.section.social.media': {
|
||||
id: 'account.settings.section.social.media',
|
||||
defaultMessage: 'Social Media Links',
|
||||
description: 'Section header for social media links settings',
|
||||
},
|
||||
'account.settings.section.social.media.description': {
|
||||
id: 'account.settings.section.social.media.description',
|
||||
defaultMessage: 'Optionally, link your personal accounts to the social media icons on your edX profile.',
|
||||
description: 'Section subheader for social media links settings',
|
||||
},
|
||||
'account.settings.field.social.platform.name.linkedin': {
|
||||
id: 'account.settings.field.social.platform.name.linkedin',
|
||||
defaultMessage: 'LinkedIn',
|
||||
description: 'Label for LinkedIn',
|
||||
},
|
||||
'account.settings.field.social.platform.name.twitter': {
|
||||
id: 'account.settings.field.social.platform.name.twitter',
|
||||
defaultMessage: 'Twitter',
|
||||
description: 'Label for Twitter',
|
||||
},
|
||||
'account.settings.field.social.platform.name.facebook': {
|
||||
id: 'account.settings.field.social.platform.name.facebook',
|
||||
defaultMessage: 'Facebook',
|
||||
description: 'Label for Facebook',
|
||||
},
|
||||
|
||||
'account.settings.editable.field.password.reset.button': {
|
||||
id: 'account.settings.editable.field.password.reset.button',
|
||||
defaultMessage: 'Reset Password',
|
||||
description: 'The password reset button in account settings',
|
||||
},
|
||||
'account.settings.editable.field.action.save': {
|
||||
id: 'account.settings.editable.field.action.save',
|
||||
defaultMessage: 'Save',
|
||||
description: 'The save button on an editable field',
|
||||
},
|
||||
'account.settings.editable.field.action.cancel': {
|
||||
id: 'account.settings.editable.field.action.cancel',
|
||||
defaultMessage: 'Cancel',
|
||||
description: 'The cancel button on an editable field',
|
||||
},
|
||||
'account.settings.editable.field.action.edit': {
|
||||
id: 'account.settings.editable.field.action.edit',
|
||||
defaultMessage: 'Edit',
|
||||
description: 'The edit button on an editable field',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
14
src/account-settings/_style.scss
Normal file
14
src/account-settings/_style.scss
Normal file
@@ -0,0 +1,14 @@
|
||||
.page__account-settings {
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
h6, .h6 {
|
||||
margin-bottom: .25rem;
|
||||
}
|
||||
.btn-link {
|
||||
line-height: 1.2;
|
||||
border: none;
|
||||
padding: 0;
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
122
src/account-settings/actions.js
Normal file
122
src/account-settings/actions.js
Normal file
@@ -0,0 +1,122 @@
|
||||
import { utils } from '../common';
|
||||
|
||||
const { AsyncActionType } = utils;
|
||||
|
||||
export const FETCH_ACCOUNT = new AsyncActionType('ACCOUNT_SETTINGS', 'FETCH_ACCOUNT');
|
||||
export const SAVE_ACCOUNT = new AsyncActionType('ACCOUNT_SETTINGS', 'SAVE_ACCOUNT');
|
||||
export const FETCH_THIRD_PARTY_AUTH_PROVIDERS = new AsyncActionType('ACCOUNT_SETTINGS', 'FETCH_THIRD_PARTY_AUTH_PROVIDERS');
|
||||
export const RESET_PASSWORD = new AsyncActionType('ACCOUNT_SETTINGS', 'RESET_PASSWORD');
|
||||
export const OPEN_FORM = 'OPEN_FORM';
|
||||
export const CLOSE_FORM = 'CLOSE_FORM';
|
||||
export const UPDATE_DRAFT = 'UPDATE_DRAFT';
|
||||
export const RESET_DRAFTS = 'RESET_DRAFTS';
|
||||
|
||||
// FETCH EXAMPLE ACTIONS
|
||||
|
||||
export const fetchAccount = () => ({
|
||||
type: FETCH_ACCOUNT.BASE,
|
||||
});
|
||||
|
||||
export const fetchAccountBegin = () => ({
|
||||
type: FETCH_ACCOUNT.BEGIN,
|
||||
});
|
||||
|
||||
export const fetchAccountSuccess = values => ({
|
||||
type: FETCH_ACCOUNT.SUCCESS,
|
||||
payload: { values },
|
||||
});
|
||||
|
||||
export const fetchAccountFailure = error => ({
|
||||
type: FETCH_ACCOUNT.FAILURE,
|
||||
payload: { error },
|
||||
});
|
||||
|
||||
export const fetchAccountReset = () => ({
|
||||
type: FETCH_ACCOUNT.RESET,
|
||||
});
|
||||
|
||||
export const openForm = formId => ({
|
||||
type: OPEN_FORM,
|
||||
payload: { formId },
|
||||
});
|
||||
|
||||
export const closeForm = formId => ({
|
||||
type: CLOSE_FORM,
|
||||
payload: { formId },
|
||||
});
|
||||
|
||||
|
||||
// FORM STATE ACTIONS
|
||||
|
||||
export const updateDraft = (name, value) => ({
|
||||
type: UPDATE_DRAFT,
|
||||
payload: {
|
||||
name,
|
||||
value,
|
||||
},
|
||||
});
|
||||
|
||||
export const resetDrafts = () => ({
|
||||
type: RESET_DRAFTS,
|
||||
});
|
||||
|
||||
// SAVE PROFILE ACTIONS
|
||||
|
||||
export const saveAccount = (formId, commitValues) => ({
|
||||
type: SAVE_ACCOUNT.BASE,
|
||||
payload: { formId, commitValues },
|
||||
});
|
||||
|
||||
export const saveAccountBegin = () => ({
|
||||
type: SAVE_ACCOUNT.BEGIN,
|
||||
});
|
||||
|
||||
export const saveAccountSuccess = (values, confirmationValues) => ({
|
||||
type: SAVE_ACCOUNT.SUCCESS,
|
||||
payload: { values, confirmationValues },
|
||||
});
|
||||
|
||||
export const saveAccountReset = () => ({
|
||||
type: SAVE_ACCOUNT.RESET,
|
||||
});
|
||||
|
||||
export const saveAccountFailure = ({ fieldErrors, message }) => ({
|
||||
type: SAVE_ACCOUNT.FAILURE,
|
||||
payload: { errors: fieldErrors, message },
|
||||
});
|
||||
|
||||
|
||||
// SAVE PROFILE ACTIONS
|
||||
|
||||
export const resetPassword = () => ({
|
||||
type: RESET_PASSWORD.BASE,
|
||||
});
|
||||
|
||||
export const resetPasswordBegin = () => ({
|
||||
type: RESET_PASSWORD.BEGIN,
|
||||
});
|
||||
|
||||
export const resetPasswordSuccess = () => ({
|
||||
type: RESET_PASSWORD.SUCCESS,
|
||||
});
|
||||
|
||||
export const resetPasswordReset = () => ({
|
||||
type: RESET_PASSWORD.RESET,
|
||||
});
|
||||
|
||||
|
||||
// fetch third party auth providers
|
||||
|
||||
export const fetchThirdPartyAuthProviders = () => ({
|
||||
type: FETCH_THIRD_PARTY_AUTH_PROVIDERS.BASE,
|
||||
});
|
||||
export const fetchThirdPartyAuthProvidersBegin = () => ({
|
||||
type: FETCH_THIRD_PARTY_AUTH_PROVIDERS.BEGIN,
|
||||
});
|
||||
export const fetchThirdPartyAuthProvidersSuccess = providers => ({
|
||||
type: FETCH_THIRD_PARTY_AUTH_PROVIDERS.SUCCESS, payload: { providers },
|
||||
});
|
||||
export const fetchThirdPartyAuthProvidersFailure = error => ({
|
||||
type: FETCH_THIRD_PARTY_AUTH_PROVIDERS.FAILURE, payload: { error },
|
||||
});
|
||||
|
||||
33
src/account-settings/components/Alert.jsx
Normal file
33
src/account-settings/components/Alert.jsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
|
||||
|
||||
function Alert(props) {
|
||||
return (
|
||||
<div className={classNames('alert d-flex align-items-start', props.className)}>
|
||||
<div>
|
||||
{props.icon}
|
||||
</div>
|
||||
<div>
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Alert.propTypes = {
|
||||
className: PropTypes.string,
|
||||
icon: PropTypes.node,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
Alert.defaultProps = {
|
||||
className: undefined,
|
||||
icon: undefined,
|
||||
children: undefined,
|
||||
};
|
||||
|
||||
|
||||
export default Alert;
|
||||
196
src/account-settings/components/EditableField.jsx
Normal file
196
src/account-settings/components/EditableField.jsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-i18n'; // eslint-disable-line
|
||||
import { Button, StatefulButton } from '@edx/paragon';
|
||||
|
||||
import Input from './temp/Input';
|
||||
import ValidationFormGroup from './temp/ValidationFormGroup';
|
||||
import SwitchContent from './temp/SwitchContent';
|
||||
import messages from '../AccountSettingsPage.messages';
|
||||
|
||||
import {
|
||||
openForm,
|
||||
closeForm,
|
||||
updateDraft,
|
||||
saveAccount,
|
||||
} from '../actions';
|
||||
import { editableFieldSelector } from '../selectors';
|
||||
|
||||
|
||||
function EditableField(props) {
|
||||
const {
|
||||
name,
|
||||
label,
|
||||
type,
|
||||
value,
|
||||
options,
|
||||
saveState,
|
||||
error,
|
||||
confirmationMessageDefinition,
|
||||
confirmationValue,
|
||||
helpText,
|
||||
onEdit,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
onChange,
|
||||
isEditing,
|
||||
isEditable,
|
||||
intl,
|
||||
...others
|
||||
} = props;
|
||||
const id = `field-${name}`;
|
||||
|
||||
const getValue = (rawValue) => {
|
||||
if (options) {
|
||||
// Use == instead of === to prevent issues when HTML casts numbers as strings
|
||||
// eslint-disable-next-line eqeqeq
|
||||
const selectedOption = options.find(option => option.value == rawValue);
|
||||
if (selectedOption) return selectedOption.label;
|
||||
}
|
||||
return rawValue;
|
||||
};
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
onSubmit(name, new FormData(e.target).get(name));
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
onChange(name, e.target.value);
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
onEdit(name);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
onCancel(name);
|
||||
};
|
||||
|
||||
const renderConfirmationMessage = () => {
|
||||
if (!confirmationMessageDefinition || !confirmationValue) return null;
|
||||
return intl.formatMessage(confirmationMessageDefinition, {
|
||||
value: confirmationValue,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SwitchContent
|
||||
expression={isEditing ? 'editing' : 'default'}
|
||||
cases={{
|
||||
editing: (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<ValidationFormGroup
|
||||
for={id}
|
||||
invalid={error != null}
|
||||
invalidMessage={error}
|
||||
helpText={helpText}
|
||||
>
|
||||
<label className="h6 d-block" htmlFor={id}>{label}</label>
|
||||
<Input
|
||||
name={name}
|
||||
id={id}
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
options={options}
|
||||
{...others}
|
||||
/>
|
||||
</ValidationFormGroup>
|
||||
<p>
|
||||
<StatefulButton
|
||||
type="submit"
|
||||
className="btn-primary mr-2"
|
||||
state={saveState}
|
||||
labels={{
|
||||
default: intl.formatMessage(messages['account.settings.editable.field.action.save']),
|
||||
}}
|
||||
onClick={(e) => {
|
||||
// Swallow clicks if the state is pending.
|
||||
// We do this instead of disabling the button to prevent
|
||||
// it from losing focus (disabled elements cannot have focus).
|
||||
// Disabling it would causes upstream issues in focus management.
|
||||
// Swallowing the onSubmit event on the form would be better, but
|
||||
// we would have to add that logic for every field given our
|
||||
// current structure of the application.
|
||||
if (saveState === 'pending') e.preventDefault();
|
||||
}}
|
||||
disabledStates={[]}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleCancel}
|
||||
className="btn-outline-primary"
|
||||
>
|
||||
{intl.formatMessage(messages['account.settings.editable.field.action.cancel'])}
|
||||
</Button>
|
||||
</p>
|
||||
</form>
|
||||
),
|
||||
default: (
|
||||
<div className="form-group">
|
||||
<div className="d-flex justify-content-between align-items-start">
|
||||
<h6>{label}</h6>
|
||||
{isEditable ? (
|
||||
<Button onClick={handleEdit} className="btn-link">
|
||||
{intl.formatMessage(messages['account.settings.editable.field.action.edit'])}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<p>{getValue(value)}</p>
|
||||
<p className="small text-muted mt-n2">{renderConfirmationMessage() || helpText}</p>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
EditableField.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
label: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
type: PropTypes.string.isRequired,
|
||||
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
options: PropTypes.arrayOf(PropTypes.shape({
|
||||
label: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
})),
|
||||
saveState: PropTypes.oneOf(['default', 'pending', 'complete', 'error']),
|
||||
error: PropTypes.string,
|
||||
confirmationMessageDefinition: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
defaultMessage: PropTypes.string.isRequired,
|
||||
description: PropTypes.string,
|
||||
}),
|
||||
confirmationValue: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
helpText: PropTypes.node,
|
||||
onEdit: PropTypes.func.isRequired,
|
||||
onCancel: PropTypes.func.isRequired,
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
isEditing: PropTypes.bool,
|
||||
isEditable: PropTypes.bool,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
EditableField.defaultProps = {
|
||||
value: undefined,
|
||||
options: undefined,
|
||||
saveState: undefined,
|
||||
label: undefined,
|
||||
error: undefined,
|
||||
confirmationMessageDefinition: undefined,
|
||||
confirmationValue: undefined,
|
||||
helpText: undefined,
|
||||
isEditing: false,
|
||||
isEditable: true,
|
||||
};
|
||||
|
||||
|
||||
export default connect(editableFieldSelector, {
|
||||
onEdit: openForm,
|
||||
onCancel: closeForm,
|
||||
onChange: updateDraft,
|
||||
onSubmit: saveAccount,
|
||||
})(injectIntl(EditableField));
|
||||
200
src/account-settings/components/EmailField.jsx
Normal file
200
src/account-settings/components/EmailField.jsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-i18n'; // eslint-disable-line
|
||||
import { Button, StatefulButton } from '@edx/paragon';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
import Input from './temp/Input';
|
||||
import ValidationFormGroup from './temp/ValidationFormGroup';
|
||||
import SwitchContent from './temp/SwitchContent';
|
||||
import Alert from './Alert';
|
||||
import messages from '../AccountSettingsPage.messages';
|
||||
|
||||
import {
|
||||
openForm,
|
||||
closeForm,
|
||||
updateDraft,
|
||||
saveAccount,
|
||||
} from '../actions';
|
||||
import { editableFieldSelector } from '../selectors';
|
||||
|
||||
|
||||
function EmailField(props) {
|
||||
const {
|
||||
name,
|
||||
label,
|
||||
value,
|
||||
saveState,
|
||||
error,
|
||||
confirmationMessageDefinition,
|
||||
confirmationValue,
|
||||
helpText,
|
||||
onEdit,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
onChange,
|
||||
isEditing,
|
||||
isEditable,
|
||||
intl,
|
||||
} = props;
|
||||
const id = `field-${name}`;
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
onSubmit(name, new FormData(e.target).get(name));
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
onChange(name, e.target.value);
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
onEdit(name);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
onCancel(name);
|
||||
};
|
||||
|
||||
const renderConfirmationMessage = () => {
|
||||
if (!confirmationMessageDefinition || !confirmationValue) return null;
|
||||
return (
|
||||
<Alert
|
||||
className="alert-warning mt-n2"
|
||||
icon={<FontAwesomeIcon className="mr-2 h6" icon={faExclamationTriangle} />}
|
||||
>
|
||||
<h6>
|
||||
{intl.formatMessage(messages['account.settings.email.field.confirmation.header'])}
|
||||
</h6>
|
||||
{intl.formatMessage(confirmationMessageDefinition, { value: confirmationValue })}
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
const renderConfirmationValue = () => (
|
||||
<span>
|
||||
{confirmationValue}
|
||||
<span className="ml-3 text-muted small">
|
||||
<FormattedMessage
|
||||
id="account.settings.email.field.confirmation.header"
|
||||
defaultMessage="Pending confirmation"
|
||||
description="The label next to a new pending email address"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<SwitchContent
|
||||
expression={isEditing ? 'editing' : 'default'}
|
||||
cases={{
|
||||
editing: (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<ValidationFormGroup
|
||||
for={id}
|
||||
invalid={error != null}
|
||||
invalidMessage={error}
|
||||
helpText={helpText}
|
||||
>
|
||||
<label className="h6 d-block" htmlFor={id}>{label}</label>
|
||||
<Input
|
||||
name={name}
|
||||
id={id}
|
||||
type="email"
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</ValidationFormGroup>
|
||||
<p>
|
||||
<StatefulButton
|
||||
type="submit"
|
||||
className="btn-primary mr-2"
|
||||
state={saveState}
|
||||
labels={{
|
||||
default: intl.formatMessage(messages['account.settings.editable.field.action.save']),
|
||||
}}
|
||||
onClick={(e) => {
|
||||
// Swallow clicks if the state is pending.
|
||||
// We do this instead of disabling the button to prevent
|
||||
// it from losing focus (disabled elements cannot have focus).
|
||||
// Disabling it would causes upstream issues in focus management.
|
||||
// Swallowing the onSubmit event on the form would be better, but
|
||||
// we would have to add that logic for every field given our
|
||||
// current structure of the application.
|
||||
if (saveState === 'pending') e.preventDefault();
|
||||
}}
|
||||
disabledStates={[]}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleCancel}
|
||||
className="btn-outline-primary"
|
||||
>
|
||||
{intl.formatMessage(messages['account.settings.editable.field.action.cancel'])}
|
||||
</Button>
|
||||
</p>
|
||||
</form>
|
||||
),
|
||||
default: (
|
||||
<div className="form-group">
|
||||
<div className="d-flex justify-content-between align-items-start">
|
||||
<h6>{label}</h6>
|
||||
{isEditable ? (
|
||||
<Button onClick={handleEdit} className="btn-link">
|
||||
{intl.formatMessage(messages['account.settings.editable.field.action.edit'])}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<p>{confirmationValue ? renderConfirmationValue() : value}</p>
|
||||
{renderConfirmationMessage() || <p className="small text-muted mt-n2">{helpText}</p>}
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
EmailField.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
label: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
saveState: PropTypes.oneOf(['default', 'pending', 'complete', 'error']),
|
||||
error: PropTypes.string,
|
||||
confirmationMessageDefinition: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
defaultMessage: PropTypes.string.isRequired,
|
||||
description: PropTypes.string,
|
||||
}),
|
||||
confirmationValue: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
helpText: PropTypes.node,
|
||||
onEdit: PropTypes.func.isRequired,
|
||||
onCancel: PropTypes.func.isRequired,
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
isEditing: PropTypes.bool,
|
||||
isEditable: PropTypes.bool,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
EmailField.defaultProps = {
|
||||
value: undefined,
|
||||
saveState: undefined,
|
||||
label: undefined,
|
||||
error: undefined,
|
||||
confirmationMessageDefinition: undefined,
|
||||
confirmationValue: undefined,
|
||||
helpText: undefined,
|
||||
isEditing: false,
|
||||
isEditable: true,
|
||||
};
|
||||
|
||||
|
||||
export default connect(editableFieldSelector, {
|
||||
onEdit: openForm,
|
||||
onCancel: closeForm,
|
||||
onChange: updateDraft,
|
||||
onSubmit: saveAccount,
|
||||
})(injectIntl(EmailField));
|
||||
84
src/account-settings/components/PasswordReset.jsx
Normal file
84
src/account-settings/components/PasswordReset.jsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-i18n'; // eslint-disable-line
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { StatefulButton, Hyperlink } from '@edx/paragon';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
import { resetPassword } from '../actions';
|
||||
import { resetPasswordSelector } from '../selectors';
|
||||
import messages from '../AccountSettingsPage.messages';
|
||||
import Alert from './Alert';
|
||||
|
||||
function PasswordReset({ email, intl, ...props }) {
|
||||
const renderConfirmationMessage = () => (
|
||||
|
||||
<Alert
|
||||
className="alert-warning mt-n2"
|
||||
icon={<FontAwesomeIcon className="mr-2" icon={faExclamationTriangle} />}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="account.settings.editable.field.password.reset.button"
|
||||
defaultMessage="We've sent a message to {email}. Click the link in the message to reset your password. Didn't receive the message? Contact {technicalSupportLink}."
|
||||
description="The password reset button in account settings"
|
||||
values={{
|
||||
email,
|
||||
technicalSupportLink: (
|
||||
<Hyperlink
|
||||
destination="https://support.edx.org/hc/en-us/articles/206212088-What-if-I-did-not-receive-a-password-reset-message-"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="account.settings.editable.field.password.reset.button.support.link"
|
||||
defaultMessage="technical support"
|
||||
description="link text used in message: account.settings.editable.field.password.reset.button 'Contact technical support.'"
|
||||
/>
|
||||
</Hyperlink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="form-group">
|
||||
<h6>
|
||||
<FormattedMessage
|
||||
id="account.settings.editable.field.password.reset.label"
|
||||
defaultMessage="Password"
|
||||
description="The password label in account settings"
|
||||
/>
|
||||
</h6>
|
||||
<p>
|
||||
<StatefulButton
|
||||
className="btn-link"
|
||||
state={props.resetPasswordState}
|
||||
onClick={props.resetPassword}
|
||||
disabledStates={[]}
|
||||
labels={{
|
||||
default: intl.formatMessage(messages['account.settings.editable.field.password.reset.button']),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
{props.resetPasswordState === 'complete' ? renderConfirmationMessage() : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
PasswordReset.propTypes = {
|
||||
resetPassword: PropTypes.func.isRequired,
|
||||
email: PropTypes.string,
|
||||
resetPasswordState: PropTypes.string,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
PasswordReset.defaultProps = {
|
||||
email: '',
|
||||
resetPasswordState: undefined,
|
||||
};
|
||||
|
||||
|
||||
export default connect(resetPasswordSelector, {
|
||||
resetPassword,
|
||||
})(injectIntl(PasswordReset));
|
||||
151
src/account-settings/components/ThirdPartyAuth.jsx
Normal file
151
src/account-settings/components/ThirdPartyAuth.jsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { Hyperlink } from '@edx/paragon';
|
||||
|
||||
import { fetchThirdPartyAuthProviders } from '../actions';
|
||||
import { thirdPartyAuthSelector } from '../selectors';
|
||||
|
||||
class ThirdPartyAuth extends React.Component {
|
||||
componentDidMount() {
|
||||
this.props.fetchThirdPartyAuthProviders();
|
||||
}
|
||||
|
||||
renderConnectedProvider(url, name) {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<h6>{name}</h6>
|
||||
<Hyperlink destination={url} className="btn btn-outline-primary">
|
||||
<FormattedMessage
|
||||
id="account.settings.sso.link.account"
|
||||
defaultMessage="Sign in with {name}"
|
||||
description="An action link to link a connected third party account.m {name} will be Google, Facebook, etc."
|
||||
values={{ name }}
|
||||
/>
|
||||
</Hyperlink>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
renderUnconnectedProvider(url, name) {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<h6>
|
||||
{name}
|
||||
<span className="small font-weight-normal text-muted ml-2">
|
||||
<FormattedMessage
|
||||
id="account.settings.sso.account.connected"
|
||||
defaultMessage="Linked"
|
||||
description="A badge to show that a third party account is linked"
|
||||
/>
|
||||
</span>
|
||||
</h6>
|
||||
<Hyperlink destination={url}>
|
||||
<FormattedMessage
|
||||
id="account.settings.sso.unlink.account"
|
||||
defaultMessage="Unlink {name} account"
|
||||
description="An action link to unlink a connected third party account"
|
||||
values={{ name }}
|
||||
/>
|
||||
</Hyperlink>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
renderProvider({
|
||||
name, disconnectUrl, connectUrl, connected, id,
|
||||
}) {
|
||||
return (
|
||||
<div className="form-group" key={id}>
|
||||
{
|
||||
connected ?
|
||||
this.renderUnconnectedProvider(disconnectUrl, name) :
|
||||
this.renderConnectedProvider(connectUrl, name)
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderNoProviders() {
|
||||
return (
|
||||
<FormattedMessage
|
||||
id="account.settings.sso.no.providers"
|
||||
defaultMessage="No accounts can be linked at this time."
|
||||
description="Displayed when no third party accounts are available to link an edX account to"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderLoading() {
|
||||
return (
|
||||
<FormattedMessage
|
||||
id="account.settings.sso.loading"
|
||||
defaultMessage="Loading..."
|
||||
description="Waiting for data to load in the third party auth provider list"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderLoadingError() {
|
||||
return (
|
||||
<FormattedMessage
|
||||
id="account.settings.sso.loading.error"
|
||||
defaultMessage="There was a problem loading linked accounts."
|
||||
description="Error message for failing to load the third party auth provider list"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<h2>
|
||||
<FormattedMessage
|
||||
id="account.settings.sso.section.header"
|
||||
defaultMessage="Linked Accounts"
|
||||
description="Section header for the third party auth settings"
|
||||
/>
|
||||
</h2>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="account.settings.sso.section.subheader"
|
||||
defaultMessage="You can link your identity accounts to simplify signing in to edX."
|
||||
description="Section subheader for the third party auth settings"
|
||||
/>
|
||||
</p>
|
||||
{this.props.providers.map(this.renderProvider, this)}
|
||||
{this.props.loaded && this.props.providers.length === 0 ? this.renderNoProviders() : null}
|
||||
{this.props.loading ? this.renderLoading() : null}
|
||||
{this.props.loadingError ? this.renderLoadingError() : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
ThirdPartyAuth.propTypes = {
|
||||
fetchThirdPartyAuthProviders: PropTypes.func.isRequired,
|
||||
providers: PropTypes.arrayOf(PropTypes.shape({
|
||||
name: PropTypes.string,
|
||||
disconnectUrl: PropTypes.string,
|
||||
connectUrl: PropTypes.string,
|
||||
connected: PropTypes.bool,
|
||||
id: PropTypes.string,
|
||||
})),
|
||||
loading: PropTypes.bool,
|
||||
loaded: PropTypes.bool,
|
||||
loadingError: PropTypes.string,
|
||||
};
|
||||
|
||||
ThirdPartyAuth.defaultProps = {
|
||||
providers: [],
|
||||
loading: false,
|
||||
loaded: false,
|
||||
loadingError: undefined,
|
||||
};
|
||||
|
||||
|
||||
export default connect(thirdPartyAuthSelector, {
|
||||
fetchThirdPartyAuthProviders,
|
||||
})(ThirdPartyAuth);
|
||||
106
src/account-settings/components/temp/Input.jsx
Normal file
106
src/account-settings/components/temp/Input.jsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
|
||||
class Input extends React.Component {
|
||||
componentDidMount() {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
this.checkHasLabel();
|
||||
}
|
||||
}
|
||||
|
||||
getHTMLTagForType() {
|
||||
const { type } = this.props;
|
||||
if (type === 'select' || type === 'textarea') return type;
|
||||
return 'input';
|
||||
}
|
||||
|
||||
getClassNameForType() {
|
||||
switch (this.props.type) {
|
||||
case 'file':
|
||||
return 'form-control-file';
|
||||
case 'checkbox':
|
||||
case 'radio':
|
||||
return 'form-check-input';
|
||||
default:
|
||||
return 'form-control';
|
||||
}
|
||||
}
|
||||
|
||||
getRef(forwardedRef) {
|
||||
if (process.env.NODE_ENV !== 'development') return forwardedRef;
|
||||
if (forwardedRef) return forwardedRef;
|
||||
if (!this.innerRef) this.innerRef = React.createRef();
|
||||
return this.innerRef;
|
||||
}
|
||||
|
||||
checkHasLabel() {
|
||||
const htmlNode = this.getRef().current;
|
||||
|
||||
if (htmlNode.labels.length > 0) return;
|
||||
if (htmlNode.getAttribute('aria-label') !== null) return;
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
if (console) console.warn('Input[a11y]: There is no associated label for this Input');
|
||||
}
|
||||
|
||||
renderOptions(options) {
|
||||
return options.map((option) => {
|
||||
const {
|
||||
value, label, group, ...attributes
|
||||
} = option;
|
||||
|
||||
if (group) {
|
||||
return (
|
||||
<optgroup key={`optgroup-${label}`} label={label} {...attributes}>
|
||||
{this.renderOptions(group)}
|
||||
</optgroup>
|
||||
);
|
||||
}
|
||||
return <option key={value} value={value} {...attributes}>{label}</option>;
|
||||
}, this);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
type, className, options, innerRef, ...attributes // eslint-disable-line react/prop-types
|
||||
} = this.props;
|
||||
|
||||
const htmlTag = this.getHTMLTagForType();
|
||||
const htmlProps = {
|
||||
className: classNames(this.getClassNameForType(), className),
|
||||
type: htmlTag === 'input' ? type : undefined,
|
||||
...attributes,
|
||||
ref: this.getRef(innerRef),
|
||||
};
|
||||
const htmlChildren = type === 'select' ? this.renderOptions(options) : null;
|
||||
|
||||
return React.createElement(htmlTag, htmlProps, htmlChildren);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Input.propTypes = {
|
||||
type: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
|
||||
className: PropTypes.string,
|
||||
options: PropTypes.arrayOf(PropTypes.shape({
|
||||
label: PropTypes.string,
|
||||
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
disabled: PropTypes.bool,
|
||||
group: PropTypes.arrayOf(PropTypes.shape({
|
||||
label: PropTypes.string,
|
||||
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
disabled: PropTypes.bool,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
|
||||
Input.defaultProps = {
|
||||
className: undefined,
|
||||
options: [],
|
||||
};
|
||||
|
||||
|
||||
// eslint-disable-next-line react/no-multi-comp
|
||||
export default React.forwardRef((props, ref) => <Input innerRef={ref} {...props} />);
|
||||
65
src/account-settings/components/temp/SwitchContent.jsx
Normal file
65
src/account-settings/components/temp/SwitchContent.jsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { TransitionReplace } from '@edx/paragon';
|
||||
|
||||
|
||||
const onChildExit = (htmlNode) => {
|
||||
// If the leaving child has focus, take control and redirect it
|
||||
if (htmlNode.contains(document.activeElement)) {
|
||||
// Get the newly entering sibling.
|
||||
// It's the previousSibling, but not for any explicit reason. So checking for both.
|
||||
const enteringChild = htmlNode.previousSibling || htmlNode.nextSibling;
|
||||
|
||||
// There's no replacement, do nothing.
|
||||
if (!enteringChild) return;
|
||||
|
||||
// Get all the focusable elements in the entering child and focus the first one
|
||||
const focusableElements = enteringChild.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
|
||||
if (focusableElements.length) {
|
||||
focusableElements[0].focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
function SwitchContent({ expression, cases, className }) {
|
||||
const getContent = (caseKey) => {
|
||||
if (cases[caseKey]) {
|
||||
if (typeof cases[caseKey] === 'string') {
|
||||
return getContent(cases[caseKey]);
|
||||
}
|
||||
return React.cloneElement(cases[caseKey], { key: caseKey });
|
||||
} else if (cases.default) {
|
||||
if (typeof cases.default === 'string') {
|
||||
return getContent(cases.default);
|
||||
}
|
||||
React.cloneElement(cases.default, { key: 'default' });
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<TransitionReplace
|
||||
className={className}
|
||||
onChildExit={onChildExit}
|
||||
>
|
||||
{getContent(expression)}
|
||||
</TransitionReplace>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
SwitchContent.propTypes = {
|
||||
expression: PropTypes.string,
|
||||
cases: PropTypes.objectOf(PropTypes.node).isRequired,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
SwitchContent.defaultProps = {
|
||||
expression: null,
|
||||
className: null,
|
||||
};
|
||||
|
||||
|
||||
export default SwitchContent;
|
||||
92
src/account-settings/components/temp/ValidationFormGroup.jsx
Normal file
92
src/account-settings/components/temp/ValidationFormGroup.jsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import Input from './Input';
|
||||
|
||||
const propTypes = {
|
||||
for: PropTypes.string.isRequired,
|
||||
className: PropTypes.string,
|
||||
invalid: PropTypes.bool,
|
||||
valid: PropTypes.bool,
|
||||
validMessage: PropTypes.node,
|
||||
invalidMessage: PropTypes.node,
|
||||
helpText: PropTypes.node,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
invalid: undefined,
|
||||
valid: undefined,
|
||||
validMessage: undefined,
|
||||
invalidMessage: undefined,
|
||||
helpText: undefined,
|
||||
children: undefined,
|
||||
className: undefined,
|
||||
};
|
||||
|
||||
|
||||
function ValidationFormGroup(props) {
|
||||
const {
|
||||
className,
|
||||
invalidMessage,
|
||||
invalid,
|
||||
valid,
|
||||
validMessage,
|
||||
helpText,
|
||||
for: id,
|
||||
children,
|
||||
} = props;
|
||||
|
||||
const renderChildren = () => React.Children.map(children, (child) => {
|
||||
// Any non-user input element should pass through unmodified
|
||||
|
||||
if (['input', 'textarea', 'select', Input].indexOf(child.type) === -1) return child;
|
||||
|
||||
// Add validation class names and describedby values to input element
|
||||
return React.cloneElement(child, {
|
||||
className: classNames(child.props.className, {
|
||||
'is-invalid': invalid,
|
||||
'is-valid': valid,
|
||||
}),
|
||||
// This is a non-standard use of the classNames package, but it's exactly the same use case.
|
||||
'aria-describedby': classNames(child.props['aria-describedby'], {
|
||||
[`${id}-help-text`]: Boolean(helpText),
|
||||
[`${id}-invalid-feedback`]: invalid && invalidMessage,
|
||||
[`${id}-valid-feedback`]: valid && validMessage,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
const renderHelpText = (text) => {
|
||||
if (!text) return null;
|
||||
return <small id={`${id}-help-text`} className="form-text text-muted">{text}</small>;
|
||||
};
|
||||
|
||||
const renderFeedback = (message, state) => {
|
||||
if (!message) return null;
|
||||
return (
|
||||
<div
|
||||
className={`${state}-feedback`}
|
||||
id={`${id}-${state}-feedback`}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classNames('form-group', className)}>
|
||||
{renderChildren()}
|
||||
{renderHelpText(helpText)}
|
||||
{renderFeedback(invalidMessage, 'invalid')}
|
||||
{renderFeedback(validMessage, 'valid')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
ValidationFormGroup.propTypes = propTypes;
|
||||
ValidationFormGroup.defaultProps = defaultProps;
|
||||
|
||||
|
||||
export default ValidationFormGroup;
|
||||
32
src/account-settings/constants.js
Normal file
32
src/account-settings/constants.js
Normal file
@@ -0,0 +1,32 @@
|
||||
|
||||
export const YEAR_OF_BIRTH_OPTIONS = (() => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const years = [];
|
||||
let startYear = currentYear - 120;
|
||||
while (startYear < currentYear) {
|
||||
startYear += 1;
|
||||
|
||||
years.push({ value: startYear, label: startYear });
|
||||
}
|
||||
return years.reverse();
|
||||
})();
|
||||
|
||||
export const EDUCATION_LEVELS = [
|
||||
null,
|
||||
'p',
|
||||
'm',
|
||||
'b',
|
||||
'a',
|
||||
'hs',
|
||||
'jhs',
|
||||
'el',
|
||||
'none',
|
||||
'o',
|
||||
];
|
||||
|
||||
export const GENDER_OPTIONS = [
|
||||
null,
|
||||
'f',
|
||||
'm',
|
||||
'o',
|
||||
];
|
||||
@@ -1,11 +1,11 @@
|
||||
import ConnectedExamplePage from './ExamplePage';
|
||||
import ConnectedAccountSettingsPage from './AccountSettingsPage';
|
||||
import reducer from './reducers';
|
||||
import saga from './sagas';
|
||||
import { configureApiService } from './service';
|
||||
import { storeName } from './selectors';
|
||||
|
||||
export {
|
||||
ConnectedExamplePage,
|
||||
ConnectedAccountSettingsPage,
|
||||
reducer,
|
||||
saga,
|
||||
configureApiService,
|
||||
172
src/account-settings/reducers.js
Normal file
172
src/account-settings/reducers.js
Normal file
@@ -0,0 +1,172 @@
|
||||
import {
|
||||
FETCH_ACCOUNT,
|
||||
OPEN_FORM,
|
||||
CLOSE_FORM,
|
||||
SAVE_ACCOUNT,
|
||||
RESET_PASSWORD,
|
||||
UPDATE_DRAFT,
|
||||
RESET_DRAFTS,
|
||||
FETCH_THIRD_PARTY_AUTH_PROVIDERS,
|
||||
} from './actions';
|
||||
|
||||
export const defaultState = {
|
||||
loading: false,
|
||||
loaded: false,
|
||||
loadingError: null,
|
||||
data: null,
|
||||
values: {},
|
||||
errors: {},
|
||||
confirmationValues: {},
|
||||
drafts: {},
|
||||
saveState: null,
|
||||
resetPasswordState: null,
|
||||
};
|
||||
|
||||
const accountSettingsReducer = (state = defaultState, action) => {
|
||||
let dispatcherIsOpenForm;
|
||||
|
||||
switch (action.type) {
|
||||
case FETCH_ACCOUNT.BEGIN:
|
||||
return {
|
||||
...state,
|
||||
loading: true,
|
||||
loaded: false,
|
||||
loadingError: null,
|
||||
};
|
||||
case FETCH_ACCOUNT.SUCCESS:
|
||||
return {
|
||||
...state,
|
||||
values: Object.assign({}, state.values, action.payload.values),
|
||||
loading: false,
|
||||
loaded: true,
|
||||
loadingError: null,
|
||||
};
|
||||
case FETCH_ACCOUNT.FAILURE:
|
||||
return {
|
||||
...state,
|
||||
loading: false,
|
||||
loaded: false,
|
||||
loadingError: action.payload.error,
|
||||
};
|
||||
case FETCH_ACCOUNT.RESET:
|
||||
return {
|
||||
...state,
|
||||
loading: false,
|
||||
loaded: false,
|
||||
loadingError: null,
|
||||
};
|
||||
|
||||
case OPEN_FORM:
|
||||
return {
|
||||
...state,
|
||||
openFormId: action.payload.formId,
|
||||
saveState: null,
|
||||
errors: {},
|
||||
drafts: {},
|
||||
};
|
||||
case CLOSE_FORM:
|
||||
dispatcherIsOpenForm = action.payload.formId === state.openFormId;
|
||||
if (dispatcherIsOpenForm) {
|
||||
return {
|
||||
...state,
|
||||
openFormId: null,
|
||||
saveState: null,
|
||||
errors: {},
|
||||
drafts: {},
|
||||
};
|
||||
}
|
||||
return state;
|
||||
case UPDATE_DRAFT:
|
||||
return {
|
||||
...state,
|
||||
drafts: Object.assign({}, state.drafts, {
|
||||
[action.payload.name]: action.payload.value,
|
||||
}),
|
||||
saveState: null,
|
||||
errors: {},
|
||||
};
|
||||
|
||||
case RESET_DRAFTS:
|
||||
return {
|
||||
...state,
|
||||
drafts: {},
|
||||
};
|
||||
|
||||
case SAVE_ACCOUNT.BEGIN:
|
||||
return {
|
||||
...state,
|
||||
saveState: 'pending',
|
||||
errors: {},
|
||||
};
|
||||
case SAVE_ACCOUNT.SUCCESS:
|
||||
return {
|
||||
...state,
|
||||
saveState: 'complete',
|
||||
values: Object.assign({}, state.values, action.payload.values),
|
||||
errors: {},
|
||||
confirmationValues: Object.assign(
|
||||
{},
|
||||
state.confirmationValues,
|
||||
action.payload.confirmationValues,
|
||||
),
|
||||
};
|
||||
case SAVE_ACCOUNT.FAILURE:
|
||||
return {
|
||||
...state,
|
||||
saveState: 'error',
|
||||
errors: Object.assign({}, state.errors, action.payload.errors),
|
||||
};
|
||||
case SAVE_ACCOUNT.RESET:
|
||||
return {
|
||||
...state,
|
||||
saveState: null,
|
||||
errors: {},
|
||||
};
|
||||
|
||||
case RESET_PASSWORD.BEGIN:
|
||||
return {
|
||||
...state,
|
||||
resetPasswordState: 'pending',
|
||||
};
|
||||
case RESET_PASSWORD.SUCCESS:
|
||||
return {
|
||||
...state,
|
||||
resetPasswordState: 'complete',
|
||||
};
|
||||
|
||||
case FETCH_THIRD_PARTY_AUTH_PROVIDERS.BEGIN:
|
||||
return {
|
||||
...state,
|
||||
thirdPartyAuthLoading: true,
|
||||
thirdPartyAuthLoaded: false,
|
||||
thirdPartyAuthLoadingError: null,
|
||||
};
|
||||
case FETCH_THIRD_PARTY_AUTH_PROVIDERS.SUCCESS:
|
||||
return {
|
||||
...state,
|
||||
authProviders: action.payload.providers,
|
||||
thirdPartyAuthLoading: false,
|
||||
thirdPartyAuthLoaded: true,
|
||||
thirdPartyAuthLoadingError: null,
|
||||
};
|
||||
case FETCH_THIRD_PARTY_AUTH_PROVIDERS.FAILURE:
|
||||
return {
|
||||
...state,
|
||||
thirdPartyAuthLoading: false,
|
||||
thirdPartyAuthLoaded: false,
|
||||
thirdPartyAuthLoadingError: action.payload.error,
|
||||
};
|
||||
case FETCH_THIRD_PARTY_AUTH_PROVIDERS.RESET:
|
||||
return {
|
||||
...state,
|
||||
thirdPartyAuthLoading: false,
|
||||
thirdPartyAuthLoaded: false,
|
||||
thirdPartyAuthLoadingError: null,
|
||||
};
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export default accountSettingsReducer;
|
||||
94
src/account-settings/sagas.js
Normal file
94
src/account-settings/sagas.js
Normal file
@@ -0,0 +1,94 @@
|
||||
|
||||
import { call, put, delay, takeEvery, select } from 'redux-saga/effects';
|
||||
import { push } from 'connected-react-router';
|
||||
import { logAPIErrorResponse } from '@edx/frontend-logging';
|
||||
|
||||
// Actions
|
||||
import {
|
||||
FETCH_ACCOUNT,
|
||||
fetchAccountBegin,
|
||||
fetchAccountSuccess,
|
||||
fetchAccountFailure,
|
||||
closeForm,
|
||||
SAVE_ACCOUNT,
|
||||
saveAccountBegin,
|
||||
saveAccountSuccess,
|
||||
saveAccountFailure,
|
||||
RESET_PASSWORD,
|
||||
resetPasswordBegin,
|
||||
resetPasswordSuccess,
|
||||
FETCH_THIRD_PARTY_AUTH_PROVIDERS,
|
||||
fetchThirdPartyAuthProvidersBegin,
|
||||
fetchThirdPartyAuthProvidersSuccess,
|
||||
fetchThirdPartyAuthProvidersFailure,
|
||||
} from './actions';
|
||||
import { usernameSelector } from './selectors';
|
||||
|
||||
// Services
|
||||
import * as ApiService from './service';
|
||||
|
||||
export function* handleFetchAccount() {
|
||||
try {
|
||||
yield put(fetchAccountBegin());
|
||||
|
||||
const username = yield select(usernameSelector);
|
||||
const values = yield call(ApiService.getAccount, username);
|
||||
yield put(fetchAccountSuccess(values));
|
||||
} catch (e) {
|
||||
logAPIErrorResponse(e);
|
||||
yield put(fetchAccountFailure(e.message));
|
||||
yield put(push('/error'));
|
||||
}
|
||||
}
|
||||
|
||||
export function* handleSaveAccount(action) {
|
||||
try {
|
||||
yield put(saveAccountBegin());
|
||||
|
||||
const username = yield select(usernameSelector);
|
||||
const { commitValues, formId } = action.payload;
|
||||
const commitData = { [formId]: commitValues };
|
||||
const savedValues = yield call(ApiService.patchAccount, username, commitData);
|
||||
yield put(saveAccountSuccess(savedValues, commitData));
|
||||
yield delay(1000);
|
||||
yield put(closeForm(action.payload.formId));
|
||||
} catch (e) {
|
||||
if (e.fieldErrors) {
|
||||
yield put(saveAccountFailure({ fieldErrors: e.fieldErrors }));
|
||||
} else {
|
||||
logAPIErrorResponse(e);
|
||||
yield put(saveAccountFailure(e.message));
|
||||
yield put(push('/error'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function* handleResetPassword() {
|
||||
try {
|
||||
yield put(resetPasswordBegin());
|
||||
const response = yield call(ApiService.postResetPassword);
|
||||
yield put(resetPasswordSuccess(response));
|
||||
} catch (e) {
|
||||
logAPIErrorResponse(e);
|
||||
yield put(push('/error'));
|
||||
}
|
||||
}
|
||||
|
||||
export function* handleFetchThirdPartyAuthProviders() {
|
||||
try {
|
||||
yield put(fetchThirdPartyAuthProvidersBegin());
|
||||
const authProviders = yield call(ApiService.getThirdPartyAuthProviders);
|
||||
yield put(fetchThirdPartyAuthProvidersSuccess(authProviders));
|
||||
} catch (e) {
|
||||
logAPIErrorResponse(e);
|
||||
yield put(fetchThirdPartyAuthProvidersFailure(e.message));
|
||||
yield put(push('/error'));
|
||||
}
|
||||
}
|
||||
|
||||
export default function* saga() {
|
||||
yield takeEvery(FETCH_ACCOUNT.BASE, handleFetchAccount);
|
||||
yield takeEvery(SAVE_ACCOUNT.BASE, handleSaveAccount);
|
||||
yield takeEvery(RESET_PASSWORD.BASE, handleResetPassword);
|
||||
yield takeEvery(FETCH_THIRD_PARTY_AUTH_PROVIDERS.BASE, handleFetchThirdPartyAuthProviders);
|
||||
}
|
||||
75
src/account-settings/selectors.js
Normal file
75
src/account-settings/selectors.js
Normal file
@@ -0,0 +1,75 @@
|
||||
import { createSelector, createStructuredSelector } from 'reselect';
|
||||
|
||||
export const storeName = 'account-settings';
|
||||
|
||||
export const usernameSelector = state => state.authentication.username;
|
||||
|
||||
export const accountSettingsSelector = state => ({ ...state[storeName] });
|
||||
|
||||
const editableFieldNameSelector = (state, props) => props.name;
|
||||
|
||||
const valuesSelector = createSelector(
|
||||
accountSettingsSelector,
|
||||
accountSettings => accountSettings.values,
|
||||
);
|
||||
|
||||
const draftsSelector = createSelector(
|
||||
accountSettingsSelector,
|
||||
accountSettings => accountSettings.drafts,
|
||||
);
|
||||
|
||||
const editableFieldValueSelector = createSelector(
|
||||
editableFieldNameSelector,
|
||||
valuesSelector,
|
||||
draftsSelector,
|
||||
(name, values, drafts) => (drafts[name] !== undefined ? drafts[name] : values[name]),
|
||||
);
|
||||
|
||||
const editableFieldErrorSelector = createSelector(
|
||||
editableFieldNameSelector,
|
||||
accountSettingsSelector,
|
||||
(name, accountSettings) => accountSettings.errors[name],
|
||||
);
|
||||
|
||||
const editableFieldConfirmationValuesSelector = createSelector(
|
||||
editableFieldNameSelector,
|
||||
accountSettingsSelector,
|
||||
(name, accountSettings) => accountSettings.confirmationValues[name],
|
||||
);
|
||||
|
||||
const isEditingSelector = createSelector(
|
||||
editableFieldNameSelector,
|
||||
accountSettingsSelector,
|
||||
(name, accountSettings) => accountSettings.openFormId === name,
|
||||
);
|
||||
|
||||
const saveStateSelector = createSelector(
|
||||
accountSettingsSelector,
|
||||
accountSettings => accountSettings.saveState,
|
||||
);
|
||||
|
||||
export const editableFieldSelector = createStructuredSelector({
|
||||
value: editableFieldValueSelector,
|
||||
error: editableFieldErrorSelector,
|
||||
confirmationValue: editableFieldConfirmationValuesSelector,
|
||||
saveState: saveStateSelector,
|
||||
isEditing: isEditingSelector,
|
||||
});
|
||||
|
||||
export const resetPasswordSelector = createSelector(
|
||||
accountSettingsSelector,
|
||||
accountSettings => ({
|
||||
resetPasswordState: accountSettings.resetPasswordState,
|
||||
email: accountSettings.values.email,
|
||||
}),
|
||||
);
|
||||
|
||||
export const thirdPartyAuthSelector = createSelector(
|
||||
accountSettingsSelector,
|
||||
accountSettings => ({
|
||||
providers: accountSettings.authProviders,
|
||||
loading: accountSettings.thirdPartyAuthLoading,
|
||||
loaded: accountSettings.thirdPartyAuthLoaded,
|
||||
loadingError: accountSettings.thirdPartyAuthLoadingError,
|
||||
}),
|
||||
);
|
||||
128
src/account-settings/service.js
Normal file
128
src/account-settings/service.js
Normal file
@@ -0,0 +1,128 @@
|
||||
import pick from 'lodash.pick';
|
||||
|
||||
let config = {
|
||||
ACCOUNTS_API_BASE_URL: null,
|
||||
ECOMMERCE_API_BASE_URL: null,
|
||||
LMS_BASE_URL: null,
|
||||
PASSWORD_RESET_URL: null,
|
||||
};
|
||||
|
||||
const SOCIAL_PLATFORMS = [
|
||||
{ id: 'twitter', key: 'social_link_twitter' },
|
||||
{ id: 'facebook', key: 'social_link_facebook' },
|
||||
{ id: 'linkedin', key: 'social_link_linkedin' },
|
||||
];
|
||||
|
||||
let apiClient = null;
|
||||
|
||||
function validateConfiguration(newConfig) {
|
||||
Object.keys(config).forEach((key) => {
|
||||
if (newConfig[key] === undefined) {
|
||||
throw new Error(`Service configuration error: ${key} is required.`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function configureApiService(newConfig, newApiClient) {
|
||||
validateConfiguration(newConfig);
|
||||
config = pick(newConfig, Object.keys(config));
|
||||
apiClient = newApiClient;
|
||||
}
|
||||
|
||||
|
||||
function unpackFieldErrors(fieldErrors) {
|
||||
const unpackedFieldErrors = fieldErrors;
|
||||
if (fieldErrors.social_links) {
|
||||
SOCIAL_PLATFORMS.forEach(({ key }) => {
|
||||
unpackedFieldErrors[key] = fieldErrors.social_links;
|
||||
});
|
||||
}
|
||||
return Object.entries(unpackedFieldErrors)
|
||||
.reduce((acc, [k, v]) => {
|
||||
acc[k] = v.user_message;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function unpackAccountResponseData(data) {
|
||||
const unpackedData = data;
|
||||
|
||||
SOCIAL_PLATFORMS.forEach(({ id, key }) => {
|
||||
const platformData = data.social_links.find(({ platform }) => platform === id);
|
||||
unpackedData[key] = typeof platformData === 'object' ? platformData.social_link : '';
|
||||
});
|
||||
|
||||
if (Array.isArray(data.language_proficiencies)) {
|
||||
if (data.language_proficiencies.length) {
|
||||
unpackedData.language_proficiencies = data.language_proficiencies[0].code;
|
||||
} else {
|
||||
unpackedData.language_proficiencies = '';
|
||||
}
|
||||
}
|
||||
|
||||
return unpackedData;
|
||||
}
|
||||
function packAccountCommitData(commitData) {
|
||||
const packedData = commitData;
|
||||
|
||||
SOCIAL_PLATFORMS.forEach(({ id, key }) => {
|
||||
if (commitData[key]) {
|
||||
packedData.social_links = [{ platform: id, social_link: commitData[key] }];
|
||||
}
|
||||
delete packedData[key];
|
||||
});
|
||||
|
||||
if (commitData.language_proficiencies) {
|
||||
packedData.language_proficiencies = [{ code: commitData.language_proficiencies }];
|
||||
}
|
||||
return packedData;
|
||||
}
|
||||
|
||||
|
||||
function handleRequestError(error) {
|
||||
if (error.response && error.response.data.field_errors) {
|
||||
const apiError = Object.create(error);
|
||||
apiError.fieldErrors = unpackFieldErrors(error.response.data.field_errors);
|
||||
throw apiError;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
|
||||
export async function getAccount(username) {
|
||||
const { data } = await apiClient.get(`${config.ACCOUNTS_API_BASE_URL}/${username}`);
|
||||
return unpackAccountResponseData(data);
|
||||
}
|
||||
|
||||
export async function patchAccount(username, commitValues) {
|
||||
const requestConfig = {
|
||||
headers: { 'Content-Type': 'application/merge-patch+json' },
|
||||
};
|
||||
|
||||
const { data } = await apiClient.patch(
|
||||
`${config.ACCOUNTS_API_BASE_URL}/${username}`,
|
||||
packAccountCommitData(commitValues),
|
||||
requestConfig,
|
||||
).catch(handleRequestError);
|
||||
|
||||
return unpackAccountResponseData(data);
|
||||
}
|
||||
|
||||
export async function postResetPassword() {
|
||||
const { data } = await apiClient
|
||||
.post(config.PASSWORD_RESET_URL)
|
||||
.catch(handleRequestError);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getThirdPartyAuthProviders() {
|
||||
const { data } = await apiClient.get(`${config.LMS_BASE_URL}/api/third_party_auth/v0/providers/user_status`)
|
||||
.catch(handleRequestError);
|
||||
|
||||
return data.map(({ connect_url: connectUrl, disconnect_url: disconnectUrl, ...provider }) => ({
|
||||
...provider,
|
||||
connectUrl: `${config.LMS_BASE_URL}${connectUrl}`,
|
||||
disconnectUrl: `${config.LMS_BASE_URL}${disconnectUrl}`,
|
||||
}));
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import SiteFooter from '@edx/frontend-component-footer';
|
||||
import { getLocale, getMessages } from '@edx/frontend-i18n'; // eslint-disable-line
|
||||
|
||||
import { PageLoading, fetchUserAccount } from '../common';
|
||||
import { ConnectedExamplePage } from '../example';
|
||||
import { ConnectedAccountSettingsPage } from '../account-settings';
|
||||
|
||||
import FooterLogo from '../assets/edx-footer.png';
|
||||
import HeaderLogo from '../assets/logo.svg';
|
||||
@@ -65,6 +65,11 @@ function PageContent({
|
||||
href: `${process.env.LMS_BASE_URL}/account/settings`,
|
||||
content: intl.formatMessage(messages['siteheader.user.menu.account.settings']),
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
href: process.env.ORDER_HISTORY_URL,
|
||||
content: intl.formatMessage(messages['siteheader.user.menu.order.history']),
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
href: process.env.LOGOUT_URL,
|
||||
@@ -99,7 +104,7 @@ function PageContent({
|
||||
/>
|
||||
<main>
|
||||
<Switch>
|
||||
<Route path="/example" component={ConnectedExamplePage} />
|
||||
<Route path="/account-settings" component={ConnectedAccountSettingsPage} />
|
||||
<Route path="/error" component={ErrorPage} />
|
||||
<Route path="/notfound" component={NotFoundPage} />
|
||||
<Route path="/" component={WelcomePage} />
|
||||
|
||||
@@ -31,6 +31,11 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Account',
|
||||
description: 'Link to account settings',
|
||||
},
|
||||
'siteheader.user.menu.order.history': {
|
||||
id: 'siteheader.user.menu.order.history',
|
||||
defaultMessage: 'Order History',
|
||||
description: 'Link to order history',
|
||||
},
|
||||
'siteheader.user.menu.logout': {
|
||||
id: 'siteheader.user.menu.logout',
|
||||
defaultMessage: 'Logout',
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export default function WelcomePage() {
|
||||
return (
|
||||
@@ -12,15 +11,6 @@ export default function WelcomePage() {
|
||||
description="Default page content for a new frontend application"
|
||||
/>
|
||||
</p>
|
||||
<p className="my-0 pt-3 text-muted">
|
||||
<Link to="/example">
|
||||
<FormattedMessage
|
||||
id="app.example.link"
|
||||
defaultMessage="Click here to visit a example page that loads data."
|
||||
description="A link to an example page that loads data."
|
||||
/>
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ export const configuration = {
|
||||
CERTIFICATES_API_BASE_URL: `${process.env.LMS_BASE_URL}/api/certificates/v0/certificates`,
|
||||
VIEW_MY_RECORDS_URL: `${process.env.CREDENTIALS_BASE_URL}/records`,
|
||||
ECOMMERCE_API_BASE_URL: `${process.env.ECOMMERCE_BASE_URL}/api/v2`,
|
||||
PASSWORD_RESET_URL: `${process.env.LMS_BASE_URL}/password_reset/`,
|
||||
};
|
||||
|
||||
export const features = {};
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
import { injectIntl, intlShape } from 'react-intl';
|
||||
|
||||
import messages from './ExamplePage.messages';
|
||||
|
||||
// Actions
|
||||
import { fetchExampleData } from './actions';
|
||||
import { exampleSelector } from './selectors';
|
||||
import { PageLoading } from '../common';
|
||||
|
||||
class ExamplePage extends React.Component {
|
||||
componentDidMount() {
|
||||
this.props.fetchExampleData('Hello example data!');
|
||||
}
|
||||
|
||||
renderContent() {
|
||||
return (
|
||||
<div>{this.props.data}</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderError() {
|
||||
return (
|
||||
<div>
|
||||
{this.props.intl.formatMessage(messages['example.loading.error'], {
|
||||
error: this.props.loadingError,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderLoading() {
|
||||
return (
|
||||
<PageLoading srMessage={this.props.intl.formatMessage(messages['example.loading.message'])} />
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
loading,
|
||||
loaded,
|
||||
loadingError,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<div className="page__example container-fluid py-5">
|
||||
<h1>
|
||||
{this.props.intl.formatMessage(messages['example.page.heading'])}
|
||||
</h1>
|
||||
{loading ? this.renderLoading() : null}
|
||||
{loaded ? this.renderContent() : null}
|
||||
{loadingError ? this.renderError() : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ExamplePage.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
data: PropTypes.string,
|
||||
loading: PropTypes.bool,
|
||||
loaded: PropTypes.bool,
|
||||
loadingError: PropTypes.string,
|
||||
fetchExampleData: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
ExamplePage.defaultProps = {
|
||||
data: null,
|
||||
loading: false,
|
||||
loaded: false,
|
||||
loadingError: null,
|
||||
};
|
||||
|
||||
export default connect(exampleSelector, {
|
||||
fetchExampleData,
|
||||
})(injectIntl(ExamplePage));
|
||||
@@ -1,21 +0,0 @@
|
||||
import { defineMessages } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
'example.page.heading': {
|
||||
id: 'example.page.heading',
|
||||
defaultMessage: 'Example Page',
|
||||
description: 'The page heading for a example page.',
|
||||
},
|
||||
'example.loading.message': {
|
||||
id: 'example.loading.message',
|
||||
defaultMessage: 'Loading',
|
||||
description: 'Message when data is being loaded',
|
||||
},
|
||||
'example.loading.error': {
|
||||
id: 'example.loading.error',
|
||||
defaultMessage: 'Error: {error}',
|
||||
description: 'Message when data failed to load',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -1,3 +0,0 @@
|
||||
.page__example {
|
||||
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { utils } from '../common';
|
||||
|
||||
const { AsyncActionType } = utils;
|
||||
|
||||
export const FETCH_EXAMPLE_DATA = new AsyncActionType('EXAMPLE', 'FETCH_EXAMPLE_DATA');
|
||||
|
||||
// FETCH EXAMPLE ACTIONS
|
||||
|
||||
export const fetchExampleData = data => ({
|
||||
type: FETCH_EXAMPLE_DATA.BASE,
|
||||
payload: {
|
||||
data,
|
||||
},
|
||||
});
|
||||
|
||||
export const fetchExampleDataBegin = () => ({
|
||||
type: FETCH_EXAMPLE_DATA.BEGIN,
|
||||
});
|
||||
|
||||
export const fetchExampleDataSuccess = data => ({
|
||||
type: FETCH_EXAMPLE_DATA.SUCCESS,
|
||||
payload: {
|
||||
data,
|
||||
},
|
||||
});
|
||||
|
||||
export const fetchExampleDataFailure = error => ({
|
||||
type: FETCH_EXAMPLE_DATA.FAILURE,
|
||||
payload: { error },
|
||||
});
|
||||
|
||||
export const fetchExampleDataReset = () => ({
|
||||
type: FETCH_EXAMPLE_DATA.RESET,
|
||||
});
|
||||
@@ -1,46 +0,0 @@
|
||||
import { FETCH_EXAMPLE_DATA } from './actions';
|
||||
|
||||
export const defaultState = {
|
||||
loading: false,
|
||||
loaded: false,
|
||||
loadingError: null,
|
||||
data: null,
|
||||
};
|
||||
|
||||
const example = (state = defaultState, action) => {
|
||||
switch (action.type) {
|
||||
case FETCH_EXAMPLE_DATA.BEGIN:
|
||||
return {
|
||||
...state,
|
||||
loading: true,
|
||||
loaded: false,
|
||||
loadingError: null,
|
||||
};
|
||||
case FETCH_EXAMPLE_DATA.SUCCESS:
|
||||
return {
|
||||
...state,
|
||||
data: action.payload.data,
|
||||
loading: false,
|
||||
loaded: true,
|
||||
loadingError: null,
|
||||
};
|
||||
case FETCH_EXAMPLE_DATA.FAILURE:
|
||||
return {
|
||||
...state,
|
||||
loading: false,
|
||||
loaded: false,
|
||||
loadingError: action.payload.error,
|
||||
};
|
||||
case FETCH_EXAMPLE_DATA.RESET:
|
||||
return {
|
||||
...state,
|
||||
loading: false,
|
||||
loaded: false,
|
||||
loadingError: null,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export default example;
|
||||
@@ -1,32 +0,0 @@
|
||||
|
||||
import { call, put, takeEvery } from 'redux-saga/effects';
|
||||
import { push } from 'connected-react-router';
|
||||
import LoggingService from '@edx/frontend-logging';
|
||||
|
||||
// Actions
|
||||
import {
|
||||
FETCH_EXAMPLE_DATA,
|
||||
fetchExampleDataBegin,
|
||||
fetchExampleDataSuccess,
|
||||
fetchExampleDataFailure,
|
||||
} from './actions';
|
||||
|
||||
// Services
|
||||
import * as ApiService from './service';
|
||||
|
||||
export function* handleFetchExampleData(action) {
|
||||
try {
|
||||
yield put(fetchExampleDataBegin());
|
||||
|
||||
const data = yield call(ApiService.getData, action.payload.data);
|
||||
yield put(fetchExampleDataSuccess(data));
|
||||
} catch (e) {
|
||||
LoggingService.logAPIErrorResponse(e);
|
||||
yield put(fetchExampleDataFailure(e.message));
|
||||
yield put(push('/error'));
|
||||
}
|
||||
}
|
||||
|
||||
export default function* saga() {
|
||||
yield takeEvery(FETCH_EXAMPLE_DATA.BASE, handleFetchExampleData);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export const storeName = 'example';
|
||||
|
||||
// Pass everything in state as props for now
|
||||
export const exampleSelector = state => ({ ...state[storeName] });
|
||||
@@ -1,32 +0,0 @@
|
||||
import pick from 'lodash.pick';
|
||||
|
||||
let config = {
|
||||
ACCOUNTS_API_BASE_URL: null,
|
||||
ECOMMERCE_API_BASE_URL: null,
|
||||
LMS_BASE_URL: null,
|
||||
};
|
||||
|
||||
let apiClient = null; // eslint-disable-line
|
||||
|
||||
function validateConfiguration(newConfig) {
|
||||
Object.keys(config).forEach((key) => {
|
||||
if (newConfig[key] === undefined) {
|
||||
throw new Error(`Service configuration error: ${key} is required.`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function configureApiService(newConfig, newApiClient) {
|
||||
validateConfiguration(newConfig);
|
||||
config = pick(newConfig, Object.keys(config));
|
||||
apiClient = newApiClient;
|
||||
}
|
||||
|
||||
export async function getData(data) {
|
||||
// const { data } = await apiClient.get(`${config.API_BASE_URL}/example/`, {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve(data);
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
@@ -20,17 +20,50 @@ import enLocale from 'react-intl/locale-data/en';
|
||||
import es419Locale from 'react-intl/locale-data/es';
|
||||
import frLocale from 'react-intl/locale-data/fr';
|
||||
import zhcnLocale from 'react-intl/locale-data/zh';
|
||||
import caLocale from 'react-intl/locale-data/ca';
|
||||
import heLocale from 'react-intl/locale-data/he';
|
||||
import idLocale from 'react-intl/locale-data/id';
|
||||
import kokrLocale from 'react-intl/locale-data/ko';
|
||||
import plLocale from 'react-intl/locale-data/pl';
|
||||
import ptbrLocale from 'react-intl/locale-data/pt';
|
||||
import ruLocale from 'react-intl/locale-data/ru';
|
||||
import thLocale from 'react-intl/locale-data/th';
|
||||
import ukLocale from 'react-intl/locale-data/uk';
|
||||
|
||||
import COUNTRIES, { langs as countryLangs } from 'i18n-iso-countries';
|
||||
import LANGUAGES, { langs as languageLangs } from '@cospired/i18n-iso-languages';
|
||||
|
||||
import arMessages from './messages/ar.json';
|
||||
import caMessages from './messages/ca.json';
|
||||
// no need to import en messages-- they are in the defaultMessage field
|
||||
import es419Messages from './messages/es_419.json';
|
||||
import frMessages from './messages/fr.json';
|
||||
import zhcnMessages from './messages/zh_CN.json';
|
||||
import heMessages from './messages/he.json';
|
||||
import idMessages from './messages/id.json';
|
||||
import kokrMessages from './messages/ko_kr.json';
|
||||
import plMessages from './messages/pl.json';
|
||||
import ptbrMessages from './messages/pt_br.json';
|
||||
import ruMessages from './messages/ru.json';
|
||||
import thMessages from './messages/th.json';
|
||||
import ukMessages from './messages/uk.json';
|
||||
|
||||
addLocaleData([...arLocale, ...enLocale, ...es419Locale, ...frLocale, ...zhcnLocale]);
|
||||
addLocaleData([
|
||||
...arLocale,
|
||||
...enLocale,
|
||||
...es419Locale,
|
||||
...frLocale,
|
||||
...zhcnLocale,
|
||||
...caLocale,
|
||||
...heLocale,
|
||||
...idLocale,
|
||||
...kokrLocale,
|
||||
...plLocale,
|
||||
...ptbrLocale,
|
||||
...ruLocale,
|
||||
...thLocale,
|
||||
...ukLocale,
|
||||
]);
|
||||
|
||||
// TODO: When we start dynamically loading translations only for the current locale, change this.
|
||||
COUNTRIES.registerLocale(require('i18n-iso-countries/langs/ar.json'));
|
||||
@@ -38,20 +71,49 @@ COUNTRIES.registerLocale(require('i18n-iso-countries/langs/en.json'));
|
||||
COUNTRIES.registerLocale(require('i18n-iso-countries/langs/es.json'));
|
||||
COUNTRIES.registerLocale(require('i18n-iso-countries/langs/fr.json'));
|
||||
COUNTRIES.registerLocale(require('i18n-iso-countries/langs/zh.json'));
|
||||
COUNTRIES.registerLocale(require('i18n-iso-countries/langs/ca.json'));
|
||||
COUNTRIES.registerLocale(require('i18n-iso-countries/langs/he.json'));
|
||||
COUNTRIES.registerLocale(require('i18n-iso-countries/langs/id.json'));
|
||||
COUNTRIES.registerLocale(require('i18n-iso-countries/langs/ko.json'));
|
||||
COUNTRIES.registerLocale(require('i18n-iso-countries/langs/pl.json'));
|
||||
COUNTRIES.registerLocale(require('i18n-iso-countries/langs/pt.json'));
|
||||
COUNTRIES.registerLocale(require('i18n-iso-countries/langs/ru.json'));
|
||||
// COUNTRIES.registerLocale(require('i18n-iso-countries/langs/th.json')); // Doesn't exist in lib.
|
||||
COUNTRIES.registerLocale(require('i18n-iso-countries/langs/uk.json'));
|
||||
|
||||
// TODO: When we start dynamically loading translations only for the current locale, change this.
|
||||
// TODO: Also note that Arabic (ar) and Chinese (zh) are missing here. That's because they're
|
||||
// not implemented in this library. If you read this and it's been a while, go check and see
|
||||
// if that's changed!
|
||||
// TODO: Also note that a bunch of languages are missing here. They're present but commented out
|
||||
// for reference. That's because they're not implemented in this library. If you read this and it's
|
||||
// been a while, go check and see if that's changed!
|
||||
// LANGUAGES.registerLocale(require('@cospired/i18n-iso-languages/langs/ar.json'));
|
||||
LANGUAGES.registerLocale(require('@cospired/i18n-iso-languages/langs/en.json'));
|
||||
LANGUAGES.registerLocale(require('@cospired/i18n-iso-languages/langs/es.json'));
|
||||
LANGUAGES.registerLocale(require('@cospired/i18n-iso-languages/langs/fr.json'));
|
||||
// LANGUAGES.registerLocale(require('@cospired/i18n-iso-languages/langs/zh.json'));
|
||||
// LANGUAGES.registerLocale(require('@cospired/i18n-iso-languages/langs/ca.json'));
|
||||
// LANGUAGES.registerLocale(require('@cospired/i18n-iso-languages/langs/he.json'));
|
||||
// LANGUAGES.registerLocale(require('@cospired/i18n-iso-languages/langs/id.json'));
|
||||
// LANGUAGES.registerLocale(require('@cospired/i18n-iso-languages/langs/ko.json'));
|
||||
LANGUAGES.registerLocale(require('@cospired/i18n-iso-languages/langs/pl.json'));
|
||||
LANGUAGES.registerLocale(require('@cospired/i18n-iso-languages/langs/pt.json'));
|
||||
// LANGUAGES.registerLocale(require('@cospired/i18n-iso-languages/langs/ru.json'));
|
||||
// LANGUAGES.registerLocale(require('@cospired/i18n-iso-languages/langs/th.json'));
|
||||
// LANGUAGES.registerLocale(require('@cospired/i18n-iso-languages/langs/uk.json'));
|
||||
|
||||
const messages = { // current fallback strategy is to use the first two letters of the locale code
|
||||
ar: arMessages,
|
||||
es: es419Messages,
|
||||
fr: frMessages,
|
||||
zh: zhcnMessages,
|
||||
ca: caMessages,
|
||||
he: heMessages,
|
||||
id: idMessages,
|
||||
ko: kokrMessages,
|
||||
pl: plMessages,
|
||||
pt: ptbrMessages,
|
||||
ru: ruMessages,
|
||||
th: thMessages,
|
||||
uk: ukMessages,
|
||||
};
|
||||
|
||||
const cookies = new Cookies();
|
||||
|
||||
@@ -13,7 +13,7 @@ const injectIntlWithShim = (WrappedComponent) => {
|
||||
super(props);
|
||||
this.shimmedIntl = Object.create(this.props.intl, {
|
||||
formatMessage: {
|
||||
value: (definition) => {
|
||||
value: (definition, ...args) => {
|
||||
if (definition === undefined || definition.id === undefined) {
|
||||
const error = new Error('i18n error: An undefined message was supplied to intl.formatMessage.');
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
@@ -23,7 +23,7 @@ const injectIntlWithShim = (WrappedComponent) => {
|
||||
LoggingService.logError(error);
|
||||
return ''; // Fail silent in production
|
||||
}
|
||||
return this.props.intl.formatMessage(definition);
|
||||
return this.props.intl.formatMessage(definition, ...args);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
11
src/i18n/messages/ca.json
Normal file
11
src/i18n/messages/ca.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"siteheader.links.courses": "Courses",
|
||||
"siteheader.links.programs": "Programs & Degrees",
|
||||
"siteheader.links.schools": "Schools & Partners",
|
||||
"siteheader.user.menu.dashboard": "Dashboard",
|
||||
"siteheader.user.menu.profile": "Profile",
|
||||
"siteheader.user.menu.account.settings": "Account",
|
||||
"siteheader.user.menu.logout": "Logout",
|
||||
"siteheader.user.menu.login": "Login",
|
||||
"siteheader.user.menu.register": "Sign Up"
|
||||
}
|
||||
11
src/i18n/messages/he.json
Normal file
11
src/i18n/messages/he.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"siteheader.links.courses": "Courses",
|
||||
"siteheader.links.programs": "Programs & Degrees",
|
||||
"siteheader.links.schools": "Schools & Partners",
|
||||
"siteheader.user.menu.dashboard": "Dashboard",
|
||||
"siteheader.user.menu.profile": "Profile",
|
||||
"siteheader.user.menu.account.settings": "Account",
|
||||
"siteheader.user.menu.logout": "Logout",
|
||||
"siteheader.user.menu.login": "Login",
|
||||
"siteheader.user.menu.register": "Sign Up"
|
||||
}
|
||||
11
src/i18n/messages/id.json
Normal file
11
src/i18n/messages/id.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"siteheader.links.courses": "Courses",
|
||||
"siteheader.links.programs": "Programs & Degrees",
|
||||
"siteheader.links.schools": "Schools & Partners",
|
||||
"siteheader.user.menu.dashboard": "Dashboard",
|
||||
"siteheader.user.menu.profile": "Profile",
|
||||
"siteheader.user.menu.account.settings": "Account",
|
||||
"siteheader.user.menu.logout": "Logout",
|
||||
"siteheader.user.menu.login": "Login",
|
||||
"siteheader.user.menu.register": "Sign Up"
|
||||
}
|
||||
11
src/i18n/messages/ko_kr.json
Normal file
11
src/i18n/messages/ko_kr.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"siteheader.links.courses": "Courses",
|
||||
"siteheader.links.programs": "Programs & Degrees",
|
||||
"siteheader.links.schools": "Schools & Partners",
|
||||
"siteheader.user.menu.dashboard": "Dashboard",
|
||||
"siteheader.user.menu.profile": "Profile",
|
||||
"siteheader.user.menu.account.settings": "Account",
|
||||
"siteheader.user.menu.logout": "Logout",
|
||||
"siteheader.user.menu.login": "Login",
|
||||
"siteheader.user.menu.register": "Sign Up"
|
||||
}
|
||||
11
src/i18n/messages/pl.json
Normal file
11
src/i18n/messages/pl.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"siteheader.links.courses": "Courses",
|
||||
"siteheader.links.programs": "Programs & Degrees",
|
||||
"siteheader.links.schools": "Schools & Partners",
|
||||
"siteheader.user.menu.dashboard": "Dashboard",
|
||||
"siteheader.user.menu.profile": "Profile",
|
||||
"siteheader.user.menu.account.settings": "Account",
|
||||
"siteheader.user.menu.logout": "Logout",
|
||||
"siteheader.user.menu.login": "Login",
|
||||
"siteheader.user.menu.register": "Sign Up"
|
||||
}
|
||||
11
src/i18n/messages/pt_br.json
Normal file
11
src/i18n/messages/pt_br.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"siteheader.links.courses": "Courses",
|
||||
"siteheader.links.programs": "Programs & Degrees",
|
||||
"siteheader.links.schools": "Schools & Partners",
|
||||
"siteheader.user.menu.dashboard": "Dashboard",
|
||||
"siteheader.user.menu.profile": "Profile",
|
||||
"siteheader.user.menu.account.settings": "Account",
|
||||
"siteheader.user.menu.logout": "Logout",
|
||||
"siteheader.user.menu.login": "Login",
|
||||
"siteheader.user.menu.register": "Sign Up"
|
||||
}
|
||||
11
src/i18n/messages/ru.json
Normal file
11
src/i18n/messages/ru.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"siteheader.links.courses": "Courses",
|
||||
"siteheader.links.programs": "Programs & Degrees",
|
||||
"siteheader.links.schools": "Schools & Partners",
|
||||
"siteheader.user.menu.dashboard": "Dashboard",
|
||||
"siteheader.user.menu.profile": "Profile",
|
||||
"siteheader.user.menu.account.settings": "Account",
|
||||
"siteheader.user.menu.logout": "Logout",
|
||||
"siteheader.user.menu.login": "Login",
|
||||
"siteheader.user.menu.register": "Sign Up"
|
||||
}
|
||||
11
src/i18n/messages/th.json
Normal file
11
src/i18n/messages/th.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"siteheader.links.courses": "Courses",
|
||||
"siteheader.links.programs": "Programs & Degrees",
|
||||
"siteheader.links.schools": "Schools & Partners",
|
||||
"siteheader.user.menu.dashboard": "Dashboard",
|
||||
"siteheader.user.menu.profile": "Profile",
|
||||
"siteheader.user.menu.account.settings": "Account",
|
||||
"siteheader.user.menu.logout": "Logout",
|
||||
"siteheader.user.menu.login": "Login",
|
||||
"siteheader.user.menu.register": "Sign Up"
|
||||
}
|
||||
11
src/i18n/messages/uk.json
Normal file
11
src/i18n/messages/uk.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"siteheader.links.courses": "Courses",
|
||||
"siteheader.links.programs": "Programs & Degrees",
|
||||
"siteheader.links.schools": "Schools & Partners",
|
||||
"siteheader.user.menu.dashboard": "Dashboard",
|
||||
"siteheader.user.menu.profile": "Profile",
|
||||
"siteheader.user.menu.account.settings": "Account",
|
||||
"siteheader.user.menu.logout": "Logout",
|
||||
"siteheader.user.menu.login": "Login",
|
||||
"siteheader.user.menu.register": "Sign Up"
|
||||
}
|
||||
@@ -1,19 +1,64 @@
|
||||
{
|
||||
"account.settings.page.heading": "Account Settings",
|
||||
"account.settings.loading.message": "Loading",
|
||||
"account.settings.loading.error": "Error: {error}",
|
||||
"account.settings.section.account.information": "Account Information",
|
||||
"account.settings.section.account.information.description": "These settings include basic information about your account.",
|
||||
"account.settings.field.username": "Username",
|
||||
"account.settings.field.full.name": "Full name",
|
||||
"account.settings.field.email": "Email address (Sign in)",
|
||||
"account.settings.field.email.confirmation": "We’ve sent a confirmation message to {value}. Click the link in the message to update your email address.",
|
||||
"account.settings.email.field.confirmation.header": "Pending confirmation",
|
||||
"account.settings.field.dob": "Year of birth",
|
||||
"account.settings.field.country": "Country",
|
||||
"account.settings.field.education": "Education",
|
||||
"account.settings.field.education.levels.null": "Select a level of education",
|
||||
"account.settings.field.education.levels.p": "Doctorate",
|
||||
"account.settings.field.education.levels.m": "Master's or professional degree",
|
||||
"account.settings.field.education.levels.b": "Bachelor's Degree",
|
||||
"account.settings.field.education.levels.a": "Associate's degree",
|
||||
"account.settings.field.education.levels.hs": "Secondary/high school",
|
||||
"account.settings.field.education.levels.jhs": "Junior secondary/junior high/middle school",
|
||||
"account.settings.field.education.levels.el": "Elementary/primary school",
|
||||
"account.settings.field.education.levels.none": "No formal education",
|
||||
"account.settings.field.education.levels.o": "Other education",
|
||||
"account.settings.field.gender": "Gender",
|
||||
"account.settings.field.gender.options.null": "Select a gender",
|
||||
"account.settings.field.gender.options.f": "Female",
|
||||
"account.settings.field.gender.options.m": "Male",
|
||||
"account.settings.field.gender.options.o": "Other",
|
||||
"account.settings.field.language.proficiencies": "Spoken Languages",
|
||||
"account.settings.section.social.media": "Social Media Links",
|
||||
"account.settings.section.social.media.description": "Optionally, link your personal accounts to the social media icons on your edX profile.",
|
||||
"account.settings.field.social.platform.name.linkedin": "LinkedIn",
|
||||
"account.settings.field.social.platform.name.twitter": "Twitter",
|
||||
"account.settings.field.social.platform.name.facebook": "Facebook",
|
||||
"account.settings.editable.field.password.reset.button": "We've sent a message to {email}. Click the link in the message to reset your password. Didn't receive the message? Contact {technicalSupportLink}.",
|
||||
"account.settings.editable.field.action.save": "Save",
|
||||
"account.settings.editable.field.action.cancel": "Cancel",
|
||||
"account.settings.editable.field.action.edit": "Edit",
|
||||
"account.settings.editable.field.password.reset.button.support.link": "technical support",
|
||||
"account.settings.editable.field.password.reset.label": "Password",
|
||||
"account.settings.sso.link.account": "Sign in with {name}",
|
||||
"account.settings.sso.account.connected": "Linked",
|
||||
"account.settings.sso.unlink.account": "Unlink {name} account",
|
||||
"account.settings.sso.no.providers": "No accounts can be linked at this time.",
|
||||
"account.settings.sso.loading": "Loading...",
|
||||
"account.settings.sso.loading.error": "There was a problem loading linked accounts.",
|
||||
"account.settings.sso.section.header": "Linked Accounts",
|
||||
"account.settings.sso.section.subheader": "You can link your identity accounts to simplify signing in to edX.",
|
||||
"siteheader.links.courses": "Courses",
|
||||
"siteheader.links.programs": "Programs & Degrees",
|
||||
"siteheader.links.schools": "Schools & Partners",
|
||||
"siteheader.user.menu.dashboard": "Dashboard",
|
||||
"siteheader.user.menu.profile": "Profile",
|
||||
"siteheader.user.menu.account.settings": "Account",
|
||||
"siteheader.user.menu.order.history": "Order History",
|
||||
"siteheader.user.menu.logout": "Logout",
|
||||
"siteheader.user.menu.login": "Login",
|
||||
"siteheader.user.menu.register": "Sign Up",
|
||||
"app.loading.message": "Loading",
|
||||
"error.unexpected.message": "An unexpected error occurred.",
|
||||
"error.notfound.message": "The page you're looking for is unavailable or there's an error in the URL. Please check the URL and try again.",
|
||||
"app.page": "Congratulations! You have a new micro-frontend.",
|
||||
"app.example.link": "Click here to visit a example page that loads data.",
|
||||
"example.page.heading": "Example Page",
|
||||
"example.loading.message": "Loading",
|
||||
"example.loading.error": "Error: {error}"
|
||||
}
|
||||
"app.page": "Congratulations! You have a new micro-frontend."
|
||||
}
|
||||
|
||||
@@ -2,14 +2,14 @@ import 'babel-polyfill';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { identifyAuthenticatedUser, sendPageEvent, configureAnalytics, initializeSegment } from '@edx/frontend-analytics';
|
||||
import LoggingService from '@edx/frontend-logging';
|
||||
import { configureLoggingService, NewRelicLoggingService } from '@edx/frontend-logging';
|
||||
import { getAuthenticatedAPIClient } from '@edx/frontend-auth';
|
||||
|
||||
import { configuration } from './environment';
|
||||
import { handleRtl } from './i18n/i18n-loader';
|
||||
import configureStore from './store';
|
||||
import { configureUserAccountApiService } from './common';
|
||||
import { configureApiService as configureExampleApiService } from './example';
|
||||
import { configureApiService as configureAccountSettingsApiService } from './account-settings';
|
||||
|
||||
import './index.scss';
|
||||
import App from './components/App';
|
||||
@@ -24,6 +24,7 @@ const apiClient = getAuthenticatedAPIClient({
|
||||
accessTokenCookieName: configuration.ACCESS_TOKEN_COOKIE_NAME,
|
||||
userInfoCookieName: configuration.USER_INFO_COOKIE_NAME,
|
||||
csrfCookieName: configuration.CSRF_COOKIE_NAME,
|
||||
loggingService: NewRelicLoggingService,
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -37,11 +38,12 @@ function createInitialState() {
|
||||
function configure() {
|
||||
const { store, history } = configureStore(createInitialState(), configuration.ENVIRONMENT);
|
||||
|
||||
configureExampleApiService(configuration, apiClient);
|
||||
configureLoggingService(NewRelicLoggingService);
|
||||
configureAccountSettingsApiService(configuration, apiClient);
|
||||
configureUserAccountApiService(configuration, apiClient);
|
||||
initializeSegment(configuration.SEGMENT_KEY);
|
||||
configureAnalytics({
|
||||
loggingService: LoggingService,
|
||||
loggingService: NewRelicLoggingService,
|
||||
authApiClient: apiClient,
|
||||
analyticsApiBaseUrl: configuration.LMS_BASE_URL,
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ $fa-font-path: "~font-awesome/fonts";
|
||||
@import "~@edx/frontend-component-site-header/src/index";
|
||||
@import "~@edx/frontend-component-footer/src/lib/scss/site-footer";
|
||||
|
||||
@import "./example/style";
|
||||
@import "./account-settings/style";
|
||||
|
||||
.word-break-all {
|
||||
word-break: break-all !important;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { combineReducers } from 'redux';
|
||||
import { userAccount } from '@edx/frontend-auth';
|
||||
import { connectRouter } from 'connected-react-router';
|
||||
|
||||
import {
|
||||
reducer as exampleReducer,
|
||||
storeName as exampleStoreName,
|
||||
} from './example';
|
||||
reducer as accountSettingsReducer,
|
||||
storeName as accountSettingsStoreName,
|
||||
} from './account-settings';
|
||||
|
||||
const identityReducer = (state) => {
|
||||
const newState = { ...state };
|
||||
@@ -18,7 +19,7 @@ const createRootReducer = history =>
|
||||
authentication: identityReducer,
|
||||
configuration: identityReducer,
|
||||
userAccount,
|
||||
[exampleStoreName]: exampleReducer,
|
||||
[accountSettingsStoreName]: accountSettingsReducer,
|
||||
router: connectRouter(history),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { all } from 'redux-saga/effects';
|
||||
import { saga as exampleSaga } from './example';
|
||||
import { saga as accountSettingsSaga } from './account-settings';
|
||||
|
||||
export default function* rootSaga() {
|
||||
yield all([
|
||||
exampleSaga(),
|
||||
accountSettingsSaga(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -134,6 +134,7 @@ module.exports = Merge.smart(commonConfig, {
|
||||
REDDIT_URL: 'https://www.reddit.com',
|
||||
APPLE_APP_STORE_URL: 'https://www.apple.com/ios/app-store/',
|
||||
GOOGLE_PLAY_URL: 'https://play.google.com/store',
|
||||
ORDER_HISTORY_URL: 'localhost:1996/orders',
|
||||
}),
|
||||
// when the --hot option is not passed in as part of the command
|
||||
// the HotModuleReplacementPlugin has to be specified in the Webpack configuration
|
||||
|
||||
@@ -159,6 +159,7 @@ module.exports = Merge.smart(commonConfig, {
|
||||
REDDIT_URL: null,
|
||||
APPLE_APP_STORE_URL: null,
|
||||
GOOGLE_PLAY_URL: null,
|
||||
ORDER_HISTORY_URL: null,
|
||||
NEW_RELIC_ADMIN_KEY: null,
|
||||
NEW_RELIC_APP_ID: null,
|
||||
NEW_RELIC_LICENSE_KEY: null,
|
||||
|
||||
Reference in New Issue
Block a user