Compare commits

..

42 Commits

Author SHA1 Message Date
Thomas Tracy
c6aedbea29 MB-366 don't save non eligible coaching number (#230)
* MB-366 don't save non eligible coaching number

This fixes a few little bugs in the system as well as the one listed in
the ticket.

* We no longer make an extra API call to user profile when toggling
coaching
* The user now must toggle coaching off to save a non-eligible phone
number
* If the user changes to another US based phone number, or is not
consented to coaching, the phone number will save as normal.

* Fix weird code
2020-05-19 10:35:21 -04:00
edX Transifex Bot
6e099457db fix(i18n): update translations 2020-05-17 17:04:24 -04:00
Olivia Ruiz-Knott
54fa5b970a Merge pull request #220 from edx/ork/MICROBA-311_add-state-to-account-settings
MICROBA-311 Add state field to account settings
2020-05-13 10:12:49 -04:00
Olivia Ruiz-Knott
38371d1a46 MICROBA-311 Add state field to account settings
Add state field dropdown to account settings page if country is US;
add states list; handle localization
2020-05-12 14:56:43 -04:00
edX Transifex Bot
f805480e21 fix(i18n): update translations 2020-05-11 02:01:25 -04:00
Matt Tuchfarber
cc3bf06a6f Merge pull request #227 from edx/tuchfarber/fix_coaching_form_for_ents
Only save coaching name if not managed profile
2020-05-08 14:33:42 -04:00
Matt Tuchfarber
0e8d7622c6 Only save coaching name if not managed profile
Managed profiles don't allow the user to change their name so we disable
the name input and skip name submission during coaching signup.
2020-05-08 14:28:21 -04:00
Waheed Ahmed
9faf28203b Merge pull request #226 from edx/waheed/PROD-1427-handle-403-password-reset-response
Handle 403 password reset response.
2020-05-07 23:35:10 +05:00
Waheed Ahmed
e94d05d9e2 Handle 403 password reset response.
Recently we have changed rate limiting configuration for password reset
endpoint to one request per email and IP. I have added the frontend
functionality to show proper error message to users.

