Compare commits

...

21 Commits

Author SHA1 Message Date
David Joy
f46db1f1d4 Updating i18n-loader with all our languages. 2019-05-07 09:08:33 -04:00
albemarle
20b0701a64 Merge pull request #18 from edx/albemarle/messages
fix(i18n): manually update messages file
2019-05-06 17:10:34 -04:00
albemarle
d754db2ed9 fix(i18n): manually update messages file 2019-05-06 13:55:01 -04:00
albemarle
d04a33b311 Merge pull request #17 from edx/albemarle/travis
build: move i18n Travis check earlier since it is faster than linting
2019-05-06 10:20:44 -04:00
albemarle
f17101de1b build: move i18n Travis check earlier since it is faster than linting 2019-05-03 15:25:04 -04:00
David Joy
884906ac06 Upgrade frontend-app-account to latest frontend-auth and frontend-logging. (#16) 2019-05-02 16:05:31 -04:00
albemarle
02c55b6e59 Merge pull request #14 from edx/albemarle/i18n-travis
build: validate i18n
2019-05-02 14:19:44 -04:00
Adam Butterworth
f1de3d7f94 feat: improve confirmation messages on email and password (#13) 2019-05-02 14:17:44 -04:00
albemarle
617e867b01 build: validate i18n 2019-05-02 14:06:33 -04:00
albemarle
3b02893c65 Merge pull request #12 from edx/albemarle/fix-id
fix(i18n): message id needs to be unique
2019-05-02 13:57:18 -04:00
albemarle
5e9b3d9cd5 fix(i18n): message id needs to be unique 2019-05-02 13:50:25 -04:00
Adam Butterworth
12fd62ffa8 feat: add social links (#11) 2019-04-30 16:56:16 -04:00
Adam Butterworth
b7049c1567 feat: add linked accounts section (#10)
* feat: add linked accounts section

fix: change badge to small text

fix: some i18n updates

* fix: return manipulated provider data

* fix: add i18n note
2019-04-30 11:10:29 -04:00
David Joy
cbcaf3d3a6 Refactoring selectors a bit to organize/take advantage of reselect composition. (#9) 2019-04-30 09:00:43 -04:00
Adam Butterworth
49488f9386 feat: account Information Country, Education, Gender, Spoken Lang (#8)
* feat: add country select. improve handling of select inputs

* feat: add education field

* feat: add gender field

* fix: injectIntl shim should pass extra arguments

* feat: add language proficiencies select

Includes extra functionality for EditableField
2019-04-26 13:38:50 -04:00
Adam Butterworth
8bec2721b1 feat: add password reset (#7)
* refactor: reduce complexity of label styling

* feat: add password reset
2019-04-26 09:51:10 -04:00
Adam Butterworth
d4fd7acbd6 feat: refactor and add email confirmation message (#6)
* refactor: delete example service

* fix: properly send errors through to ui

* feat: add editable options to fields

* fix: make button display as a link

* fix: remove unnecessary Object.create for error

* feat: add email confirmation message and refactor to support the pattern

* refactor: move isEditing prop to form selector
2019-04-25 14:44:29 -04:00
Adam Butterworth
53aaba4f13 feat: add username (#5)
* refactor: delete example service

* fix: properly send errors through to ui

* feat: add editable options to fields

* fix: make button display as a link

* fix: remove unnecessary Object.create for error
2019-04-25 14:42:11 -04:00
Adam Butterworth
ece8b6d007 feat: add name, email, and year of birth fields
Includes redux and api pipes
2019-04-24 13:03:03 -07:00
Douglas Hall
b62f3cae70 ARCH-642 Add Order History header menu link. (#4)
ARCH-642 Add Order History header menu link.
2019-04-24 14:27:53 -04:00
Douglas Hall
eb05d5ca0a ARCH-642 Add Order History header menu link. 2019-04-24 13:34:28 -04:00
51 changed files with 2226 additions and 303 deletions

View File

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

@@ -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": {

View File

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

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

View 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: 'Weve 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;

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

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

View 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;

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

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

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

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

View 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} />);

View 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;

View 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;

View 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',
];

View File

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

View 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;

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +0,0 @@
.page__example {
}

View File

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

View File

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

View File

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

View File

@@ -1,4 +0,0 @@
export const storeName = 'example';
// Pass everything in state as props for now
export const exampleSelector = state => ({ ...state[storeName] });

View File

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

View File

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

View File

@@ -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
View 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
View 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
View 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"
}

View 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
View 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"
}

View 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
View 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
View 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
View 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"
}

View File

@@ -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": "Weve 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."
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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