PROD-1427
2020-05-07 22:39:36 +05:00
Thomas Tracy
8b4f7818fc Fix coaching toggle when phone number not saved (#225) 2020-05-06 14:57:07 -04:00
Thomas Tracy
f18c71d13a Prevent phone_number save on coaching save failure (#222)
Originally, we had it set up so that the coaching consent form saved 33
values in parallel. This add a new saga (saveMultipleSettings) to save
those 3 values in sync. This way, if one fails, the rest of the calls
fail.

Something to watch out for here: the order matters. The array that you
give the save function must be ordered in the way you need the data
saved.
2020-05-06 10:43:19 -04:00
Matt Tuchfarber
a1aeb7035e Merge pull request #224 from edx/tuchfarber/fix_coaching_api_call
Fix coaching data API call
2020-05-05 16:40:50 -04:00
Matt Tuchfarber
ef85448e27 Fix coaching data API call
Was mixing extraction of data key in call
2020-05-05 16:10:30 -04:00
Matt Tuchfarber
741cfb6aac Merge pull request #221 from edx/tuchfarber/catch_403_for_inactive_user
Catch 403 for inactive user on coaching API
2020-05-05 11:35:00 -04:00
Matt Tuchfarber
c808c8c0a1 Catch 403 for inactive user on coaching API 2020-05-05 11:14:05 -04:00
Matt Tuchfarber
4eb96e64c7 Merge pull request #218 from edx/tuchfarber/reset_coaching_submission_states
Reset coaching submission state on submit
2020-05-04 11:00:19 -04:00
Albert (AJ) St. Aubin
f1ec989054 Merge pull request #219 from edx/aj/coaching_text_change
Update to the text used by the Coarching account settings
2020-05-04 08:44:18 -04:00
Albert (AJ) St. Aubin
54032e6ec5 Update to the text used by the Coarching account settings 2020-05-01 15:45:44 -04:00
Matt Tuchfarber
ef5c303fbc Reset coaching submission state on submit 2020-05-01 14:51:51 -04:00
Thomas Tracy
caa06a08b0 MICROBA-hotfix: coaching sign-up save phone number (#217)
Before this fix, we were having an issues where upon the first time a
user signs up with the coaching form, the phone number would not save.
This was because of the way we patch the user in the form. The phone
number was saving properly, then getting overwritten with `null`.

This fixes that issue, and also cleans up an error message.
2020-05-01 14:14:50 -04:00
Mike OConnell
5e4278ea5a Merge pull request #216 from edx/sameen/fix-account-settings-bug
Handled no enterprise returned case
2020-05-01 11:43:23 -04:00
sameenfatima78
96dc3f7e3f fixed-account-setting-bug 2020-05-01 20:19:46 +05:00
Matt Tuchfarber
5726be2805 Merge pull request #213 from edx/tuchfarber/default_consent_false
Default eligble for coaching to false
2020-04-27 17:07:58 -04:00
Matt Tuchfarber
73c66d5d18 Default eligble for coaching to false
A user without coaching data should no longer see the toggle
2020-04-27 16:43:58 -04:00
Sameen Fatima
19ef66cf42 Merge pull request #211 from edx/sameen/ENT-2741
ENT-2741: Ent name on account settings should be of current ent
2020-04-24 12:57:24 +05:00
sameenfatima78
c83d76e1a9 ENT-2741-Fixed-Enterprise-Name-Banner-scenario
incorporated-feedback

fixed-lint-issue

incorporated-additional-feedback
2020-04-23 18:05:36 +05:00
David Joy
2f2abd54ff Fixing logout URLs for dev and test. (#212) 2020-04-21 12:56:16 -04:00
Zaman Afzal
0b00fa21a2 ENT-2651 Recovery email Field UX logic on Account Settings page was not same to Dashboard (#206)
* ENT-2651 Recovery email Field UX logic on Account Settings page was not same to Dashboard
2020-04-08 15:36:40 +05:00
edX Transifex Bot
1aa6a22c1c fix(i18n): update translations 2020-04-05 17:06:49 -04:00
Matt Tuchfarber
d3d0bf97b6 Merge pull request #205 from edx/aj/MICROBA-234_MB_phone
Add coaching consent form
2020-04-02 14:22:48 -04:00
Albert (AJ) St. Aubin
7361674019 Created the form to collect phone number and name for coaching. 2020-04-02 13:53:36 -04:00
Renovate Bot
10a3f1fb35 fix(deps): update dependency @edx/frontend-component-footer to v10.0.9 2020-03-23 17:44:10 +00:00
Renovate Bot
06d018fc62 fix(deps): update dependency @fortawesome/fontawesome-svg-core to v1.2.28 2020-03-23 16:19:03 +00:00
edX Transifex Bot
3e5bf2b19a fix(i18n): update translations 2020-03-22 17:06:24 -04:00
adeel khan
724a7f9201 Merge pull request #198 from edx/adeel/prod_1349_fix_timezone_dropdown
Fix timezone dropdown under Site Preferences
2020-03-20 00:59:10 +05:00
Adeel Khan
66b27a01d0 Fix timezone dropdown under Site Preferences
This patch would fix timezone dropdown which
doesn't change  value on selection.

PROD-1349
2020-03-19 22:52:38 +05:00
Thomas Tracy
9c9725c86c Bypass key check until we are ready to add it to internal (#197) 2020-03-16 13:59:52 -04:00
Thomas Tracy
bc8d41cd66 Remove coaching environment variable (#196)
Since we are not yet setting this in production, we need to remove it
from .env to prevent the page from building 'null' as its value
2020-03-16 12:51:47 -04:00
Thomas Tracy
3a49fb3296 Fix 404 error for coaching (#195) 2020-03-16 11:52:45 -04:00
Thomas Tracy
c1d1af4943 Add phone number field and coaching consent toggle (#192)
* Add phone number field and coaching consent toggle

MB-196

Adds phone number and coaching consent toggle to the profile. Currently,
toggled off in production, until we are ready for MB learners to start
recieving coaching.

* fix(deps): update dependency @fortawesome/react-fontawesome to v0.1.9

* fix(deps): update dependency @edx/frontend-component-footer to v10.0.8

* Add phone number field and coaching consent toggle

MB-196

Adds phone number and coaching consent toggle to the profile. Currently,
toggled off in production, until we are ready for MB learners to start
recieving coaching.

* Made requested changes and additional fixes

* Requested changes

Co-authored-by: Renovate Bot <bot@renovateapp.com>
2020-03-16 10:55:36 -04:00
Renovate Bot
a9d3463619 fix(deps): update dependency @edx/frontend-component-footer to v10.0.8 2020-03-05 19:17:11 +00:00
Renovate Bot
d53c9c11a9 fix(deps): update dependency @fortawesome/react-fontawesome to v0.1.9 2020-03-05 18:08:50 +00:00
54 changed files with 1227 additions and 1319 deletions

1
.env
View File

@@ -15,4 +15,3 @@ SEGMENT_KEY=null
SITE_NAME=null
SUPPORT_URL=null
USER_INFO_COOKIE_NAME=null
ENABLE_LOGIN_AND_REGISTRATION=false

View File

@@ -5,8 +5,8 @@ CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
ECOMMERCE_BASE_URL='http://localhost:18130'
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
LMS_BASE_URL='http://localhost:18000'
LOGIN_URL='http://localhost:1997/login'
LOGOUT_URL='http://localhost:18000/login'
LOGIN_URL='http://localhost:18000/login'
LOGOUT_URL='http://localhost:18000/logout'
MARKETING_SITE_BASE_URL='http://localhost:18000'
NODE_ENV='development'
ORDER_HISTORY_URL='localhost:1996/orders'
@@ -16,4 +16,5 @@ SEGMENT_KEY=null
SITE_NAME='edX'
SUPPORT_URL='http://localhost:18000/support'
USER_INFO_COOKIE_NAME='edx-user-info'
ENABLE_LOGIN_AND_REGISTRATION=true
# Temporary, Remove this once we are ready to release the feature.
COACHING_ENABLED=true

View File

@@ -6,7 +6,7 @@ ECOMMERCE_BASE_URL='http://localhost:18130'
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
LMS_BASE_URL='http://localhost:18000'
LOGIN_URL='http://localhost:18000/login'
LOGOUT_URL='http://localhost:18000/login'
LOGOUT_URL='http://localhost:18000/logout'
MARKETING_SITE_BASE_URL='http://localhost:18000'
NODE_ENV=null
ORDER_HISTORY_URL='localhost:1996/orders'
@@ -15,4 +15,4 @@ SEGMENT_KEY=null
SITE_NAME='edX'
SUPPORT_URL='http://localhost:18000/support'
USER_INFO_COOKIE_NAME='edx-user-info'
ENABLE_LOGIN_AND_REGISTRATION=false #hackathon 23 todo
COACHING_ENABLED=''

View File

@@ -0,0 +1,30 @@
1. Add Coaching Consent
--------------------------------
Status
------
Accepted
Context
-------
We need to provide users who are eligible for coaching with both an always available
coaching toggle and a one-time form they can view to signup for coaching.
Decision
--------
While the coaching functionality is currently both limited, closed source, and the form
exists outside of the standard design of this MFE, it was decided to add it here as a
temporary measure due to it being at it's core, an account setting.
The longer term solutions include either:
- using the frontend plugins feature when they become available to inject our coaching
work into the account MFE
- roll it into it's own MFE if enough additional coaching frontend work is required
Consequences
------------
Code will exist inside this Open edX MFE that integrates with a closed source app.

52
package-lock.json generated
View File

@@ -1112,15 +1112,15 @@
}
},
"@edx/frontend-component-footer": {
"version": "10.0.7",
"resolved": "https://registry.npmjs.org/@edx/frontend-component-footer/-/frontend-component-footer-10.0.7.tgz",
"integrity": "sha512-CCEjGToWhkhfAcgr27Y660y7Sg7qiHfC+SV83dcAQnK47m5fwLQaJZZ9PRwbotXZZ/Ynmqpltd9Lgk7JQ7p0eg==",
"version": "10.0.9",
"resolved": "https://registry.npmjs.org/@edx/frontend-component-footer/-/frontend-component-footer-10.0.9.tgz",
"integrity": "sha512-8qRxr/TvFibB1CI6bzj0Yn/Ky7MIvSVdSGNfF/njWfJKX1HSJHhRM4GonTUMBX8mWybdD4wfaJqrDlCT0F6Qqg==",
"requires": {
"@fortawesome/fontawesome-svg-core": "1.2.27",
"@fortawesome/fontawesome-svg-core": "1.2.28",
"@fortawesome/free-brands-svg-icons": "5.8.2",
"@fortawesome/free-regular-svg-icons": "5.8.2",
"@fortawesome/free-solid-svg-icons": "5.8.2",
"@fortawesome/react-fontawesome": "0.1.8"
"@fortawesome/react-fontawesome": "0.1.9"
},
"dependencies": {
"@fortawesome/free-regular-svg-icons": {
@@ -1157,9 +1157,9 @@
}
},
"@edx/frontend-platform": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@edx/frontend-platform/-/frontend-platform-1.2.0.tgz",
"integrity": "sha512-fy3ergsRxozDofUyNvY62KU1+rZt6kZVdiSovMWYGGV7QYNYLnjyajfyb1gRhsnnRa6uubuIqcQOsH3AeLE4eg==",
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/@edx/frontend-platform/-/frontend-platform-1.1.14.tgz",
"integrity": "sha512-wEKKNcjKVNCHL0rgdXIlCF0dwRZc9kuuip2At7+LZ2CxPGJc3s4WGgcsA5yO16YjG2RV+gh2aZV5B/8aczM3Bw==",
"requires": {
"@cospired/i18n-iso-languages": "2.1.1",
"axios": "0.18.1",
@@ -1180,7 +1180,7 @@
"form-urlencoded": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/form-urlencoded/-/form-urlencoded-4.1.1.tgz",
"integrity": "sha1-Nv/xWCFoRT1XrYnVUAhbPJ3pv4I="
"integrity": "sha512-q4SOWDgKw2Mmec2HNYzTNr/7ZXecEkO9QOwzpzx6q9HR+z+no6CsDFpAELdiyk7G3fUV5WvKy57utZxKgleqhg=="
}
}
},
@@ -1249,17 +1249,17 @@
"integrity": "sha512-834DrzO2Ne3upCW+mJJPC/E6BsFcj+2Z1HmPIhbpbj8UaKmXWum4NClqLpUiMetugRlHuG4jbIHNdv2/lc3c1Q=="
},
"@fortawesome/fontawesome-svg-core": {
"version": "1.2.27",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-1.2.27.tgz",
"integrity": "sha512-sOD3DKynocnHYpuw2sLPnTunDj7rLk91LYhi2axUYwuGe9cPCw7Bsu9EWtVdNJP+IYgTCZIbyARKXuy5K/nv+Q==",
"version": "1.2.28",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-1.2.28.tgz",
"integrity": "sha512-4LeaNHWvrneoU0i8b5RTOJHKx7E+y7jYejplR7uSVB34+mp3Veg7cbKk7NBCLiI4TyoWS1wh9ZdoyLJR8wSAdg==",
"requires": {
"@fortawesome/fontawesome-common-types": "^0.2.27"
"@fortawesome/fontawesome-common-types": "^0.2.28"
},
"dependencies": {
"@fortawesome/fontawesome-common-types": {
"version": "0.2.27",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.27.tgz",
"integrity": "sha512-97GaByGaXDGMkzcJX7VmR/jRJd8h1mfhtA7RsxDBN61GnWE/PPCZhOdwG/8OZYktiRUF0CvFOr+VgRkJrt6TWg=="
"version": "0.2.28",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.28.tgz",
"integrity": "sha512-gtis2/5yLdfI6n0ia0jH7NJs5i/Z/8M/ZbQL6jXQhCthEOe5Cr5NcQPhgTvFxNOtURE03/ZqUcEskdn2M+QaBg=="
}
}
},
@@ -1288,11 +1288,11 @@
}
},
"@fortawesome/react-fontawesome": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.1.8.tgz",
"integrity": "sha512-I5h9YQg/ePA3Br9ISS18fcwOYmzQYDSM1ftH03/8nHkiqIVHtUyQBw482+60dnzvlr82gHt3mGm+nDUp159FCw==",
"version": "0.1.9",
"resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.1.9.tgz",
"integrity": "sha512-49V3WNysLZU5fZ3sqSuys4nGRytsrxJktbv3vuaXkEoxv22C6T7TEG0TW6+nqVjMnkfCQd5xOnmJoZHMF78tOw==",
"requires": {
"prop-types": "^15.5.10"
"prop-types": "^15.7.2"
}
},
"@jest/console": {
@@ -11519,6 +11519,11 @@
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
"integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8="
},
"lodash.debounce": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
"integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168="
},
"lodash.defaultsdeep": {
"version": "4.6.1",
"resolved": "https://registry.npmjs.org/lodash.defaultsdeep/-/lodash.defaultsdeep-4.6.1.tgz",
@@ -14603,7 +14608,7 @@
"pubsub-js": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/pubsub-js/-/pubsub-js-1.7.0.tgz",
"integrity": "sha1-7Kl/mkIXvvYrLTqqFVIAUmDMLkk="
"integrity": "sha512-Pb68P9qFZxnvDipHMuj9oT1FoIgBcXJ9C9eWdHCLZAnulaUoJ3+Y87RhGMYilWpun6DMWVmvK70T4RP4drZMSA=="
},
"pump": {
"version": "3.0.0",
@@ -14734,7 +14739,8 @@
"querystring": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz",
"integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA="
"integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=",
"dev": true
},
"querystring-es3": {
"version": "0.2.1",
@@ -14949,7 +14955,7 @@
"react-intl": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/react-intl/-/react-intl-2.9.0.tgz",
"integrity": "sha1-yXxdF9RxjxV1/b1adp+WAYo7GEM=",
"integrity": "sha512-27jnDlb/d2A7mSJwrbOBnUgD+rPep+abmoJE511Tf8BnoONIAUehy/U1zZCHGO17mnOwMWxqN4qC0nW11cD6rA==",
"requires": {
"hoist-non-react-statics": "^3.3.0",
"intl-format-cache": "^2.0.5",

View File

@@ -29,15 +29,15 @@
"ie 11"
],
"dependencies": {
"@edx/frontend-component-footer": "10.0.7",
"@edx/frontend-component-footer": "10.0.9",
"@edx/frontend-component-header": "2.0.5",
"@edx/frontend-platform": "1.2.0",
"@edx/frontend-platform": "1.1.14",
"@edx/paragon": "7.1.5",
"@fortawesome/fontawesome-svg-core": "1.2.27",
"@fortawesome/fontawesome-svg-core": "1.2.28",
"@fortawesome/free-brands-svg-icons": "5.8.2",
"@fortawesome/free-regular-svg-icons": "5.7.2",
"@fortawesome/free-solid-svg-icons": "5.8.2",
"@fortawesome/react-fontawesome": "0.1.8",
"@fortawesome/react-fontawesome": "0.1.9",
"babel-polyfill": "6.26.0",
"classnames": "2.2.6",
"font-awesome": "4.7.0",
@@ -45,6 +45,7 @@
"formdata-polyfill": "3.0.19",
"history": "4.10.1",
"lodash.camelcase": "4.3.0",
"lodash.debounce": "4.0.8",
"lodash.findindex": "4.6.0",
"lodash.get": "4.4.2",
"lodash.isempty": "4.4.0",
@@ -55,7 +56,6 @@
"memoize-one": "5.1.1",
"newrelic": "5.13.1",
"prop-types": "15.7.2",
"querystring": "0.2.0",
"react": "16.10.2",
"react-dom": "16.10.2",
"react-redux": "7.1.3",

View File

@@ -31,8 +31,11 @@ import {
YEAR_OF_BIRTH_OPTIONS,
EDUCATION_LEVELS,
GENDER_OPTIONS,
COUNTRY_WITH_STATES,
getStatesList,
} from './data/constants';
import { fetchSiteLanguages } from './site-language';
import CoachingToggle from './coaching/CoachingToggle';
class AccountSettingsPage extends React.Component {
constructor(props, context) {
@@ -81,11 +84,15 @@ class AccountSettingsPage extends React.Component {
return concatTimeZoneOptions;
});
getLocalizedOptions = memoize(locale => ({
getLocalizedOptions = memoize((locale, country) => ({
countryOptions: [{
value: '',
label: this.props.intl.formatMessage(messages['account.settings.field.country.options.empty']),
}].concat(getCountryList(locale).map(({ code, name }) => ({ value: code, label: name }))),
stateOptions: [{
value: '',
label: this.props.intl.formatMessage(messages['account.settings.field.state.options.empty']),
}].concat(getStatesList(country)),
languageProficiencyOptions: [{
value: '',
label: this.props.intl.formatMessage(messages['account.settings.field.language_proficiencies.options.empty']),
@@ -183,7 +190,7 @@ class AccountSettingsPage extends React.Component {
}
renderSecondaryEmailField(editableFieldProps) {
if (this.props.hiddenFields.includes('secondary_email')) {
if (!Boolean(this.props.formValues.secondary_email_enabled)) {
return null;
}
@@ -208,11 +215,15 @@ class AccountSettingsPage extends React.Component {
// Memoized options lists
const {
countryOptions,
stateOptions,
languageProficiencyOptions,
yearOfBirthOptions,
educationLevelOptions,
genderOptions,
} = this.getLocalizedOptions(this.context.locale);
} = this.getLocalizedOptions(this.context.locale, this.props.formValues.country);
// Show State field only if the country is US (could include Canada later)
const showState = this.props.formValues.country == COUNTRY_WITH_STATES;
const timeZoneOptions = this.getLocalizedTimeZoneOptions(
this.props.timeZoneOptions,
@@ -293,6 +304,22 @@ class AccountSettingsPage extends React.Component {
isEditable={this.isEditable('country')}
{...editableFieldProps}
/>
{showState &&
<EditableField
name="state"
type="select"
value={this.props.formValues.state}
options={stateOptions}
label={this.props.intl.formatMessage(messages['account.settings.field.state'])}
emptyLabel={
this.isEditable('state') ?
this.props.intl.formatMessage(messages['account.settings.field.state.empty']) :
this.renderEmptyStaticFieldMessage()
}
isEditable={this.isEditable('state')}
{...editableFieldProps}
/>
}
</div>
<div className="account-section" id="profile-information">
@@ -327,6 +354,14 @@ class AccountSettingsPage extends React.Component {
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.language.proficiencies.empty'])}
{...editableFieldProps}
/>
{getConfig().COACHING_ENABLED &&
this.props.formValues.coaching.eligible_for_coaching &&
<CoachingToggle
name="coaching"
phone_number={this.props.formValues.phone_number}
coaching={this.props.formValues.coaching}
/>
}
</div>
<div className="account-section" id="social-media">
@@ -379,7 +414,7 @@ class AccountSettingsPage extends React.Component {
<EditableField
name="time_zone"
type="select"
value={this.props.formValues.time_zone || ''}
value={this.props.formValues.time_zone}
options={timeZoneOptions}
label={this.props.intl.formatMessage(messages['account.settings.field.time.zone'])}
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.time.zone.empty'])}
@@ -474,10 +509,16 @@ AccountSettingsPage.propTypes = {
level_of_education: PropTypes.string,
gender: PropTypes.string,
language_proficiencies: PropTypes.string,
phone_number: PropTypes.string,
social_link_linkedin: PropTypes.string,
social_link_facebook: PropTypes.string,
social_link_twitter: PropTypes.string,
time_zone: PropTypes.string,
coaching: PropTypes.shape({
coaching_consent: PropTypes.bool.isRequired,
user: PropTypes.number.isRequired,
eligible_for_coaching: PropTypes.bool.isRequired,
}),
}).isRequired,
siteLanguage: PropTypes.shape({
previousValue: PropTypes.string,
@@ -489,8 +530,8 @@ AccountSettingsPage.propTypes = {
})),
profileDataManager: PropTypes.string,
staticFields: PropTypes.arrayOf(PropTypes.string),
hiddenFields: PropTypes.arrayOf(PropTypes.string),
isActive: PropTypes.bool,
secondary_email_enabled: PropTypes.bool,
timeZoneOptions: PropTypes.arrayOf(PropTypes.shape({
label: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
@@ -517,9 +558,9 @@ AccountSettingsPage.defaultProps = {
countryTimeZoneOptions: [],
profileDataManager: null,
staticFields: [],
hiddenFields: ['secondary_email'],
tpaProviders: [],
isActive: true,
secondary_email_enabled: false,
};
export default connect(accountSettingsPageSelector, {

View File

@@ -156,6 +156,21 @@ const messages = defineMessages({
defaultMessage: 'Select a Country',
description: 'Option for empty value on account settings country field.',
},
'account.settings.field.state': {
id: 'account.settings.field.state',
defaultMessage: 'State',
description: 'Label for account settings state field.',
},
'account.settings.field.state.empty': {
id: 'account.settings.field.state.empty',
defaultMessage: 'Add state',
description: 'Placeholder for empty account settings state field.',
},
'account.settings.field.state.options.empty': {
id: 'account.settings.field.state.options.empty',
defaultMessage: 'Select a State',
description: 'Option for empty value on account settings state field.',
},
'account.settings.field.site.language': {
id: 'account.settings.field.site.language',
defaultMessage: 'Site language',

View File

@@ -35,4 +35,13 @@
margin-bottom: map-get($spacers, 5);
padding-top: 1rem;
}
.custom-switch {
padding: 0;
max-width: 500px;
.custom-control-label {
left: 2.25rem;
line-height: 1.6rem;
}
}
}

View File

@@ -0,0 +1,316 @@
import React from 'react';
import { getConfig, getQueryParameters } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Hyperlink } from '@edx/paragon';
import { faCheck } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import PageLoading from '../PageLoading';
import CoachingConsentForm from './CoachingConsentForm';
import messages from './CoachingConsent.messages';
import LogoSVG from '../../logo.svg';
import { fetchSettings, saveSettings, saveMultipleSettings } from '../data/actions';
import { coachingConsentPageSelector } from '../data/selectors';
const Logo = ({ src, alt, ...attributes }) => (
<>
<img src={src} alt={alt} {...attributes} />
</>
);
const SuccessMessage = props => (
<div className="col-12 col-lg-6 shadow-lg mx-auto mt-4 p-5">
<FontAwesomeIcon className="text-success" icon={faCheck} size="5x" />
<div className="h3">{props.header}</div>
<div>{props.message}</div>
<Hyperlink destination={props.continueUrl} className="d-block p-2 my-3 text-center text-white bg-primary rounded">
{props.continue}
</Hyperlink>
</div>
);
const AutoRedirect = (props) => {
window.location.href = props.redirectUrl;
return <></>;
};
const VIEWS = {
NOT_LOADED: 'NOT_LOADED',
LOADED: 'LOADED',
SUCCESS: 'SUCCESS',
SUCCESS_PENDING: 'SUCCESS_PENDING',
DECLINED: 'DECLINED',
DECLINE_PENDING: 'DECLINE_PENDING',
};
class CoachingConsent extends React.Component {
constructor(props, context) {
super(props, context);
// Used to redirect back to the courseware.
const nextUrl = this.sanitizeForwardingUrl(getQueryParameters().next);
this.state = {
redirectUrl: nextUrl || `${getConfig().LMS_BASE_URL}/dashboard/`,
formErrors: {},
formSubmitted: false,
declineSubmitted: false,
allSubmissionsComplete: false,
};
this.handleSubmit = this.handleSubmit.bind(this);
this.declineCoaching = this.declineCoaching.bind(this);
}
componentDidMount() {
this.props.fetchSettings();
}
componentDidUpdate(prevProps, prevState) {
/*
When we are submitting the form, we're calling saveSettings 3 times, which causes
multiple parallel redux flows. Because of this we can't rely on just the redux states
being sent in through props. For instance if the coaching submission and name
submission happen in near parallel, the coaching flow could return errors in
formErrors and the name flow could overwrite the formErrors with an empty object.
To minimize disruption to the rest of the app, we're going to manage flow state from
within this component.
*/
// If a new error comes in, store it before the next redux call overwrites it.
let allFormErrors = {};
let allSubmissionsComplete = false;
// Collect new errors and add to state (will be cleared on new submission)
const newErrorsFound = (
this.props.formErrors !== prevProps.formErrors
&& Object.keys(this.props.formErrors).length > 0
);
if (newErrorsFound) {
allFormErrors = Object.assign({}, this.state.formErrors, this.props.formErrors);
}
// Check if all values from the form have confirmation values
if (
this.state.formSubmitted &&
this.props.saveState === 'complete'
) {
allSubmissionsComplete = true;
}
// Check if all values from the decline link have confirmation values
if (this.props.confirmationValues.coaching && this.state.declineSubmitted) {
allSubmissionsComplete = true;
}
if (newErrorsFound || (allSubmissionsComplete !== prevState.allSubmissionsComplete)) {
this.setState({
formErrors: allFormErrors,
allSubmissionsComplete,
});
}
}
sanitizeForwardingUrl(url) {
// Redirect to root of MFE if invalid next param is sent
return url && url.startsWith(getConfig().LMS_BASE_URL) ? url : `${getConfig().LMS_BASE_URL}/dashboard/`;
}
async handleSubmit(e) {
e.preventDefault();
this.setState({
formErrors: {},
formSubmitted: true,
declineSubmitted: false,
});
// Must store target values or they disappear before the async function can use them.
const fullName = e.target.fullName.value;
const phoneNumber = e.target.phoneNumber.value;
const coachingValues = this.props.formValues.coaching;
// !important: The order of this data matters!
// The order that this data is in, is the order that the saveSettings() function
// is called.
const settingsSubmissions = [];
if (!this.props.profileDataManager) {
settingsSubmissions.push({
formId: 'name',
commitValues: fullName,
});
}
Array.prototype.push.apply(settingsSubmissions, [
{
formId: 'coaching',
commitValues: {
...coachingValues,
phone_number: phoneNumber,
coaching_consent: true,
consent_form_seen: true,
},
},
{
formId: 'phone_number',
commitValues: phoneNumber,
},
]);
this.props.saveMultipleSettings(settingsSubmissions);
}
async declineCoaching(e) {
e.preventDefault();
this.setState({
formErrors: {},
declineSubmitted: true,
formSubmitted: false,
});
// Must store target values or they disappear before the async function can use them.
const coachingValues = this.props.formValues.coaching;
this.props.saveSettings('coaching', {
...coachingValues,
coaching_consent: false,
consent_form_seen: true,
});
}
renderView(currentView) {
switch (currentView) {
case VIEWS.NOT_LOADED:
return <PageLoading srMessage="" />;
case VIEWS.LOADED:
return (<CoachingConsentForm
onSubmit={this.handleSubmit}
declineCoaching={this.declineCoaching}
formErrors={this.state.formErrors}
formValues={this.props.formValues}
redirectUrl={this.state.redirectUrl}
profileDataManager={this.props.profileDataManager}
/>);
case VIEWS.SUCCESS_PENDING:
return <PageLoading srMessage="Submitting..." />;
case VIEWS.SUCCESS:
return (<SuccessMessage
continueUrl={this.state.redirectUrl}
header={this.props.intl.formatMessage(messages['account.settings.coaching.consent.success.header'])}
message={this.props.intl.formatMessage(messages['account.settings.coaching.consent.success.message'])}
continue={this.props.intl.formatMessage(messages['account.settings.coaching.consent.success.continue'])}
/>);
case VIEWS.DECLINE_PENDING:
return <PageLoading srMessage="Redirecting..." />;
case VIEWS.DECLINED:
return <AutoRedirect redirectUrl={this.state.redirectUrl} />;
default:
return <></>;
}
}
render() {
const { loaded } = this.props;
const formHasErrors = Object.keys(this.state.formErrors).length > 0;
let currentView = null;
// This amount of logic was making the template very hard to read, so I broke it out into views.
if (!loaded) {
currentView = VIEWS.NOT_LOADED;
} else if (this.state.formSubmitted && !formHasErrors) {
if (this.state.allSubmissionsComplete) {
currentView = VIEWS.SUCCESS;
} else {
currentView = VIEWS.SUCCESS_PENDING;
}
} else if (this.state.declineSubmitted && !formHasErrors) {
if (this.state.allSubmissionsComplete) {
currentView = VIEWS.DECLINED;
} else {
currentView = VIEWS.DECLINE_PENDING;
}
} else {
currentView = VIEWS.LOADED;
}
return (
<main>
<div className="w-100 d-flex justify-content-center align-items-center shadow coaching-header">
<Logo
className="logo"
src={LogoSVG}
alt="Logo"
/>
</div>
{this.renderView(currentView)}
</main>
);
}
}
Logo.defaultProps = {
src: '',
alt: '',
};
Logo.propTypes = {
src: PropTypes.string,
alt: PropTypes.string,
};
SuccessMessage.defaultProps = {
header: '',
message: '',
continueUrl: '',
continue: '',
};
SuccessMessage.propTypes = {
header: PropTypes.string,
message: PropTypes.string,
continueUrl: PropTypes.string,
continue: PropTypes.string,
};
AutoRedirect.defaultProps = {
redirectUrl: '',
};
AutoRedirect.propTypes = {
redirectUrl: PropTypes.string,
};
CoachingConsent.defaultProps = {
loaded: false,
saveState: undefined,
profileDataManager: null,
};
CoachingConsent.propTypes = {
intl: intlShape.isRequired,
loaded: PropTypes.bool,
formValues: PropTypes.shape({
name: PropTypes.string,
phone_number: PropTypes.string,
coaching: PropTypes.shape({
coaching_consent: PropTypes.bool.isRequired,
user: PropTypes.number.isRequired,
eligible_for_coaching: PropTypes.bool.isRequired,
consent_form_seen: PropTypes.bool.isRequired,
}),
}).isRequired,
formErrors: PropTypes.shape({
coaching: PropTypes.object,
}).isRequired,
confirmationValues: PropTypes.shape({
coaching: PropTypes.object,
name: PropTypes.object,
phone_number: PropTypes.object,
}).isRequired,
fetchSettings: PropTypes.func.isRequired,
saveSettings: PropTypes.func.isRequired,
saveMultipleSettings: PropTypes.func.isRequired,
saveState: PropTypes.string,
profileDataManager: PropTypes.string,
};
export default connect(coachingConsentPageSelector, {
fetchSettings,
saveSettings,
saveMultipleSettings,
})(injectIntl(CoachingConsent));

View File

@@ -0,0 +1,66 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'account.settings.coaching.consent.welcome.header': {
id: 'account.settings.coaching.consent.welcome.header',
defaultMessage: 'Lets get started.',
description: 'The welcome header for consent form.',
},
'account.settings.coaching.consent.welcome.subheader': {
id: 'account.settings.coaching.consent.welcome.subheader',
defaultMessage: "We're here for you from start to finish",
description: 'The welcome subheader for consent form.',
},
'account.settings.coaching.consent.description': {
id: 'account.settings.coaching.consent.description',
defaultMessage: "MicroBachelors programs include coaching that focuses on your career, education, and how you'll achieve results through one-on-one communication with an experienced professional. If youre interested, provide the information below and click “Submit,” and our coaching partner will connect with you via email and/or text message to help you move forward. Terms and conditions apply.*",
description: 'Text describing what Coaching is.',
},
'account.settings.coaching.consent.text-messaging.disclaimer': {
id: 'account.settings.coaching.consent.text-messaging.disclaimer',
defaultMessage: '* Coaching services are included at no additional cost to learners with US phone numbers. Coaching includes recurring text messages. Message and data rates may apply. Text STOP to opt-out.',
description: 'Text describing what Coaching is.',
},
'account.settings.coaching.consent.accept-coaching': {
id: 'account.settings.coaching.consent.accept-coaching',
defaultMessage: 'Sign up for coaching',
description: 'Text to confirm coaching enablement',
},
'account.settings.coaching.consent.decline-coaching': {
id: 'account.settings.coaching.consent.decline-coaching',
defaultMessage: 'I prefer not to be contacted with free coaching services',
description: 'Text to decline coaching enablement',
},
'account.settings.coaching.consent.label.name': {
id: 'account.settings.coaching.consent.label.name',
defaultMessage: 'Please confirm your name',
description: 'Label for name input',
},
'account.settings.coaching.consent.label.phone-number': {
id: 'account.settings.coaching.consent.label.phone-number',
defaultMessage: 'Enter your mobile number',
description: 'Label for mobile phone number input',
},
'account.settings.coaching.consent.success.header': {
id: 'account.settings.coaching.consent.success.header',
defaultMessage: 'Success!',
description: 'Heading announcing that submission succeeded',
},
'account.settings.coaching.consent.success.message': {
id: 'account.settings.coaching.consent.success.message',
defaultMessage: "You're signed up for coaching. You will receive a text message confirmation.",
description: 'Text announcing that you have signed up and will receive texts',
},
'account.settings.coaching.consent.success.continue': {
id: 'account.settings.coaching.consent.success.continue',
defaultMessage: 'Start my course',
description: 'Text that the user will be sent back to the courseware',
},
'account.settings.coaching.managed.support': {
id: 'account.settings.coaching.managed.support',
defaultMessage: 'support',
description: 'website support',
},
});
export default messages;

View File

@@ -0,0 +1,130 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import { Input, Button, Hyperlink } from '@edx/paragon';
import PropTypes from 'prop-types';
import Alert from '../Alert';
import messages from './CoachingConsent.messages';
const ErrorMessage = props => (
<div className="alert-warning mb-2">{props.message}</div>
);
const ManagedProfileAlert = ({ profileDataManager }) => (
<Alert className="alert alert-primary" role="alert">
<FormattedMessage
id="account.settings.coaching.managed.alert"
defaultMessage="Your name is managed by {managerTitle}. Contact your administrator for help."
description="alert message informing the user their account data is managed by a third party"
values={{
managerTitle: <b>{profileDataManager}</b>,
}}
/>
</Alert>
);
const CoachingForm = props => (
<div className="col-12 col-md-6 col-xl-5 mx-auto mt-4 p-5 shadow-lg">
<h2 className="h2">
{props.intl.formatMessage(messages['account.settings.coaching.consent.welcome.header'])}
</h2>
<p>{props.intl.formatMessage(messages['account.settings.coaching.consent.description'])}</p>
<div>
<form onSubmit={props.onSubmit}>
<div className="py-3">
{
!!props.profileDataManager &&
<ManagedProfileAlert profileDataManager={props.profileDataManager} />
}
<ErrorMessage message={props.formErrors.name} />
<label className="h6" htmlFor="fullName">{props.intl.formatMessage(messages['account.settings.coaching.consent.label.name'])}</label>
<Input
type="text"
name="full-name"
id="fullName"
disabled={!!props.profileDataManager}
defaultValue={props.formValues.name}
/>
</div>
<div className="py-3">
<ErrorMessage message={props.formErrors.phone_number} />
<label className="h6" htmlFor="phoneNumber">{props.intl.formatMessage(messages['account.settings.coaching.consent.label.phone-number'])}</label>
<Input
type="text"
name="full-name"
id="phoneNumber"
defaultValue={props.formValues.phone_number}
/>
</div>
<div className=" py-3">
<p className="small font-italic">
{props.intl.formatMessage(messages['account.settings.coaching.consent.text-messaging.disclaimer'])}
</p>
</div>
<ErrorMessage message={props.formErrors.coaching} />
<div className="d-flex flex-column align-items-center">
<Button className="w-100 btn-outline-primary" type="submit">
{props.intl.formatMessage(messages['account.settings.coaching.consent.accept-coaching'])}
</Button>
</div>
<div className="mt-3">
<Hyperlink
className="mt-3 text-dark btn-link small"
destination={props.redirectUrl}
onClick={props.declineCoaching}
>
{props.intl.formatMessage(messages['account.settings.coaching.consent.decline-coaching'])}
</Hyperlink>
</div>
</form>
</div>
</div>
);
CoachingForm.defaultProps = {
formErrors: {
coaching: '',
name: '',
phone_number: '',
},
};
CoachingForm.propTypes = {
intl: intlShape.isRequired,
onSubmit: PropTypes.func.isRequired,
declineCoaching: PropTypes.func.isRequired,
formValues: PropTypes.shape({
name: PropTypes.string,
phone_number: PropTypes.string,
coaching: PropTypes.shape({
coaching_consent: PropTypes.bool.isRequired,
user: PropTypes.number.isRequired,
eligible_for_coaching: PropTypes.bool.isRequired,
consent_form_seen: PropTypes.bool.isRequired,
}),
}).isRequired,
formErrors: PropTypes.shape({
coaching: PropTypes.string,
name: PropTypes.string,
phone_number: PropTypes.string,
}),
redirectUrl: PropTypes.string.isRequired,
profileDataManager: PropTypes.string.isRequired,
};
ErrorMessage.defaultProps = {
message: '',
};
ErrorMessage.propTypes = {
message: PropTypes.string,
};
ManagedProfileAlert.propTypes = {
profileDataManager: PropTypes.string.isRequired,
intl: intlShape.isRequired,
};
export default injectIntl(CoachingForm);

View File

@@ -0,0 +1,99 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { ValidationFormGroup, Input } from '@edx/paragon';
import messages from './CoachingToggle.messages';
import { editableFieldSelector } from '../data/selectors';
import { saveSettings, updateDraft, saveMultipleSettings } from '../data/actions';
import EditableField from '../EditableField';
const CoachingToggle = props => (
<>
<EditableField
name="phone_number"
type="text"
value={props.phone_number}
label={props.intl.formatMessage(messages['account.settings.field.phone_number'])}
emptyLabel={props.intl.formatMessage(messages['account.settings.field.phone_number.empty'])}
onChange={props.updateDraft}
onSubmit={() => {
const { coaching } = props;
if (coaching.coaching_consent === true) {
return props.saveMultipleSettings([
{
formId: 'coaching',
commitValues: {
...coaching,
phone_number: props.phone_number,
},
},
{
formId: 'phone_number',
commitValues: props.phone_number,
},
]);
}
return props.saveSettings('phone_number', props.phone_number);
}}
/>
<ValidationFormGroup
for="coachingConsent"
helpText={props.intl.formatMessage(messages['account.settings.field.coaching_consent.tooltip'])}
invalid={!!props.error}
invalidMessage={props.intl.formatMessage(messages['account.settings.field.coaching_consent.error'])}
className="custom-control custom-switch"
>
<Input
name={props.name}
className="custom-control-input"
disabled={props.saveState === 'pending'}
type="checkbox"
id="coachingConsent"
checked={props.coaching.coaching_consent}
value={props.coaching.coaching_consent}
onChange={async (e) => {
const { name } = e.target;
// eslint-disable-next-line camelcase
const { user, eligible_for_coaching } = props.coaching;
const value = {
user,
eligible_for_coaching,
coaching_consent: e.target.checked,
};
props.saveSettings(name, value);
}}
/>
<label className="custom-control-label" htmlFor="coachingConsent">{props.intl.formatMessage(messages['account.settings.field.coaching_consent'])}</label>
</ValidationFormGroup>
</>
);
CoachingToggle.defaultProps = {
phone_number: '',
error: '',
saveState: undefined,
};
CoachingToggle.propTypes = {
name: PropTypes.string.isRequired,
error: PropTypes.string,
coaching: PropTypes.shape({
coaching_consent: PropTypes.bool.isRequired,
user: PropTypes.number.isRequired,
eligible_for_coaching: PropTypes.bool.isRequired,
}).isRequired,
saveState: PropTypes.oneOf(['default', 'pending', 'complete', 'error']),
saveSettings: PropTypes.func.isRequired,
saveMultipleSettings: PropTypes.func.isRequired,
updateDraft: PropTypes.func.isRequired,
intl: intlShape.isRequired,
phone_number: PropTypes.string,
};
export default connect(editableFieldSelector, {
saveSettings,
updateDraft,
saveMultipleSettings,
})(injectIntl(CoachingToggle));

View File

@@ -0,0 +1,31 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'account.settings.field.phone_number': {
id: 'account.settings.field.phone_number',
defaultMessage: 'Phone Number',
description: 'The label for a phone numbers setting in the user profile',
},
'account.settings.field.phone_number.empty': {
id: 'account.settings.field.phone_number.empty',
defaultMessage: 'Add a phone number',
description: 'placeholder for a profiles empty phone number field',
},
'account.settings.field.coaching_consent': {
id: 'account.settings.field.coaching_consent',
defaultMessage: 'Coaching consent',
description: 'The label for the coaching consent setting in the user profile',
},
'account.settings.field.coaching_consent.tooltip': {
id: 'account.settings.field.coaching_consent.tooltip',
defaultMessage: 'MicroBachelors programs include text message based coaching that helps you pair educational experiences with your career goals through one-on-one advice. Coaching services are included at no additional cost, and are available to learners with U.S. mobile phone numbers. Standard messaging rates apply. Text STOP at anytime to opt-out of messages.',
description: 'A tooltip explaining what coaching is and who it is for',
},
'account.settings.field.coaching_consent.error': {
id: 'account.settings.field.coaching_consent.error',
defaultMessage: 'A valid US phone number is required to opt into coaching',
description: 'An error message that displays when a user attempts to consent to coaching without first providing a phone number in their profile',
},
});
export default messages;

View File

@@ -0,0 +1,51 @@
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { getConfig } from '@edx/frontend-platform';
import get from 'lodash.get';
/**
* get all settings related to the coaching plugin. Settings used
* by Microbachelors students.
* @param {Number} userId users are identified in the api by LMS id
*/
export async function getCoachingPreferences(userId) {
let data = {};
try {
({ data } = await getAuthenticatedHttpClient()
.get(`${getConfig().LMS_BASE_URL}/api/coaching/v1/users/${userId}/`));
} catch (error) {
// If a user isn't active the API call will fail with a lack of credentials.
data = {
coaching_consent: false,
user: userId,
eligible_for_coaching: false,
consent_form_seen: false,
};
}
return data;
}
/**
* patch all of the settings related to coaching.
* @param {Number} userId users are identified in the api by LMS id
* @param {Object} commitValues { coaching }
*/
export async function patchCoachingPreferences(userId, commitValues) {
const requestUrl = `${getConfig().LMS_BASE_URL}/api/coaching/v1/users/${userId}/`;
const { coaching } = commitValues;
coaching.user = userId;
await getAuthenticatedHttpClient()
.patch(requestUrl, coaching)
.catch((error) => {
const apiError = Object.create(error);
apiError.fieldErrors = JSON.parse(error.customAttributes.httpErrorResponseData);
if (get(apiError, 'fieldErrors.phone_number')) {
// eslint-disable-next-line prefer-destructuring
apiError.fieldErrors.coaching = apiError.fieldErrors.phone_number[0];
delete apiError.fieldErrors.phone_number;
}
throw apiError;
});
return commitValues;
}

View File

@@ -2,6 +2,7 @@ import { AsyncActionType } from './utils';
export const FETCH_SETTINGS = new AsyncActionType('ACCOUNT_SETTINGS', 'FETCH_SETTINGS');
export const SAVE_SETTINGS = new AsyncActionType('ACCOUNT_SETTINGS', 'SAVE_SETTINGS');
export const SAVE_MULTIPLE_SETTINGS = new AsyncActionType('ACCOUNT_SETTINGS', 'SAVE_MULTIPLE_SETTINGS');
export const FETCH_TIME_ZONES = new AsyncActionType('ACCOUNT_SETTINGS', 'FETCH_TIME_ZONES');
export const SAVE_PREVIOUS_SITE_LANGUAGE = 'SAVE_PREVIOUS_SITE_LANGUAGE';
export const OPEN_FORM = 'OPEN_FORM';
@@ -99,6 +100,25 @@ export const savePreviousSiteLanguage = previousSiteLanguage => ({
payload: { previousSiteLanguage },
});
export const saveMultipleSettings = settingsArray => ({
type: SAVE_MULTIPLE_SETTINGS.BASE,
payload: { settingsArray },
});
export const saveMultipleSettingsBegin = () => ({
type: SAVE_MULTIPLE_SETTINGS.BEGIN,
});
export const saveMultipleSettingsSuccess = settingsArray => ({
type: SAVE_MULTIPLE_SETTINGS.SUCCESS,
payload: { settingsArray },
});
export const saveMultipleSettingsFailure = ({ fieldErrors, message }) => ({
type: SAVE_MULTIPLE_SETTINGS.FAILURE,
payload: { errors: fieldErrors, message },
});
// FETCH TIME_ZONE ACTIONS
export const fetchTimeZones = country => ({

View File

@@ -31,4 +31,84 @@ export const GENDER_OPTIONS = [
'o',
];
export const COUNTRY_WITH_STATES = 'US';
export const TRANSIFEX_LANGUAGE_BASE_URL = 'https://www.transifex.com/open-edx/edx-platform/language/';
const COUNTRY_STATES_MAP = {
CA: [
{ value: 'AB', label: 'Alberta' },
{ value: 'BC', label: 'British Columbia' },
{ value: 'MB', label: 'Manitoba' },
{ value: 'NB', label: 'New Brunswick' },
{ value: 'NL', label: 'Newfoundland and Labrador' },
{ value: 'NS', label: 'Nova Scotia' },
{ value: 'NT', label: 'Northwest Territories' },
{ value: 'NU', label: 'Nunavut' },
{ value: 'ON', label: 'Ontario' },
{ value: 'PE', label: 'Prince Edward Island' },
{ value: 'QC', label: 'Québec' },
{ value: 'SK', label: 'Saskatchewan' },
{ value: 'YT', label: 'Yukon' },
],
US: [
{ value: 'AL', label: 'Alabama' },
{ value: 'AK', label: 'Alaska' },
{ value: 'AZ', label: 'Arizona' },
{ value: 'AR', label: 'Arkansas' },
{ value: 'AA', label: 'Armed Forces Americas' },
{ value: 'AE', label: 'Armed Forces Europe' },
{ value: 'AP', label: 'Armed Forces Pacific' },
{ value: 'CA', label: 'California' },
{ value: 'CO', label: 'Colorado' },
{ value: 'CT', label: 'Connecticut' },
{ value: 'DE', label: 'Delaware' },
{ value: 'DC', label: 'District Of Columbia' },
{ value: 'FL', label: 'Florida' },
{ value: 'GA', label: 'Georgia' },
{ value: 'HI', label: 'Hawaii' },
{ value: 'ID', label: 'Idaho' },
{ value: 'IL', label: 'Illinois' },
{ value: 'IN', label: 'Indiana' },
{ value: 'IA', label: 'Iowa' },
{ value: 'KS', label: 'Kansas' },
{ value: 'KY', label: 'Kentucky' },
{ value: 'LA', label: 'Louisiana' },
{ value: 'ME', label: 'Maine' },
{ value: 'MD', label: 'Maryland' },
{ value: 'MA', label: 'Massachusetts' },
{ value: 'MI', label: 'Michigan' },
{ value: 'MN', label: 'Minnesota' },
{ value: 'MS', label: 'Mississippi' },
{ value: 'MO', label: 'Missouri' },
{ value: 'MT', label: 'Montana' },
{ value: 'NE', label: 'Nebraska' },
{ value: 'NV', label: 'Nevada' },
{ value: 'NH', label: 'New Hampshire' },
{ value: 'NJ', label: 'New Jersey' },
{ value: 'NM', label: 'New Mexico' },
{ value: 'NY', label: 'New York' },
{ value: 'NC', label: 'North Carolina' },
{ value: 'ND', label: 'North Dakota' },
{ value: 'OH', label: 'Ohio' },
{ value: 'OK', label: 'Oklahoma' },
{ value: 'OR', label: 'Oregon' },
{ value: 'PA', label: 'Pennsylvania' },
{ value: 'RI', label: 'Rhode Island' },
{ value: 'SC', label: 'South Carolina' },
{ value: 'SD', label: 'South Dakota' },
{ value: 'TN', label: 'Tennessee' },
{ value: 'TX', label: 'Texas' },
{ value: 'UT', label: 'Utah' },
{ value: 'VT', label: 'Vermont' },
{ value: 'VA', label: 'Virginia' },
{ value: 'WA', label: 'Washington' },
{ value: 'WV', label: 'West Virginia' },
{ value: 'WI', label: 'Wisconsin' },
{ value: 'WY', label: 'Wyoming' },
],
};
export function getStatesList(country) {
return country && COUNTRY_STATES_MAP[country.toUpperCase()];
}

View File

@@ -7,6 +7,7 @@ import {
SAVE_PREVIOUS_SITE_LANGUAGE,
UPDATE_DRAFT,
RESET_DRAFTS,
SAVE_MULTIPLE_SETTINGS,
} from './actions';
import { reducer as deleteAccountReducer, DELETE_ACCOUNT } from '../delete-account';
@@ -144,6 +145,24 @@ const reducer = (state = defaultState, action) => {
...state,
previousSiteLanguage: action.payload.previousSiteLanguage,
};
case SAVE_MULTIPLE_SETTINGS.BEGIN:
return {
...state,
saveState: 'pending',
};
case SAVE_MULTIPLE_SETTINGS.SUCCESS:
return {
...state,
saveState: 'complete',
};
case SAVE_MULTIPLE_SETTINGS.FAILURE:
return {
...state,
saveState: 'error',
errors: Object.assign({}, state.errors, action.payload.errors),
};
case FETCH_TIME_ZONES.SUCCESS:
return {
@@ -177,6 +196,7 @@ const reducer = (state = defaultState, action) => {
case RESET_PASSWORD.BEGIN:
case RESET_PASSWORD.SUCCESS:
case RESET_PASSWORD.FORBIDDEN:
return {
...state,
resetPassword: resetPasswordReducer(state.resetPassword, action),

View File

@@ -12,6 +12,7 @@ import {
fetchSettingsFailure,
closeForm,
SAVE_SETTINGS,
SAVE_MULTIPLE_SETTINGS,
saveSettingsBegin,
saveSettingsSuccess,
saveSettingsFailure,
@@ -19,6 +20,9 @@ import {
FETCH_TIME_ZONES,
fetchTimeZones,
fetchTimeZonesSuccess,
saveMultipleSettingsBegin,
saveMultipleSettingsSuccess,
saveMultipleSettingsFailure,
} from './actions';
// Sub-modules
@@ -37,7 +41,7 @@ import { getSettings, patchSettings, getTimeZones } from './service';
export function* handleFetchSettings() {
try {
yield put(fetchSettingsBegin());
const { username, roles: userRoles } = getAuthenticatedUser();
const { username, userId, roles: userRoles } = getAuthenticatedUser();
const {
thirdPartyAuthProviders, profileDataManager, timeZones, ...values
@@ -45,6 +49,7 @@ export function* handleFetchSettings() {
getSettings,
username,
userRoles,
userId,
);
if (values.country) yield put(fetchTimeZones(values.country));
@@ -65,7 +70,7 @@ export function* handleSaveSettings(action) {
try {
yield put(saveSettingsBegin());
const { username } = getAuthenticatedUser();
const { username, userId } = getAuthenticatedUser();
const { commitValues, formId } = action.payload;
const commitData = { [formId]: commitValues };
let savedValues = null;
@@ -83,7 +88,7 @@ export function* handleSaveSettings(action) {
handleRtl();
savedValues = commitData;
} else {
savedValues = yield call(patchSettings, username, commitData);
savedValues = yield call(patchSettings, username, commitData, userId);
}
yield put(saveSettingsSuccess(savedValues, commitData));
if (savedValues.country) yield put(fetchTimeZones(savedValues.country));
@@ -99,6 +104,31 @@ export function* handleSaveSettings(action) {
}
}
// handles mutiple settings saved at once, in order, and stops executing on first failure.
export function* handleSaveMultipleSettings(settings) {
try {
yield put(saveMultipleSettingsBegin());
const { username, userId } = getAuthenticatedUser();
const { settingsArray } = settings.payload;
for (let i = 0; i < settingsArray.length; i += 1) {
const { formId, commitValues } = settingsArray[i];
yield put(saveSettingsBegin());
const commitData = { [formId]: commitValues };
const savedSettings = yield call(patchSettings, username, commitData, userId);
yield put(saveSettingsSuccess(savedSettings, commitData));
}
yield put(saveMultipleSettingsSuccess(settings));
} catch (e) {
if (e.fieldErrors) {
yield put(saveMultipleSettingsFailure({ fieldErrors: e.fieldErrors }));
} else {
yield put(saveMultipleSettingsFailure(e.message));
throw e;
}
}
}
export function* handleFetchTimeZones(action) {
const response = yield call(getTimeZones, action.payload.country);
yield put(fetchTimeZonesSuccess(response, action.payload.country));
@@ -108,6 +138,7 @@ export function* handleFetchTimeZones(action) {
export default function* saga() {
yield takeEvery(FETCH_SETTINGS.BASE, handleFetchSettings);
yield takeEvery(SAVE_SETTINGS.BASE, handleSaveSettings);
yield takeEvery(SAVE_MULTIPLE_SETTINGS.BASE, handleSaveMultipleSettings);
yield takeEvery(FETCH_TIME_ZONES.BASE, handleFetchTimeZones);
yield all([
deleteAccountSaga(),

View File

@@ -41,6 +41,16 @@ const isEditingSelector = createSelector(
(name, accountSettings) => accountSettings.openFormId === name,
);
const confirmationValuesSelector = createSelector(
accountSettingsSelector,
accountSettings => accountSettings.confirmationValues,
);
const errorSelector = createSelector(
accountSettingsSelector,
accountSettings => accountSettings.errors,
);
const saveStateSelector = createSelector(
accountSettingsSelector,
accountSettings => accountSettings.saveState,
@@ -63,10 +73,6 @@ export const staticFieldsSelector = createSelector(
accountSettings => (accountSettings.profileDataManager ? ['name', 'email', 'country'] : []),
);
export const hiddenFieldsSelector = createSelector(
accountSettingsSelector,
accountSettings => (accountSettings.profileDataManager ? [] : ['secondary_email']),
);
/**
* If there's no draft present at all (undefined), use the original committed value.
@@ -128,7 +134,6 @@ export const accountSettingsPageSelector = createSelector(
formValuesSelector,
profileDataManagerSelector,
staticFieldsSelector,
hiddenFieldsSelector,
timeZonesSelector,
countryTimeZonesSelector,
activeAccountSelector,
@@ -139,7 +144,6 @@ export const accountSettingsPageSelector = createSelector(
formValues,
profileDataManager,
staticFields,
hiddenFields,
timeZoneOptions,
countryTimeZoneOptions,
activeAccount,
@@ -155,7 +159,35 @@ export const accountSettingsPageSelector = createSelector(
formValues,
profileDataManager,
staticFields,
hiddenFields,
tpaProviders: accountSettings.thirdPartyAuth.providers,
}),
);
export const coachingConsentPageSelector = createSelector(
accountSettingsSelector,
formValuesSelector,
activeAccountSelector,
profileDataManagerSelector,
saveStateSelector,
confirmationValuesSelector,
errorSelector,
(
accountSettings,
formValues,
activeAccount,
profileDataManager,
saveState,
confirmationValues,
errors,
) => ({
loading: accountSettings.loading,
loaded: accountSettings.loaded,
loadingError: accountSettings.loadingError,
isActive: activeAccount,
profileDataManager,
formValues,
saveState,
confirmationValues,
formErrors: errors,
}),
);

View File

@@ -6,6 +6,7 @@ import isEmpty from 'lodash.isempty';
import { handleRequestError, unpackFieldErrors } from './utils';
import { getThirdPartyAuthProviders } from '../third-party-auth';
import { getCoachingPreferences, patchCoachingPreferences } from '../coaching/data/service';
const SOCIAL_PLATFORMS = [
{ id: 'twitter', key: 'social_link_twitter' },
@@ -136,12 +137,11 @@ export async function getProfileDataManager(username, userRoles) {
const url = `${getConfig().LMS_BASE_URL}/enterprise/api/v1/enterprise-learner/?username=${username}`;
const { data } = await getAuthenticatedHttpClient().get(url).catch(handleRequestError);
if ('results' in data) {
for (let i = 0; i < data.results.length; i += 1) {
const enterprise = data.results[i].enterprise_customer;
if (enterprise.sync_learner_profile_data) {
return enterprise.name;
}
if (data.results.length > 0) {
const enterprise = data.results[0] && data.results[0].enterprise_customer;
// To ensure that enterprise returned is current enterprise & it manages profile settings
if (enterprise && enterprise.sync_learner_profile_data) {
return enterprise.name;
}
}
}
@@ -151,15 +151,16 @@ export async function getProfileDataManager(username, userRoles) {
/**
* A single function to GET everything considered a setting.
* Currently encapsulates Account, Preferences, and ThirdPartyAuth
* Currently encapsulates Account, Preferences, Coaching, and ThirdPartyAuth
*/
export async function getSettings(username, userRoles) {
export async function getSettings(username, userRoles, userId) {
const results = await Promise.all([
getAccount(username),
getPreferences(username),
getThirdPartyAuthProviders(),
getProfileDataManager(username, userRoles),
getTimeZones(),
getConfig().COACHING_ENABLED && getCoachingPreferences(userId),
]);
return {
@@ -168,20 +169,23 @@ export async function getSettings(username, userRoles) {
thirdPartyAuthProviders: results[2],
profileDataManager: results[3],
timeZones: results[4],
coaching: results[5],
};
}
/**
* A single function to PATCH everything considered a setting.
* Currently encapsulates Account, Preferences, and ThirdPartyAuth
* Currently encapsulates Account, Preferences, coaching and ThirdPartyAuth
*/
export async function patchSettings(username, commitValues) {
export async function patchSettings(username, commitValues, userId) {
// Note: time_zone exists in the return value from user/v1/accounts
// but it is always null and won't update. It also exists in
// user/v1/preferences where it does update. This is the one we use.
const preferenceKeys = ['time_zone'];
const accountCommitValues = omit(commitValues, preferenceKeys);
const coachingKeys = ['coaching'];
const accountCommitValues = omit(commitValues, preferenceKeys, coachingKeys);
const preferenceCommitValues = pick(commitValues, preferenceKeys);
const coachingCommitValues = pick(commitValues, coachingKeys);
const patchRequests = [];
if (!isEmpty(accountCommitValues)) {
@@ -190,6 +194,9 @@ export async function patchSettings(username, commitValues) {
if (!isEmpty(preferenceCommitValues)) {
patchRequests.push(patchPreferences(username, preferenceCommitValues));
}
if (!isEmpty(coachingCommitValues)) {
patchRequests.push(patchCoachingPreferences(userId, coachingCommitValues));
}
const results = await Promise.all(patchRequests);
// Assigns in order of requests. Preference keys

View File

@@ -27,6 +27,10 @@ export class AsyncActionType {
get RESET() {
return `${this.topic}__${this.name}__RESET`;
}
get FORBIDDEN() {
return `${this.topic}__${this.name}__FORBIDDEN`;
}
}
/**

View File

@@ -12,6 +12,7 @@ describe('AsyncActionType', () => {
expect(actionType.SUCCESS).toBe('HOUSE_CATS__START_THE_RACE__SUCCESS');
expect(actionType.FAILURE).toBe('HOUSE_CATS__START_THE_RACE__FAILURE');
expect(actionType.RESET).toBe('HOUSE_CATS__START_THE_RACE__RESET');
expect(actionType.FORBIDDEN).toBe('HOUSE_CATS__START_THE_RACE__FORBIDDEN');
});
});

View File

@@ -0,0 +1,24 @@
import React from 'react';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
import Alert from '../Alert';
const RequestInProgressAlert = (props) => {
return (
<Alert
className="alert-warning mt-n2"
icon={<FontAwesomeIcon className="mr-2" icon={faExclamationTriangle} />}
>
<FormattedMessage
id="account.settings.editable.field.password.reset.button.forbidden"
defaultMessage="Your previous request is in progress, please try again in few moments."
description="A message displayed when a previous password reset request is still in progress."
/>
</Alert>
);
};
export default RequestInProgressAlert;

View File

@@ -7,6 +7,7 @@ import { StatefulButton } from '@edx/paragon';
import { resetPassword } from './data/actions';
import messages from './messages';
import ConfirmationAlert from './ConfirmationAlert';
import RequestInProgressAlert from './RequestInProgressAlert';
const ResetPassword = (props) => {
const { email, intl, status } = props;
@@ -43,6 +44,7 @@ const ResetPassword = (props) => {
/>
</p>
{status === 'complete' ? <ConfirmationAlert email={email} /> : null}
{status === 'forbidden' ? <RequestInProgressAlert /> : null}
</div>
);
};

View File

@@ -18,3 +18,7 @@ export const resetPasswordSuccess = () => ({
export const resetPasswordReset = () => ({
type: RESET_PASSWORD.RESET,
});
export const resetPasswordForbidden = () => ({
type: RESET_PASSWORD.FORBIDDEN,
});

View File

@@ -17,6 +17,11 @@ const reducer = (state = defaultState, action = null) => {
...state,
status: 'complete',
};
case RESET_PASSWORD.FORBIDDEN:
return {
...state,
status: 'forbidden',
};
default:
}

View File

@@ -1,12 +1,20 @@
import { put, call, takeEvery } from 'redux-saga/effects';
import { resetPasswordBegin, resetPasswordSuccess, RESET_PASSWORD } from './actions';
import { resetPasswordBegin, resetPasswordForbidden, resetPasswordSuccess, RESET_PASSWORD } from './actions';
import { postResetPassword } from './service';
function* handleResetPassword(action) {
yield put(resetPasswordBegin());
const response = yield call(postResetPassword, action.payload.email);
yield put(resetPasswordSuccess(response));
try {
const response = yield call(postResetPassword, action.payload.email);
yield put(resetPasswordSuccess(response));
} catch (error) {
if (error.response && error.response.status === 403) {
yield put(resetPasswordForbidden(error));
} else {
throw error;
}
}
}
export default function* saga() {

View File

@@ -5,12 +5,7 @@ import {
storeName as accountSettingsStoreName,
} from '../account-settings';
import {
reducer as registrationReducer,
} from '../registration';
const createRootReducer = () => combineReducers({
[accountSettingsStoreName]: accountSettingsReducer,
registration: registrationReducer,
});
export default createRootReducer;

View File

@@ -1,10 +1,6 @@
import { all } from 'redux-saga/effects';
import { saga as accountSettingsSaga } from '../account-settings';
import { saga as registrationSaga } from '../registration';
export default function* rootSaga() {
yield all([
accountSettingsSaga(),
registrationSaga(),
]);
yield all([accountSettingsSaga()]);
}

View File

@@ -33,6 +33,9 @@
"account.settings.field.country": "Country",
"account.settings.field.country.empty": "Add country",
"account.settings.field.country.options.empty": "Select a Country",
"account.settings.field.state": "State",
"account.settings.field.state.empty": "Add state",
"account.settings.field.state.options.empty": "Select a State",
"account.settings.field.site.language": "Site language",
"account.settings.field.site.language.help.text": "The language used throughout this site. This site is currently available in a limited number of languages.",
"account.settings.field.education": "Education",
@@ -76,6 +79,24 @@
"account.settings.editable.field.action.edit": "Edit",
"account.settings.static.field.empty": "No value set. Contact your {enterprise} administrator to make changes.",
"account.settings.static.field.empty.no.admin": "No value set.",
"account.settings.coaching.consent.welcome.header": "Lets get started.",
"account.settings.coaching.consent.welcome.subheader": "We're here for you from start to finish",
"account.settings.coaching.consent.description": "MicroBachelors programs include coaching that focuses on your career, education, and how you'll achieve results through one-on-one communication with an experienced professional. If youre interested, provide the information below and click “Submit,” and our coaching partner will connect with you via email and/or text message to help you move forward. Terms and conditions apply.*",
"account.settings.coaching.consent.text-messaging.disclaimer": "* Coaching services are included at no additional cost to learners with US phone numbers. Coaching includes recurring text messages. Message and data rates may apply. Text STOP to opt-out.",
"account.settings.coaching.consent.accept-coaching": "Sign up for coaching",
"account.settings.coaching.consent.decline-coaching": "I prefer not to be contacted with free coaching services",
"account.settings.coaching.consent.label.name": "Please confirm your name",
"account.settings.coaching.consent.label.phone-number": "Enter your mobile number",
"account.settings.coaching.consent.success.header": "Success!",
"account.settings.coaching.consent.success.message": "You're signed up for coaching. You will receive a text message confirmation.",
"account.settings.coaching.consent.success.continue": "Start my course",
"account.settings.coaching.managed.support": "support",
"account.settings.coaching.managed.alert": "Your name is managed by {managerTitle}. Contact your administrator for help.",
"account.settings.field.phone_number": "Phone Number",
"account.settings.field.phone_number.empty": "Add a phone number",
"account.settings.field.coaching_consent": "Coaching consent",
"account.settings.field.coaching_consent.tooltip": "MicroBachelors programs include text message based coaching that helps you pair educational experiences with your career goals through one-on-one advice. Coaching services are included at no additional cost, and are available to learners with U.S. mobile phone numbers. Standard messaging rates apply. Text STOP at anytime to opt-out of messages.",
"account.settings.field.coaching_consent.error": "A valid US phone number is required to opt into coaching",
"account.settings.delete.account.before.proceeding": "Before proceeding, please {actionLink}.",
"account.settings.delete.account.header": "Delete My Account",
"account.settings.delete.account.subheader": "We're sorry to see you go!",
@@ -104,6 +125,7 @@
"account.settings.editable.field.password.reset.button.confirmation.support.link": "technical support",
"account.settings.editable.field.password.reset.button.confirmation": "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.password.reset.button": "Reset Password",
"account.settings.editable.field.password.reset.button.forbidden": "Your previous request is in progress, please try again in few moments.",
"account.settings.editable.field.password.reset.label": "Password",
"account.settings.sso.link.account": "Sign in with {name}",
"account.settings.sso.account.connected": "Linked",

View File

@@ -33,6 +33,9 @@
"account.settings.field.country": "País",
"account.settings.field.country.empty": "Agregar país",
"account.settings.field.country.options.empty": "Seleccionar un país",
"account.settings.field.state": "Estado",
"account.settings.field.state.empty": "Add state",
"account.settings.field.state.options.empty": "Select a State",
"account.settings.field.site.language": "Idioma del sitio",
"account.settings.field.site.language.help.text": "El idioma que se usará para el sitio. Actualmente solo hay disponibilidad de usar un número limitado de idiomas.",
"account.settings.field.education": "Educación",
@@ -74,8 +77,26 @@
"account.settings.editable.field.action.save": "Guardar",
"account.settings.editable.field.action.cancel": "Cancelar",
"account.settings.editable.field.action.edit": "Editar",
"account.settings.static.field.empty": "No value set. Contact your {enterprise} administrator to make changes.",
"account.settings.static.field.empty.no.admin": "No value set.",
"account.settings.static.field.empty": "No hay valor establecido. Contacte su administrador {enterprise} para hacer cambios.",
"account.settings.static.field.empty.no.admin": "No hay valor establecido.",
"account.settings.coaching.consent.welcome.header": "Lets get started.",
"account.settings.coaching.consent.welcome.subheader": "We're here for you from start to finish",
"account.settings.coaching.consent.description": "MicroBachelors programs include coaching that focuses on your career, education, and how you'll achieve results through one-on-one communication with an experienced professional. If youre interested, provide the information below and click “Submit,” and our coaching partner will connect with you via email and/or text message to help you move forward. Terms and conditions apply.*",
"account.settings.coaching.consent.text-messaging.disclaimer": "* Coaching services are included at no additional cost to learners with US phone numbers. Coaching includes recurring text messages. Message and data rates may apply. Text STOP to opt-out.",
"account.settings.coaching.consent.accept-coaching": "Sign up for coaching",
"account.settings.coaching.consent.decline-coaching": "I prefer not to be contacted with free coaching services",
"account.settings.coaching.consent.label.name": "Please confirm your name",
"account.settings.coaching.consent.label.phone-number": "Enter your mobile number",
"account.settings.coaching.consent.success.header": "Success!",
"account.settings.coaching.consent.success.message": "You're signed up for coaching. You will receive a text message confirmation.",
"account.settings.coaching.consent.success.continue": "Start my course",
"account.settings.coaching.managed.support": "soporte",
"account.settings.coaching.managed.alert": "Your name is managed by {managerTitle}. Contact your administrator for help.",
"account.settings.field.phone_number": "Teléfono",
"account.settings.field.phone_number.empty": "Add a phone number",
"account.settings.field.coaching_consent": "Coaching consent",
"account.settings.field.coaching_consent.tooltip": "MicroBachelors programs include text message based coaching that helps you pair educational experiences with your career goals through one-on-one advice. Coaching services are included at no additional cost, and are available to learners with U.S. mobile phone numbers. Standard messaging rates apply. Text STOP at anytime to opt-out of messages.",
"account.settings.field.coaching_consent.error": "A valid US phone number is required to opt into coaching",
"account.settings.delete.account.before.proceeding": "Antes de continuar, por favor {actionLink}.",
"account.settings.delete.account.header": "Eliminar mi cuenta",
"account.settings.delete.account.subheader": "¡Sentimos que te vayas!",
@@ -86,7 +107,7 @@
"account.settings.delete.account.text.change.instead": "En lugar de eso, ¿quieres cambiar tu correo electrónico, nombre o contraseña?",
"account.settings.delete.account.button": "Eliminar mi cuenta",
"account.settings.delete.account.please.activate": "activar su cuenta",
"account.settings.delete.account.please.unlink": "unlink all social media accounts",
"account.settings.delete.account.please.unlink": "Desvincular todas las cuentas de redes sociales.",
"account.settings.delete.account.modal.header": "¿Está seguro?",
"account.settings.delete.account.modal.text.1": "Has seleccionado “Eliminar mi cuenta”. La eliminación de tu cuenta y datos personales es permanente e irreversible. edX no será capaz de recuperar tu cuenta o los datos que se hayan borrado.",
"account.settings.delete.account.modal.text.2": "Si procedes, no será posible usar esta cuenta para tomar cursos ni en la aplicación móvil de edX, ni en edx.org, ni en cualquier otro sitio hospedado por edX. Esto incluye el acceso a edx.org desde el sistema de tu empleador o universidad, y el acceso a sitios privados ofrecidos por MIT Open Learning, Wharton Executive Education, y Harvard Medical School.",
@@ -104,10 +125,11 @@
"account.settings.editable.field.password.reset.button.confirmation.support.link": "soporte técnico",
"account.settings.editable.field.password.reset.button.confirmation": "Hemos mandado un mensaje a {email}. Haz clic en el enlace en el mensaje para restablecer tu contraseña. ¿No recibiste el mensaje? Contáctate con {technicalSupportLink}.",
"account.settings.editable.field.password.reset.button": "Restablecer contraseña",
"account.settings.editable.field.password.reset.button.forbidden": "Your previous request is in progress, please try again in few moments.",
"account.settings.editable.field.password.reset.label": "Contraseña",
"account.settings.sso.link.account": "Iniciar sesión con {name}",
"account.settings.sso.account.connected": "Vinculado",
"account.settings.sso.account.disconnect.error": "There was a problem disconnecting this account. Contact support if the problem persists.",
"account.settings.sso.account.disconnect.error": "Hubo un problema al desconectar esta Cuenta. Si el problema persiste, contacte soporte.",
"account.settings.sso.unlink.account": "Desvincular la cuenta de {accountName} ",
"account.settings.sso.no.providers": "No accounts can be linked at this time."
"account.settings.sso.no.providers": "No se pueden vincular cuentas en este momento."
}

View File

@@ -33,6 +33,9 @@
"account.settings.field.country": "Country",
"account.settings.field.country.empty": "Add country",
"account.settings.field.country.options.empty": "Select a Country",
"account.settings.field.state": "State",
"account.settings.field.state.empty": "Add state",
"account.settings.field.state.options.empty": "Select a State",
"account.settings.field.site.language": "Site language",
"account.settings.field.site.language.help.text": "The language used throughout this site. This site is currently available in a limited number of languages.",
"account.settings.field.education": "Education",
@@ -76,6 +79,24 @@
"account.settings.editable.field.action.edit": "Edit",
"account.settings.static.field.empty": "No value set. Contact your {enterprise} administrator to make changes.",
"account.settings.static.field.empty.no.admin": "No value set.",
"account.settings.coaching.consent.welcome.header": "Lets get started.",
"account.settings.coaching.consent.welcome.subheader": "We're here for you from start to finish",
"account.settings.coaching.consent.description": "MicroBachelors programs include coaching that focuses on your career, education, and how you'll achieve results through one-on-one communication with an experienced professional. If youre interested, provide the information below and click “Submit,” and our coaching partner will connect with you via email and/or text message to help you move forward. Terms and conditions apply.*",
"account.settings.coaching.consent.text-messaging.disclaimer": "* Coaching services are included at no additional cost to learners with US phone numbers. Coaching includes recurring text messages. Message and data rates may apply. Text STOP to opt-out.",
"account.settings.coaching.consent.accept-coaching": "Sign up for coaching",
"account.settings.coaching.consent.decline-coaching": "I prefer not to be contacted with free coaching services",
"account.settings.coaching.consent.label.name": "Please confirm your name",
"account.settings.coaching.consent.label.phone-number": "Enter your mobile number",
"account.settings.coaching.consent.success.header": "Success!",
"account.settings.coaching.consent.success.message": "You're signed up for coaching. You will receive a text message confirmation.",
"account.settings.coaching.consent.success.continue": "Start my course",
"account.settings.coaching.managed.support": "support",
"account.settings.coaching.managed.alert": "Your name is managed by {managerTitle}. Contact your administrator for help.",
"account.settings.field.phone_number": "Phone Number",
"account.settings.field.phone_number.empty": "Add a phone number",
"account.settings.field.coaching_consent": "Coaching consent",
"account.settings.field.coaching_consent.tooltip": "MicroBachelors programs include text message based coaching that helps you pair educational experiences with your career goals through one-on-one advice. Coaching services are included at no additional cost, and are available to learners with U.S. mobile phone numbers. Standard messaging rates apply. Text STOP at anytime to opt-out of messages.",
"account.settings.field.coaching_consent.error": "A valid US phone number is required to opt into coaching",
"account.settings.delete.account.before.proceeding": "Before proceeding, please {actionLink}.",
"account.settings.delete.account.header": "Delete My Account",
"account.settings.delete.account.subheader": "We're sorry to see you go!",
@@ -104,6 +125,7 @@
"account.settings.editable.field.password.reset.button.confirmation.support.link": "technical support",
"account.settings.editable.field.password.reset.button.confirmation": "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.password.reset.button": "Reset Password",
"account.settings.editable.field.password.reset.button.forbidden": "Your previous request is in progress, please try again in few moments.",
"account.settings.editable.field.password.reset.label": "Password",
"account.settings.sso.link.account": "Sign in with {name}",
"account.settings.sso.account.connected": "Linked",

View File

@@ -33,6 +33,9 @@
"account.settings.field.country": "Country",
"account.settings.field.country.empty": "Add country",
"account.settings.field.country.options.empty": "Select a Country",
"account.settings.field.state": "State",
"account.settings.field.state.empty": "Add state",
"account.settings.field.state.options.empty": "Select a State",
"account.settings.field.site.language": "Site language",
"account.settings.field.site.language.help.text": "The language used throughout this site. This site is currently available in a limited number of languages.",
"account.settings.field.education": "Education",
@@ -76,6 +79,24 @@
"account.settings.editable.field.action.edit": "Edit",
"account.settings.static.field.empty": "No value set. Contact your {enterprise} administrator to make changes.",
"account.settings.static.field.empty.no.admin": "No value set.",
"account.settings.coaching.consent.welcome.header": "Lets get started.",
"account.settings.coaching.consent.welcome.subheader": "We're here for you from start to finish",
"account.settings.coaching.consent.description": "MicroBachelors programs include coaching that focuses on your career, education, and how you'll achieve results through one-on-one communication with an experienced professional. If youre interested, provide the information below and click “Submit,” and our coaching partner will connect with you via email and/or text message to help you move forward. Terms and conditions apply.*",
"account.settings.coaching.consent.text-messaging.disclaimer": "* Coaching services are included at no additional cost to learners with US phone numbers. Coaching includes recurring text messages. Message and data rates may apply. Text STOP to opt-out.",
"account.settings.coaching.consent.accept-coaching": "Sign up for coaching",
"account.settings.coaching.consent.decline-coaching": "I prefer not to be contacted with free coaching services",
"account.settings.coaching.consent.label.name": "Please confirm your name",
"account.settings.coaching.consent.label.phone-number": "Enter your mobile number",
"account.settings.coaching.consent.success.header": "Success!",
"account.settings.coaching.consent.success.message": "You're signed up for coaching. You will receive a text message confirmation.",
"account.settings.coaching.consent.success.continue": "Start my course",
"account.settings.coaching.managed.support": "support",
"account.settings.coaching.managed.alert": "Your name is managed by {managerTitle}. Contact your administrator for help.",
"account.settings.field.phone_number": "Phone Number",
"account.settings.field.phone_number.empty": "Add a phone number",
"account.settings.field.coaching_consent": "Coaching consent",
"account.settings.field.coaching_consent.tooltip": "MicroBachelors programs include text message based coaching that helps you pair educational experiences with your career goals through one-on-one advice. Coaching services are included at no additional cost, and are available to learners with U.S. mobile phone numbers. Standard messaging rates apply. Text STOP at anytime to opt-out of messages.",
"account.settings.field.coaching_consent.error": "A valid US phone number is required to opt into coaching",
"account.settings.delete.account.before.proceeding": "Before proceeding, please {actionLink}.",
"account.settings.delete.account.header": "Delete My Account",
"account.settings.delete.account.subheader": "We're sorry to see you go!",
@@ -104,6 +125,7 @@
"account.settings.editable.field.password.reset.button.confirmation.support.link": "technical support",
"account.settings.editable.field.password.reset.button.confirmation": "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.password.reset.button": "Reset Password",
"account.settings.editable.field.password.reset.button.forbidden": "Your previous request is in progress, please try again in few moments.",
"account.settings.editable.field.password.reset.label": "Password",
"account.settings.sso.link.account": "Sign in with {name}",
"account.settings.sso.account.connected": "Linked",

View File

@@ -1,7 +1,7 @@
import 'babel-polyfill';
import 'formdata-polyfill';
import { AppProvider, ErrorPage, AuthenticatedPageRoute } from '@edx/frontend-platform/react';
import { subscribe, initialize, APP_INIT_ERROR, APP_READY, mergeConfig, getConfig } from '@edx/frontend-platform';
import { AppProvider, ErrorPage } from '@edx/frontend-platform/react';
import { subscribe, initialize, APP_INIT_ERROR, APP_READY, mergeConfig } from '@edx/frontend-platform';
import React from 'react';
import ReactDOM from 'react-dom';
import { Route, Switch } from 'react-router-dom';
@@ -11,59 +11,33 @@ import Footer, { messages as footerMessages } from '@edx/frontend-component-foot
import configureStore from './data/configureStore';
import AccountSettingsPage, { NotFoundPage } from './account-settings';
import LoginPage from './registration/LoginPage';
import RegistrationPage from './registration/RegistrationPage';
import CoachingConsent from './account-settings/coaching/CoachingConsent';
import appMessages from './i18n';
import './index.scss';
import './assets/favicon.ico';
import logo from './assets/headerlogo.svg';
const HeaderFooterLayout = ({ children }) => (
<div>
<Header />
<main>
{children}
</main>
<Footer />
</div>
);
subscribe(APP_READY, () => {
ReactDOM.render(
<AppProvider store={configureStore()}>
<Switch>
<AuthenticatedPageRoute exact path="/">
<Header />
<main>
<AccountSettingsPage />
</main>
</AuthenticatedPageRoute>
<Route path="/notfound">
<Header />
<main>
<NotFoundPage />
</main>
</Route>
{
getConfig().ENABLE_LOGIN_AND_REGISTRATION &&
<>
<Route path="/login" >
<div className="registration-header">
<img src={logo} alt="edX" className="logo" />
</div>
<main>
<LoginPage />
</main>
</Route>
<Route path="/registration">
<div className="registration-header">
<img src={logo} alt="edX" className="logo" />
</div>
<main>
<RegistrationPage />
</main>
</Route>
</>
}
<Route path="*">
<Header />
<main>
<NotFoundPage />
</main>
</Route>
<Route path="/coaching_consent" component={CoachingConsent} />
<HeaderFooterLayout>
<Route exact path="/" component={AccountSettingsPage} />
<Route path="/notfound" component={NotFoundPage} />
<Route path="*" component={NotFoundPage} />
</HeaderFooterLayout>
</Switch>
<Footer />
</AppProvider>,
document.getElementById('root'),
);
@@ -79,13 +53,13 @@ initialize({
headerMessages,
footerMessages,
],
requireAuthenticatedUser: false,
requireAuthenticatedUser: true,
hydrateAuthenticatedUser: true,
handlers: {
config: () => {
mergeConfig({
SUPPORT_URL: process.env.SUPPORT_URL,
ENABLE_LOGIN_AND_REGISTRATION: process.env.ENABLE_LOGIN_AND_REGISTRATION,
COACHING_ENABLED: (process.env.COACHING_ENABLED || false),
}, 'App loadConfig override handler');
},
},

View File

@@ -8,7 +8,6 @@ $fa-font-path: "~font-awesome/fonts";
@import "~@edx/frontend-component-footer/dist/footer";
@import "./account-settings/style";
@import "./registration/style";
.word-break-all {
word-break: break-all !important;
@@ -36,3 +35,18 @@ $fa-font-path: "~font-awesome/fonts";
flex-grow: 1;
}
}
.coaching-header {
.logo {
display: block;
box-sizing: content-box;
height: 1.75rem;
padding: .75rem 0;
}
}
.coaching-consent {
.disclaimer {
font-size: 0.75rem;
}
}

View File

@@ -12,4 +12,4 @@
<path d="M710.619925,85.5 L664.019925,85.5 L664.019925,0.02 L576.019925,0.02 L576.019925,85.5 L632.859925,85.5 L632.859925,159.2 C598.417874,134.487772 557.04992,121.286425 514.659925,121.48 C456.044663,121.405246 400.107354,146.01621 360.559925,189.28 C381.937732,213.470272 397.422343,242.283149 405.799925,273.46 C426.944121,233.500977 468.451514,208.51034 513.659925,208.52 C581.059925,208.52 632.879925,263.16 632.879925,330.52 L632.879925,331.2 C632.539925,398.28 580.879925,452.56 513.659925,452.56 C468.477451,452.593197 426.976426,427.652566 405.799925,387.74 L405.799925,387.74 C401.869213,380.340239 398.718926,372.551658 396.399925,364.5 L308.399925,364.5 C309.686934,372.450225 311.443338,380.317312 313.659925,388.06 C315.970162,396.190434 318.775397,404.171995 322.059925,411.96 L397.659925,411.96 C388.805702,433.929527 376.258024,454.222269 360.559925,471.96 C400.107354,515.22379 456.044663,539.834754 514.659925,539.76 C571.465111,540.091874 625.745998,516.316729 664.019925,474.34 L664.019925,452.04 L705.059925,452.04 L718.899925,434.96 L718.899925,95.74 L710.619925,85.5 Z M632.879925,501.9 L632.879925,539.74 L664.019925,539.74 L664.019925,474.18 C654.623775,484.469293 644.18821,493.758755 632.879925,501.9 L632.879925,501.9 Z M313.599925,273.14 C311.569597,280.231983 309.927163,287.429316 308.679925,294.7 L322.179925,294.7 C320.007771,287.25831 317.134915,280.039338 313.599925,273.14 L313.599925,273.14 Z" id="Shape" fill="#8A8C8F" fill-rule="nonzero"></path>
<path d="M410.399925,294.7 C409.199925,287.5 407.659925,280.4 405.799925,273.46 C402.19356,280.242036 399.246706,287.354671 396.999925,294.7 C390.06397,317.386469 389.855554,341.597488 396.399925,364.4 L410.719925,364.4 C414.264276,341.293291 414.156293,317.77319 410.399925,294.7 L410.399925,294.7 Z M209.059925,121.48 C107.422724,121.487508 20.5081632,194.571683 3.05992537,294.7 L91.3999254,294.7 C107.135726,243.467257 154.465065,208.503753 208.059925,208.52 C252.638644,208.335148 293.496156,233.351373 313.599925,273.14 C322.501981,241.908137 338.58337,213.190393 360.559925,189.28 C322.206855,145.880863 266.976617,121.163964 209.059925,121.48 L209.059925,121.48 Z M297.479925,411.86 C275.077969,437.877726 242.392659,452.761934 208.059925,452.58 C153.691226,452.598435 105.87164,416.63791 90.7999254,364.4 L308.339925,364.4 C304.575868,341.309464 304.690779,317.752715 308.679925,294.7 L3.05992537,294.7 C-0.902504563,317.755068 -1.01739385,341.307372 2.71992537,364.4 L2.71992537,364.4 C19.3292424,465.441984 106.661918,539.594765 209.059925,539.6 C266.986094,539.900862 322.217868,515.161403 360.559925,471.74 C344.364089,454.096816 331.320914,433.801419 321.999925,411.74 L297.479925,411.86 Z" id="Shape" fill="#B72768" fill-rule="nonzero"></path>
</g>
</svg>
</svg>

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@@ -1,115 +0,0 @@
import React from 'react';
import { Button, Input, ValidationFormGroup } from '@edx/paragon';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faFacebookF, faGoogle, faMicrosoft } from '@fortawesome/free-brands-svg-icons';
export default class LoginPage extends React.Component {
state = {
password: '',
email: '',
errors: {
email: '',
password: '',
},
emailValid: false,
passwordValid: false,
formValid: false,
}
handleOnChange(e) {
this.setState({
[e.target.name]: e.target.value,
});
this.validateInput(e.target.name, e.target.value);
}
validateInput(inputName, value) {
let inputErrors = this.state.errors;
let emailValid = this.state.emailValid;
let passwordValid = this.state.passwordValid;
switch (inputName) {
case 'email':
emailValid = value.match(/^([\w.%+-]+)@([\w-]+\.)+([\w]{2,})$/i);
inputErrors.email = emailValid ? '' : null;
break;
case 'password':
passwordValid = value.length >= 8 && value.match(/\d+/g);
inputErrors.password = passwordValid ? '' : null;
break;
default:
break;
}
this.setState({
errors: inputErrors,
emailValid,
passwordValid,
}, this.validateForm);
}
validateForm() {
this.setState({
formValid: this.state.emailValid && this.state.passwordValid,
});
}
render() {
return (
<React.Fragment>
<div className="d-flex justify-content-center registration-container">
<div className="d-flex flex-column" style={{ width: '400px' }}>
<div className="d-flex flex-row">
<p>We <span>&#x2764;&#xFE0F;</span>our learners.</p>
<p> First time here?</p>
<a className="ml-2" href="/registration">Join our community!</a>
</div>
<form className="m-0">
<div className="form-group">
<h3 className="text-center mt-3">Sign In</h3>
<div className="d-flex flex-column align-items-start">
<ValidationFormGroup
for="email"
invalid={this.state.errors.email !== ''}
invalidMessage="The email address you've provided isn't formatted correctly."
>
<label htmlFor="loginEmail" className="h6 mr-1">Email</label>
<Input
name="email"
id="loginEmail"
type="email"
placeholder="email@domain.com"
value={this.state.email}
onChange={e => this.handleOnChange(e)}
style={{ width: '400px' }}
/>
</ValidationFormGroup>
</div>
<p className="mb-4">The email address you used to register with edX.</p>
<div className="d-flex flex-column align-items-start">
<label htmlFor="loginPassword" className="h6 mr-1">Password</label>
<Input
name="password"
id="loginPassword"
type="password"
value={this.state.password}
onChange={e => this.handleOnChange(e)}
/>
</div>
</div>
<Button className="btn-primary submit">Sign in</Button>
</form>
<div className="section-heading-line mb-4">
<h4>or sign in with</h4>
</div>
<div className="row text-center d-block mb-4">
<button className="btn-social facebook"><FontAwesomeIcon className="mr-2" icon={faFacebookF} />Facebook</button>
<button className="btn-social google"><FontAwesomeIcon className="mr-2" icon={faGoogle} />Google</button>
<button className="btn-social microsoft"><FontAwesomeIcon className="mr-2" icon={faMicrosoft} />Microsoft</button>
</div>
</div>
</div>
</React.Fragment>
);
}
}

View File

@@ -1,256 +0,0 @@
import React from 'react';
import { connect } from 'react-redux';
import { Button, Input, ValidationFormGroup, StatusAlert } from '@edx/paragon';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faFacebookF, faGoogle, faMicrosoft } from '@fortawesome/free-brands-svg-icons';
import { faGraduationCap } from '@fortawesome/free-solid-svg-icons';
import countryList from './countryList';
import { registerNewUser } from './data/actions';
export class RegistrationPage extends React.Component {
state = {
email: '',
name: '',
username: '',
password: '',
country: '',
errors: {
email: '',
name: '',
username: '',
password: '',
country: '',
},
emailValid: false,
nameValid: false,
usernameValid: false,
passwordValid: false,
countryValid: false,
formValid: false,
open: false,
}
handleSelectCountry = (e) => {
this.setState({
country: e.target.value,
});
}
handleSubmit = (e) => {
console.log('clicked submit', e);
e.preventDefault();
this.setState({ open: true });
const payload = {
email: this.state.email,
username: this.state.username,
password: this.state.password,
name: this.state.name,
honor_code: true,
country: this.state.country,
};
this.props.registerNewUser(payload);
}
resetStatusAlertWrapperState() {
this.setState({ open: false });
this.button.focus();
}
handleOnChange(e) {
this.setState({
[e.target.name]: e.target.value,
});
this.validateInput(e.target.name, e.target.value);
}
validateInput(inputName, value) {
let inputErrors = this.state.errors;
let emailValid = this.state.emailValid;
let nameValid = this.state.nameValid;
let usernameValid = this.state.usernameValid;
let passwordValid = this.state.passwordValid;
let countryValid = this.state.countryValid;
switch (inputName) {
case 'email':
emailValid = value.match(/^([\w.%+-]+)@([\w-]+\.)+([\w]{2,})$/i);
inputErrors.email = emailValid ? '' : null;
break;
case 'name':
nameValid = value.length >= 1;
inputErrors.name = nameValid ? '' : null;
break;
case 'username':
usernameValid = value.length >= 2 && value.length <= 30;
inputErrors.username = usernameValid ? '' : null;
break;
case 'password':
passwordValid = value.length >= 8 && value.match(/\d+/g);
inputErrors.password = passwordValid ? '' : null;
break;
case 'country':
countryValid = value !== 'Country or Region of Residence (required)';
inputErrors.country = countryValid ? '' : null;
break;
default:
break;
}
this.setState({
errors: inputErrors,
emailValid,
nameValid,
usernameValid,
passwordValid,
countryValid,
}, this.validateForm);
}
validateForm() {
this.setState({
formValid: this.state.emailValid && this.state.nameValid &&
this.state.usernameValid && this.state.passwordValid && this.state.countryValid,
});
}
renderCountryList() {
const items = [{ value: 'Country or Region of Residence (required)', label: 'Country or Region of Residence (required)' }];
const countries = Object.values(countryList);
for (let i = 0; i < countries.length; i += 1) {
items.push({ value: countries[i], label: countries[i] });
}
return items;
}
render() {
return (
<React.Fragment>
<div className="registration-container d-flex flex-column align-items-center mx-auto" style={{ width: '30rem' }}>
<div className="mb-4">
<FontAwesomeIcon className="d-block mx-auto fa-2x" icon={faGraduationCap} />
<h4 className="d-block mx-auto">Start learning now!</h4>
</div>
<div className="d-block mb-4">
<span className="d-block mx-auto mb-4 section-heading-line">Create an account using</span>
<button className="btn-social facebook"><FontAwesomeIcon className="mr-2" icon={faFacebookF} />Facebook</button>
<button className="btn-social google"><FontAwesomeIcon className="mr-2" icon={faGoogle} />Google</button>
<button className="btn-social microsoft"><FontAwesomeIcon className="mr-2" icon={faMicrosoft} />Microsoft</button>
<span className="d-block mx-auto text-center mt-4 section-heading-line">or create a new one here</span>
</div>
<form className="mb-4 mx-auto form-group">
<ValidationFormGroup
for="email"
invalid={this.state.errors.email !== ''}
invalidMessage="Enter a valid email address that contains at least 3 characters."
>
<label htmlFor="registrationEmail" className="h6 pt-3">Email (required)</label>
<Input
name="email"
id="registrationEmail"
type="email"
placeholder="email@domain.com"
value={this.state.email}
onChange={e => this.handleOnChange(e)}
required
/>
</ValidationFormGroup>
<ValidationFormGroup
for="name"
invalid={this.state.errors.name !== ''}
invalidMessage="Enter your full name."
>
<label htmlFor="registrationName" className="h6 pt-3">Full Name (required)</label>
<Input
name="name"
id="registrationName"
type="text"
placeholder="Name"
value={this.state.name}
onChange={e => this.handleOnChange(e)}
required
/>
</ValidationFormGroup>
<ValidationFormGroup
for="username"
invalid={this.state.errors.username !== ''}
invalidMessage="Username must be between 2 and 30 characters long."
>
<label htmlFor="registrationUsername" className="h6 pt-3">Public Username (required)</label>
<Input
name="username"
id="registrationUsername"
type="text"
placeholder="Username"
value={this.state.username}
onChange={e => this.handleOnChange(e)}
required
/>
</ValidationFormGroup>
<ValidationFormGroup
for="password"
invalid={this.state.errors.password !== ''}
invalidMessage="This password is too short. It must contain at least 8 characters. This password must contain at least 1 number."
>
<label htmlFor="registrationPassword" className="h6 pt-3">Password (required)</label>
<Input
name="password"
id="registrationPassword"
type="password"
placeholder="Password"
value={this.state.password}
onChange={e => this.handleOnChange(e)}
required
/>
</ValidationFormGroup>
<ValidationFormGroup
for="country"
invalid={this.state.errors.country !== ''}
invalidMessage="Select your country or region of residence."
>
<label htmlFor="registrationCountry" className="h6 pt-3">Country (required)</label>
<Input
type="select"
placeholder="Country or Region of Residence"
value={this.state.country}
options={this.renderCountryList()}
onChange={this.handleSelectCountry}
required
/>
</ValidationFormGroup>
<span>By creating an account, you agree to the <a href="https://www.edx.org/edx-terms-service">Terms of Service and Honor Code</a> and you acknowledge that edX and each Member process your personal data in accordance with the <a href="https://www.edx.org/edx-privacy-policy">Privacy Policy</a>.</span>
<Button
className="btn-primary mt-4 submit"
onClick={this.handleSubmit}
inputRef={(input) => {
this.button = input;
}}
>
Create Account
</Button>
<StatusAlert
alertType="danger"
open={this.state.open}
dialog="❤️❤️❤️ Your account was has been created! Welcome to our learning community! ❤️❤️❤️"
onClose={this.resetStatusAlertWrapperState}
/>
</form>
<div className="text-center mb-2 pt-2">
<span>Already have an edX account?</span>
<a href="/login"> Sign in.</a>
</div>
</div>
</React.Fragment>
);
}
}
export default connect(
() => ({}),
{
registerNewUser,
},
)(RegistrationPage);

View File

@@ -1,72 +0,0 @@
.registration-container {
margin: 4rem;
line-height: 1.5;
}
.registration-header {
border-bottom: 1px solid #e7e7e7;
height: 3.75rem;
position: relative;
z-index: 1000;
}
.registration-header img {
height: 1.75rem;
margin-left: 2rem;
padding: 1rem 0;
display: block;
position: relative;
box-sizing: content-box;
}
.btn-social {
padding: 0.5rem 1rem;
color: white;
margin-right: 1rem;
}
.facebook {
border-color: #4267b2;
background-color: #4267b2;
}
.google {
border-color: #4285f4;
background-color: #4285f4;
}
.microsoft {
border-color: #2f2f2f;
background-color: #2f2f2f;
}
.submit {
display: inherit;
margin: 0 auto;
margin-bottom: 2rem;
}
.section-heading-line{
position: relative;
text-align: center;
&:before {
content: '';
position: absolute;
left: 0;
top: 50%;
width: 20%;
background-color: gray;
height: 1px;
}
&:after {
content: '';
position: absolute;
right: 0;
top: 50%;
width: 20%;
background-color: gray;
height: 1px;
}
}

View File

@@ -1,253 +0,0 @@
const countryList = {
AF: 'Afghanistan',
AL: 'Albania',
DZ: 'Algeria',
AS: 'American Samoa',
AD: 'Andorra',
AO: 'Angola',
AI: 'Anguilla',
AQ: 'Antarctica',
AG: 'Antigua and Barbuda',
AR: 'Argentina',
AM: 'Armenia',
AW: 'Aruba',
AU: 'Australia',
AT: 'Austria',
AZ: 'Azerbaijan',
BS: 'Bahamas (the)',
BH: 'Bahrain',
BD: 'Bangladesh',
BB: 'Barbados',
BY: 'Belarus',
BE: 'Belgium',
BZ: 'Belize',
BJ: 'Benin',
BM: 'Bermuda',
BT: 'Bhutan',
BO: 'Bolivia (Plurinational State of)',
BQ: 'Bonaire, Sint Eustatius and Saba',
BA: 'Bosnia and Herzegovina',
BW: 'Botswana',
BV: 'Bouvet Island',
BR: 'Brazil',
IO: 'British Indian Ocean Territory (the)',
BN: 'Brunei Darussalam',
BG: 'Bulgaria',
BF: 'Burkina Faso',
BI: 'Burundi',
CV: 'Cabo Verde',
KH: 'Cambodia',
CM: 'Cameroon',
CA: 'Canada',
KY: 'Cayman Islands (the)',
CF: 'Central African Republic (the)',
TD: 'Chad',
CL: 'Chile',
CN: 'China',
CX: 'Christmas Island',
CC: 'Cocos (Keeling) Islands (the)',
CO: 'Colombia',
KM: 'Comoros (the)',
CD: 'Congo (the Democratic Republic of the)',
CG: 'Congo (the)',
CK: 'Cook Islands (the)',
CR: 'Costa Rica',
HR: 'Croatia',
CU: 'Cuba',
CW: 'Curaçao',
CY: 'Cyprus',
CZ: 'Czechia',
CI: 'Côte d\'Ivoire',
DK: 'Denmark',
DJ: 'Djibouti',
DM: 'Dominica',
DO: 'Dominican Republic (the)',
EC: 'Ecuador',
EG: 'Egypt',
SV: 'El Salvador',
GQ: 'Equatorial Guinea',
ER: 'Eritrea',
EE: 'Estonia',
SZ: 'Eswatini',
ET: 'Ethiopia',
FK: 'Falkland Islands (the) [Malvinas]',
FO: 'Faroe Islands (the)',
FJ: 'Fiji',
FI: 'Finland',
FR: 'France',
GF: 'French Guiana',
PF: 'French Polynesia',
TF: 'French Southern Territories (the)',
GA: 'Gabon',
GM: 'Gambia (the)',
GE: 'Georgia',
DE: 'Germany',
GH: 'Ghana',
GI: 'Gibraltar',
GR: 'Greece',
GL: 'Greenland',
GD: 'Grenada',
GP: 'Guadeloupe',
GU: 'Guam',
GT: 'Guatemala',
GG: 'Guernsey',
GN: 'Guinea',
GW: 'Guinea-Bissau',
GY: 'Guyana',
HT: 'Haiti',
HM: 'Heard Island and McDonald Islands',
VA: 'Holy See (the)',
HN: 'Honduras',
HK: 'Hong Kong',
HU: 'Hungary',
IS: 'Iceland',
IN: 'India',
ID: 'Indonesia',
IR: 'Iran (Islamic Republic of)',
IQ: 'Iraq',
IE: 'Ireland',
IM: 'Isle of Man',
IL: 'Israel',
IT: 'Italy',
JM: 'Jamaica',
JP: 'Japan',
JE: 'Jersey',
JO: 'Jordan',
KZ: 'Kazakhstan',
KE: 'Kenya',
KI: 'Kiribati',
KP: 'Korea (the Democratic People\'s Republic of)',
KR: 'Korea (the Republic of)',
KW: 'Kuwait',
KG: 'Kyrgyzstan',
LA: 'Lao People\'s Democratic Republic (the)',
LV: 'Latvia',
LB: 'Lebanon',
LS: 'Lesotho',
LR: 'Liberia',
LY: 'Libya',
LI: 'Liechtenstein',
LT: 'Lithuania',
LU: 'Luxembourg',
MO: 'Macao',
MG: 'Madagascar',
MW: 'Malawi',
MY: 'Malaysia',
MV: 'Maldives',
ML: 'Mali',
MT: 'Malta',
MH: 'Marshall Islands (the)',
MQ: 'Martinique',
MR: 'Mauritania',
MU: 'Mauritius',
YT: 'Mayotte',
MX: 'Mexico',
FM: 'Micronesia (Federated States of)',
MD: 'Moldova (the Republic of)',
MC: 'Monaco',
MN: 'Mongolia',
ME: 'Montenegro',
MS: 'Montserrat',
MA: 'Morocco',
MZ: 'Mozambique',
MM: 'Myanmar',
NA: 'Namibia',
NR: 'Nauru',
NP: 'Nepal',
NL: 'Netherlands (the)',
NC: 'New Caledonia',
NZ: 'New Zealand',
NI: 'Nicaragua',
NE: 'Niger (the)',
NG: 'Nigeria',
NU: 'Niue',
NF: 'Norfolk Island',
MP: 'Northern Mariana Islands (the)',
NO: 'Norway',
OM: 'Oman',
PK: 'Pakistan',
PW: 'Palau',
PS: 'Palestine, State of',
PA: 'Panama',
PG: 'Papua New Guinea',
PY: 'Paraguay',
PE: 'Peru',
PH: 'Philippines (the)',
PN: 'Pitcairn',
PL: 'Poland',
PT: 'Portugal',
PR: 'Puerto Rico',
QA: 'Qatar',
MK: 'Republic of North Macedonia',
RO: 'Romania',
RU: 'Russian Federation (the)',
RW: 'Rwanda',
RE: 'Réunion',
BL: 'Saint Barthélemy',
SH: 'Saint Helena, Ascension and Tristan da Cunha',
KN: 'Saint Kitts and Nevis',
LC: 'Saint Lucia',
MF: 'Saint Martin (French part)',
PM: 'Saint Pierre and Miquelon',
VC: 'Saint Vincent and the Grenadines',
WS: 'Samoa',
SM: 'San Marino',
ST: 'Sao Tome and Principe',
SA: 'Saudi Arabia',
SN: 'Senegal',
RS: 'Serbia',
SC: 'Seychelles',
SL: 'Sierra Leone',
SG: 'Singapore',
SX: 'Sint Maarten (Dutch part)',
SK: 'Slovakia',
SI: 'Slovenia',
SB: 'Solomon Islands',
SO: 'Somalia',
ZA: 'South Africa',
GS: 'South Georgia and the South Sandwich Islands',
SS: 'South Sudan',
ES: 'Spain',
LK: 'Sri Lanka',
SD: 'Sudan (the)',
SR: 'Suriname',
SJ: 'Svalbard and Jan Mayen',
SE: 'Sweden',
CH: 'Switzerland',
SY: 'Syrian Arab Republic',
TW: 'Taiwan (Province of China)',
TJ: 'Tajikistan',
TZ: 'Tanzania, United Republic of',
TH: 'Thailand',
TL: 'Timor-Leste',
TG: 'Togo',
TK: 'Tokelau',
TO: 'Tonga',
TT: 'Trinidad and Tobago',
TN: 'Tunisia',
TR: 'Turkey',
TM: 'Turkmenistan',
TC: 'Turks and Caicos Islands (the)',
TV: 'Tuvalu',
UG: 'Uganda',
UA: 'Ukraine',
AE: 'United Arab Emirates (the)',
GB: 'United Kingdom of Great Britain and Northern Ireland (the)',
UM: 'United States Minor Outlying Islands (the)',
US: 'United States of America (the)',
UY: 'Uruguay',
UZ: 'Uzbekistan',
VU: 'Vanuatu',
VE: 'Venezuela (Bolivarian Republic of)',
VN: 'Viet Nam',
VG: 'Virgin Islands (British)',
VI: 'Virgin Islands (U.S.)',
WF: 'Wallis and Futuna',
EH: 'Western Sahara',
YE: 'Yemen',
ZM: 'Zambia',
ZW: 'Zimbabwe',
AX: 'Åland Islands',
};
export default countryList;

View File

@@ -1,41 +0,0 @@
import { AsyncActionType } from './utils';
export const REGISTER_NEW_USER = new AsyncActionType('REGISTRATION', 'REGISTER_NEW_USER');
export const LOGIN_REQUEST = new AsyncActionType('LOGIN', 'REQUEST');
// Register
export const registerNewUser = registrationInfo => ({
type: REGISTER_NEW_USER.BASE,
payload: { registrationInfo },
});
export const registerNewUserBegin = () => ({
type: REGISTER_NEW_USER.BEGIN,
});
export const registerNewUserSuccess = () => ({
type: REGISTER_NEW_USER.SUCCESS,
});
export const registerNewUserFailure = () => ({
type: REGISTER_NEW_USER.FAILURE,
});
// Login
export const loginRequest = creds => ({
type: LOGIN_REQUEST.BASE,
payload: { creds },
});
export const loginRequestBegin = () => ({
type: LOGIN_REQUEST.BEGIN,
});
export const loginRequestSuccess = () => ({
type: LOGIN_REQUEST.SUCCESS,
});
export const loginRequestFailure = () => ({
type: LOGIN_REQUEST.FAILURE,
});

View File

@@ -1,41 +0,0 @@
import {
REGISTER_NEW_USER,
LOGIN_REQUEST,
} from './actions';
export const defaultState = {
registrationResult: {},
};
const reducer = (state = defaultState, action) => {
switch (action.type) {
case REGISTER_NEW_USER.BEGIN:
return {
...state,
};
case REGISTER_NEW_USER.SUCCESS:
return {
...state,
};
case REGISTER_NEW_USER.FAILURE:
return {
...state,
};
case LOGIN_REQUEST.BEGIN:
return {
...state,
};
case LOGIN_REQUEST.SUCCESS:
return {
...state,
};
case LOGIN_REQUEST.FAILURE:
return {
...state,
};
default:
return state;
}
};
export default reducer;

View File

@@ -1,48 +0,0 @@
import { call, put, takeEvery } from 'redux-saga/effects';
// Actions
import {
REGISTER_NEW_USER,
registerNewUserBegin,
registerNewUserFailure,
registerNewUserSuccess,
LOGIN_REQUEST,
loginRequestBegin,
loginRequestFailure,
loginRequestSuccess,
} from './actions';
// Services
import { postNewUser, login } from './service';
export function* handleNewUserRegistration(action) {
try {
yield put(registerNewUserBegin());
yield call(postNewUser, action.payload.registrationInfo);
yield put(registerNewUserSuccess());
} catch (e) {
yield put(registerNewUserFailure());
throw e;
}
}
export function* handleLoginRequest(action) {
try {
yield put(loginRequestBegin());
yield call(login, action.payload.creds);
yield put(loginRequestSuccess());
} catch (e) {
yield put(loginRequestFailure());
throw e;
}
}
export default function* saga() {
yield takeEvery(REGISTER_NEW_USER.BASE, handleNewUserRegistration);
yield takeEvery(LOGIN_REQUEST.BASE, handleLoginRequest);
}

View File

@@ -1,41 +0,0 @@
import { getConfig } from '@edx/frontend-platform';
import { getHttpClient } from '@edx/frontend-platform/auth';
import querystring from 'querystring';
export async function postNewUser(registrationInformation) {
const requestConfig = {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
};
const { data } = await getHttpClient()
.post(
`${getConfig().LMS_BASE_URL}/user_api/v1/account/registration/`,
querystring.stringify(registrationInformation),
requestConfig,
)
.catch((e) => {
console.log('You messed up');
throw (e);
});
return data;
}
export async function login(creds) {
const requestConfig = {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
};
const { data } = await getHttpClient()
.post(
`${getConfig().LMS_BASE_URL}/user_api/v1/account/registration/`,
creds,
requestConfig,
)
.catch((e) => {
console.log('You messed up');
throw (e);
});
return data;
}

View File

@@ -1,3 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`getModuleState should throw an exception on a bad path 1`] = `"Unexpected state key uhoh given to getModuleState. Is your state path set up correctly?"`;

View File

@@ -1,38 +0,0 @@
import camelCase from 'lodash.camelcase';
import snakeCase from 'lodash.snakecase';
export function modifyObjectKeys(object, modify) {
// If the passed in object is not an object, return it.
if (
object === undefined ||
object === null ||
(typeof object !== 'object' && !Array.isArray(object))
) {
return object;
}
if (Array.isArray(object)) {
return object.map(value => modifyObjectKeys(value, modify));
}
// Otherwise, process all its keys.
const result = {};
Object.entries(object).forEach(([key, value]) => {
result[modify(key)] = modifyObjectKeys(value, modify);
});
return result;
}
export function camelCaseObject(object) {
return modifyObjectKeys(object, camelCase);
}
export function snakeCaseObject(object) {
return modifyObjectKeys(object, snakeCase);
}
export function convertKeyNames(object, nameMap) {
const transformer = key => (nameMap[key] === undefined ? key : nameMap[key]);
return modifyObjectKeys(object, transformer);
}

View File

@@ -1,90 +0,0 @@
import {
modifyObjectKeys,
camelCaseObject,
snakeCaseObject,
convertKeyNames,
} from './dataUtils';
describe('modifyObjectKeys', () => {
it('should use the provided modify function to change all keys in and object and its children', () => {
function meowKeys(key) {
return `${key}Meow`;
}
const result = modifyObjectKeys(
{
one: undefined,
two: null,
three: '',
four: 0,
five: NaN,
six: [1, 2, { seven: 'woof' }],
eight: { nine: { ten: 'bark' }, eleven: true },
},
meowKeys,
);
expect(result).toEqual({
oneMeow: undefined,
twoMeow: null,
threeMeow: '',
fourMeow: 0,
fiveMeow: NaN,
sixMeow: [1, 2, { sevenMeow: 'woof' }],
eightMeow: { nineMeow: { tenMeow: 'bark' }, elevenMeow: true },
});
});
});
describe('camelCaseObject', () => {
it('should make everything camelCase', () => {
const result = camelCaseObject({
what_now: 'brown cow',
but_who: { says_you_people: 'okay then', but_how: { will_we_even_know: 'the song is over' } },
'dot.dot.dot': 123,
});
expect(result).toEqual({
whatNow: 'brown cow',
butWho: { saysYouPeople: 'okay then', butHow: { willWeEvenKnow: 'the song is over' } },
dotDotDot: 123,
});
});
});
describe('snakeCaseObject', () => {
it('should make everything snake_case', () => {
const result = snakeCaseObject({
whatNow: 'brown cow',
butWho: { saysYouPeople: 'okay then', butHow: { willWeEvenKnow: 'the song is over' } },
'dot.dot.dot': 123,
});
expect(result).toEqual({
what_now: 'brown cow',
but_who: { says_you_people: 'okay then', but_how: { will_we_even_know: 'the song is over' } },
dot_dot_dot: 123,
});
});
});
describe('convertKeyNames', () => {
it('should replace the specified keynames', () => {
const result = convertKeyNames(
{
one: { two: { three: 'four' } },
five: 'six',
},
{
two: 'blue',
five: 'alive',
seven: 'heaven',
},
);
expect(result).toEqual({
one: { blue: { three: 'four' } },
alive: 'six',
});
});
});

View File

@@ -1,12 +0,0 @@
export {
camelCaseObject,
convertKeyNames,
modifyObjectKeys,
snakeCaseObject,
} from './dataUtils';
export {
AsyncActionType,
getModuleState,
} from './reduxUtils';
export { default as handleFailure } from './sagaUtils';
export { unpackFieldErrors, handleRequestError } from './serviceUtils';

View File

@@ -1,62 +0,0 @@
/**
* Helper class to save time when writing out action types for asynchronous methods. Also helps
* ensure that actions are namespaced.
*/
export class AsyncActionType {
constructor(topic, name) {
this.topic = topic;
this.name = name;
}
get BASE() {
return `${this.topic}__${this.name}`;
}
get BEGIN() {
return `${this.topic}__${this.name}__BEGIN`;
}
get SUCCESS() {
return `${this.topic}__${this.name}__SUCCESS`;
}
get FAILURE() {
return `${this.topic}__${this.name}__FAILURE`;
}
get RESET() {
return `${this.topic}__${this.name}__RESET`;
}
}
/**
* Given a state tree and an array representing a set of keys to traverse in that tree, returns
* the portion of the tree at that key path.
*
* Example:
*
* const result = getModuleState(
* {
* first: { red: { awesome: 'sauce' }, blue: { weak: 'sauce' } },
* second: { other: 'data', }
* },
* ['first', 'red']
* );
*
* result will be:
*
* {
* awesome: 'sauce'
* }
*/
export function getModuleState(state, originalPath) {
const path = [...originalPath]; // don't modify your argument
if (path.length < 1) {
return state;
}
const key = path.shift();
if (state[key] === undefined) {
throw new Error(`Unexpected state key ${key} given to getModuleState. Is your state path set up correctly?`);
}
return getModuleState(state[key], path);
}

View File

@@ -1,51 +0,0 @@
import {
AsyncActionType,
getModuleState,
} from './reduxUtils';
describe('AsyncActionType', () => {
it('should return well formatted action strings', () => {
const actionType = new AsyncActionType('HOUSE_CATS', 'START_THE_RACE');
expect(actionType.BASE).toBe('HOUSE_CATS__START_THE_RACE');
expect(actionType.BEGIN).toBe('HOUSE_CATS__START_THE_RACE__BEGIN');
expect(actionType.SUCCESS).toBe('HOUSE_CATS__START_THE_RACE__SUCCESS');
expect(actionType.FAILURE).toBe('HOUSE_CATS__START_THE_RACE__FAILURE');
expect(actionType.RESET).toBe('HOUSE_CATS__START_THE_RACE__RESET');
});
});
describe('getModuleState', () => {
const state = {
first: { red: { awesome: 'sauce' }, blue: { weak: 'sauce' } },
second: { other: 'data' },
};
it('should return everything if given an empty path', () => {
expect(getModuleState(state, [])).toEqual(state);
});
it('should resolve paths correctly', () => {
expect(getModuleState(
state,
['first'],
)).toEqual({ red: { awesome: 'sauce' }, blue: { weak: 'sauce' } });
expect(getModuleState(
state,
['first', 'red'],
)).toEqual({ awesome: 'sauce' });
expect(getModuleState(state, ['second'])).toEqual({ other: 'data' });
});
it('should throw an exception on a bad path', () => {
expect(() => {
getModuleState(state, ['uhoh']);
}).toThrowErrorMatchingSnapshot();
});
it('should return non-objects correctly', () => {
expect(getModuleState(state, ['first', 'red', 'awesome'])).toEqual('sauce');
});
});

View File

@@ -1,16 +0,0 @@
import { put } from 'redux-saga/effects';
import { logError } from '@edx/frontend-platform/logging';
import { history } from '@edx/frontend-platform';
export default function* handleFailure(error, failureAction = null, failureRedirectPath = null) {
if (error.fieldErrors && failureAction !== null) {
yield put(failureAction({ fieldErrors: error.fieldErrors }));
}
logError(error);
if (failureAction !== null) {
yield put(failureAction(error.message));
}
if (failureRedirectPath !== null) {
history.push(failureRedirectPath);
}
}

View File

@@ -1,48 +0,0 @@
/**
* Turns field errors of the form:
*
* {
* "name":{
* "developer_message": "Nerdy message here",
* "user_message": "This value is invalid."
* },
* "other_field": {
* "developer_message": "Other Nerdy message here",
* "user_message": "This other value is invalid."
* }
* }
*
* Into:
*
* {
* "name": "This value is invalid.",
* "other_field": "This other value is invalid"
* }
*/
export function unpackFieldErrors(fieldErrors) {
return Object.entries(fieldErrors).reduce((acc, [k, v]) => {
acc[k] = v.user_message;
return acc;
}, {});
}
/**
* Processes and re-throws request errors. If the response contains a field_errors field, will
* massage the data into a form expected by the client.
*
* Field errors will be packaged as an api error with a fieldErrors field usable by the client.
* Takes an optional unpack function which is used to process the field errors,
* otherwise uses the default unpackFieldErrors function.
*
* @param error The original error object.
* @param unpackFunction (Optional) A function to use to unpack the field errors as a replacement
* for the default.
*/
export function handleRequestError(error, unpackFunction = unpackFieldErrors) {
if (error.response && error.response.data.field_errors) {
const apiError = Object.create(error);
apiError.fieldErrors = unpackFunction(error.response.data.field_errors);
throw apiError;
}
throw error;
}

View File

@@ -1,4 +0,0 @@
export { default as LoginPage } from './LoginPage';
export { RegistrationPage } from './RegistrationPage';
export { default as reducer } from './data/reducers';
export { default as saga } from './data/sagas';