Compare commits
38 Commits
kdmccormic
...
registrati
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25532dee77 | ||
|
|
b56f0e75cc | ||
|
|
c5aec2aa78 | ||
|
|
6542a29c1d | ||
|
|
0c0b14cdfe | ||
|
|
01f17bccf7 | ||
|
|
4db5823570 | ||
|
|
ee72aa5caf | ||
|
|
41f3317fd4 | ||
|
|
071c666add | ||
|
|
cf4ae4a51f | ||
|
|
563757d492 | ||
|
|
8009c0ac7b | ||
|
|
f75e9e05c3 | ||
|
|
752b9a36da | ||
|
|
23837b316b | ||
|
|
b411f22ff7 | ||
|
|
06f320c1be | ||
|
|
147f305cd2 | ||
|
|
d4ce099596 | ||
|
|
6721869a2d | ||
|
|
75ff0f8079 | ||
|
|
d19a332a5f | ||
|
|
2e5e5a1d3d | ||
|
|
9db0a99981 | ||
|
|
563bbc524a | ||
|
|
68428f7f98 | ||
|
|
7883ba5c77 | ||
|
|
b11a560d2f | ||
|
|
00bf8ad342 | ||
|
|
c064f7f413 | ||
|
|
8e9d07971b | ||
|
|
1ff9083755 | ||
|
|
0617585ee5 | ||
|
|
2cfc6dcdb4 | ||
|
|
c21cd8c011 | ||
|
|
9f31f341c1 | ||
|
|
44cefb7c20 |
1
.env
1
.env
@@ -15,3 +15,4 @@ SEGMENT_KEY=null
|
|||||||
SITE_NAME=null
|
SITE_NAME=null
|
||||||
SUPPORT_URL=null
|
SUPPORT_URL=null
|
||||||
USER_INFO_COOKIE_NAME=null
|
USER_INFO_COOKIE_NAME=null
|
||||||
|
ENABLE_LOGIN_AND_REGISTRATION=false
|
||||||
@@ -1,20 +1,19 @@
|
|||||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||||
BASE_URL='localhost:19000/account/'
|
BASE_URL='localhost:1997'
|
||||||
CREDENTIALS_BASE_URL='http://localhost:18150'
|
CREDENTIALS_BASE_URL='http://localhost:18150'
|
||||||
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
|
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
|
||||||
ECOMMERCE_BASE_URL='http://localhost:18130'
|
ECOMMERCE_BASE_URL='http://localhost:18130'
|
||||||
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
|
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
|
||||||
LMS_BASE_URL='http://localhost:18000'
|
LMS_BASE_URL='http://localhost:18000'
|
||||||
LOGIN_URL='http://localhost:18000/login'
|
LOGIN_URL='http://localhost:1997/login'
|
||||||
LOGOUT_URL='http://localhost:18000/login'
|
LOGOUT_URL='http://localhost:18000/login'
|
||||||
MARKETING_SITE_BASE_URL='http://localhost:18000'
|
MARKETING_SITE_BASE_URL='http://localhost:18000'
|
||||||
NODE_ENV='development'
|
NODE_ENV='development'
|
||||||
ORDER_HISTORY_URL='localhost:19000/orders/'
|
ORDER_HISTORY_URL='localhost:1996/orders'
|
||||||
PORT=1997 # For standalone dev server only.
|
PORT=1997
|
||||||
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
||||||
SEGMENT_KEY=null
|
SEGMENT_KEY=null
|
||||||
SITE_NAME='edX'
|
SITE_NAME='edX'
|
||||||
SUPPORT_URL='http://localhost:18000/support'
|
SUPPORT_URL='http://localhost:18000/support'
|
||||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||||
# Temporary, Remove this once we are ready to release the feature.
|
ENABLE_LOGIN_AND_REGISTRATION=true
|
||||||
COACHING_ENABLED=''
|
|
||||||
|
|||||||
@@ -15,4 +15,4 @@ SEGMENT_KEY=null
|
|||||||
SITE_NAME='edX'
|
SITE_NAME='edX'
|
||||||
SUPPORT_URL='http://localhost:18000/support'
|
SUPPORT_URL='http://localhost:18000/support'
|
||||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||||
COACHING_ENABLED=''
|
ENABLE_LOGIN_AND_REGISTRATION=false #hackathon 23 todo
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
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.
|
|
||||||
8957
package-lock.json
generated
8957
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@@ -10,7 +10,6 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "fedx-scripts webpack",
|
"build": "fedx-scripts webpack",
|
||||||
"dev-build": "fedx-scripts webpack-dev",
|
|
||||||
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
|
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
|
||||||
"is-es5": "es-check es5 ./dist/*.js",
|
"is-es5": "es-check es5 ./dist/*.js",
|
||||||
"lint": "fedx-scripts eslint",
|
"lint": "fedx-scripts eslint",
|
||||||
@@ -30,15 +29,15 @@
|
|||||||
"ie 11"
|
"ie 11"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@edx/frontend-component-footer": "10.0.9",
|
"@edx/frontend-component-footer": "10.0.7",
|
||||||
"@edx/frontend-component-header": "2.0.5",
|
"@edx/frontend-component-header": "2.0.5",
|
||||||
"@edx/frontend-platform": "1.1.14",
|
"@edx/frontend-platform": "1.2.0",
|
||||||
"@edx/paragon": "7.1.5",
|
"@edx/paragon": "7.1.5",
|
||||||
"@fortawesome/fontawesome-svg-core": "1.2.28",
|
"@fortawesome/fontawesome-svg-core": "1.2.27",
|
||||||
"@fortawesome/free-brands-svg-icons": "5.8.2",
|
"@fortawesome/free-brands-svg-icons": "5.8.2",
|
||||||
"@fortawesome/free-regular-svg-icons": "5.7.2",
|
"@fortawesome/free-regular-svg-icons": "5.7.2",
|
||||||
"@fortawesome/free-solid-svg-icons": "5.8.2",
|
"@fortawesome/free-solid-svg-icons": "5.8.2",
|
||||||
"@fortawesome/react-fontawesome": "0.1.9",
|
"@fortawesome/react-fontawesome": "0.1.8",
|
||||||
"babel-polyfill": "6.26.0",
|
"babel-polyfill": "6.26.0",
|
||||||
"classnames": "2.2.6",
|
"classnames": "2.2.6",
|
||||||
"font-awesome": "4.7.0",
|
"font-awesome": "4.7.0",
|
||||||
@@ -46,7 +45,6 @@
|
|||||||
"formdata-polyfill": "3.0.19",
|
"formdata-polyfill": "3.0.19",
|
||||||
"history": "4.10.1",
|
"history": "4.10.1",
|
||||||
"lodash.camelcase": "4.3.0",
|
"lodash.camelcase": "4.3.0",
|
||||||
"lodash.debounce": "4.0.8",
|
|
||||||
"lodash.findindex": "4.6.0",
|
"lodash.findindex": "4.6.0",
|
||||||
"lodash.get": "4.4.2",
|
"lodash.get": "4.4.2",
|
||||||
"lodash.isempty": "4.4.0",
|
"lodash.isempty": "4.4.0",
|
||||||
@@ -57,6 +55,7 @@
|
|||||||
"memoize-one": "5.1.1",
|
"memoize-one": "5.1.1",
|
||||||
"newrelic": "5.13.1",
|
"newrelic": "5.13.1",
|
||||||
"prop-types": "15.7.2",
|
"prop-types": "15.7.2",
|
||||||
|
"querystring": "0.2.0",
|
||||||
"react": "16.10.2",
|
"react": "16.10.2",
|
||||||
"react-dom": "16.10.2",
|
"react-dom": "16.10.2",
|
||||||
"react-redux": "7.1.3",
|
"react-redux": "7.1.3",
|
||||||
@@ -74,7 +73,7 @@
|
|||||||
"universal-cookie": "4.0.3"
|
"universal-cookie": "4.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@edx/frontend-build": "github:kdmccormick/frontend-build#kdmccormick/devstack",
|
"@edx/frontend-build": "2.0.6",
|
||||||
"codecov": "3.6.5",
|
"codecov": "3.6.5",
|
||||||
"enzyme": "3.10.0",
|
"enzyme": "3.10.0",
|
||||||
"enzyme-adapter-react-16": "1.15.2",
|
"enzyme-adapter-react-16": "1.15.2",
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ import {
|
|||||||
GENDER_OPTIONS,
|
GENDER_OPTIONS,
|
||||||
} from './data/constants';
|
} from './data/constants';
|
||||||
import { fetchSiteLanguages } from './site-language';
|
import { fetchSiteLanguages } from './site-language';
|
||||||
import CoachingToggle from './coaching/CoachingToggle';
|
|
||||||
|
|
||||||
class AccountSettingsPage extends React.Component {
|
class AccountSettingsPage extends React.Component {
|
||||||
constructor(props, context) {
|
constructor(props, context) {
|
||||||
@@ -328,14 +327,6 @@ class AccountSettingsPage extends React.Component {
|
|||||||
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.language.proficiencies.empty'])}
|
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.language.proficiencies.empty'])}
|
||||||
{...editableFieldProps}
|
{...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>
|
||||||
|
|
||||||
<div className="account-section" id="social-media">
|
<div className="account-section" id="social-media">
|
||||||
@@ -388,7 +379,7 @@ class AccountSettingsPage extends React.Component {
|
|||||||
<EditableField
|
<EditableField
|
||||||
name="time_zone"
|
name="time_zone"
|
||||||
type="select"
|
type="select"
|
||||||
value={this.props.formValues.time_zone}
|
value={this.props.formValues.time_zone || ''}
|
||||||
options={timeZoneOptions}
|
options={timeZoneOptions}
|
||||||
label={this.props.intl.formatMessage(messages['account.settings.field.time.zone'])}
|
label={this.props.intl.formatMessage(messages['account.settings.field.time.zone'])}
|
||||||
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.time.zone.empty'])}
|
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.time.zone.empty'])}
|
||||||
@@ -483,16 +474,10 @@ AccountSettingsPage.propTypes = {
|
|||||||
level_of_education: PropTypes.string,
|
level_of_education: PropTypes.string,
|
||||||
gender: PropTypes.string,
|
gender: PropTypes.string,
|
||||||
language_proficiencies: PropTypes.string,
|
language_proficiencies: PropTypes.string,
|
||||||
phone_number: PropTypes.string,
|
|
||||||
social_link_linkedin: PropTypes.string,
|
social_link_linkedin: PropTypes.string,
|
||||||
social_link_facebook: PropTypes.string,
|
social_link_facebook: PropTypes.string,
|
||||||
social_link_twitter: PropTypes.string,
|
social_link_twitter: PropTypes.string,
|
||||||
time_zone: PropTypes.string,
|
time_zone: PropTypes.string,
|
||||||
coaching: PropTypes.objectOf(PropTypes.shape({
|
|
||||||
coaching_consent: PropTypes.string.isRequired,
|
|
||||||
user: PropTypes.number.isRequired,
|
|
||||||
eligible_for_coaching: PropTypes.bool.isRequired,
|
|
||||||
})),
|
|
||||||
}).isRequired,
|
}).isRequired,
|
||||||
siteLanguage: PropTypes.shape({
|
siteLanguage: PropTypes.shape({
|
||||||
previousValue: PropTypes.string,
|
previousValue: PropTypes.string,
|
||||||
|
|||||||
@@ -35,13 +35,4 @@
|
|||||||
margin-bottom: map-get($spacers, 5);
|
margin-bottom: map-get($spacers, 5);
|
||||||
padding-top: 1rem;
|
padding-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-switch {
|
|
||||||
padding: 0;
|
|
||||||
max-width: 500px;
|
|
||||||
.custom-control-label {
|
|
||||||
left: 2.25rem;
|
|
||||||
line-height: 1.6rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,293 +0,0 @@
|
|||||||
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 } 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.confirmationValues.coaching &&
|
|
||||||
this.props.confirmationValues.name &&
|
|
||||||
this.props.confirmationValues.phone_number
|
|
||||||
) {
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
// 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;
|
|
||||||
|
|
||||||
// These will overwrite each other's redux states (see componentDidUpdate note)
|
|
||||||
this.props.saveSettings('name', fullName);
|
|
||||||
this.props.saveSettings('phone_number', phoneNumber);
|
|
||||||
this.props.saveSettings('coaching', {
|
|
||||||
...coachingValues,
|
|
||||||
phone_number: phoneNumber,
|
|
||||||
coaching_consent: true,
|
|
||||||
consent_form_seen: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async declineCoaching(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
this.setState({
|
|
||||||
formErrors: {},
|
|
||||||
declineSubmitted: true,
|
|
||||||
});
|
|
||||||
// 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}
|
|
||||||
/>);
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default connect(coachingConsentPageSelector, {
|
|
||||||
fetchSettings,
|
|
||||||
saveSettings,
|
|
||||||
})(injectIntl(CoachingConsent));
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
'account.settings.coaching.consent.welcome.header': {
|
|
||||||
id: 'account.settings.coaching.consent.welcome.header',
|
|
||||||
defaultMessage: 'Let’s 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 you’re 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',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default messages;
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
|
||||||
import { Input, Button, Hyperlink } from '@edx/paragon';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
import messages from './CoachingConsent.messages';
|
|
||||||
|
|
||||||
const ErrorMessage = props => (
|
|
||||||
<div className="alert-warning mb-2">{props.message}</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
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">
|
|
||||||
<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"
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
ErrorMessage.defaultProps = {
|
|
||||||
message: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
ErrorMessage.propTypes = {
|
|
||||||
message: PropTypes.string,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default injectIntl(CoachingForm);
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
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 } 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={props.saveSettings}
|
|
||||||
/>
|
|
||||||
<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;
|
|
||||||
const value = {
|
|
||||||
...props.coaching,
|
|
||||||
phone_number: props.phone_number,
|
|
||||||
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: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
CoachingToggle.propTypes = {
|
|
||||||
name: PropTypes.string.isRequired,
|
|
||||||
error: PropTypes.string,
|
|
||||||
coaching: PropTypes.objectOf(PropTypes.shape({
|
|
||||||
coaching_consent: PropTypes.string.isRequired,
|
|
||||||
user: PropTypes.number.isRequired,
|
|
||||||
eligible_for_coaching: PropTypes.bool.isRequired,
|
|
||||||
})).isRequired,
|
|
||||||
saveState: PropTypes.func.isRequired,
|
|
||||||
saveSettings: PropTypes.func.isRequired,
|
|
||||||
updateDraft: PropTypes.func.isRequired,
|
|
||||||
intl: intlShape.isRequired,
|
|
||||||
phone_number: PropTypes.string,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default connect(editableFieldSelector, {
|
|
||||||
saveSettings,
|
|
||||||
updateDraft,
|
|
||||||
})(injectIntl(CoachingToggle));
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
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 in English and Spanish languages. 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;
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 = null;
|
|
||||||
try {
|
|
||||||
({ data } = await getAuthenticatedHttpClient()
|
|
||||||
.get(`${getConfig().LMS_BASE_URL}/api/coaching/v1/users/${userId}/`));
|
|
||||||
} catch (error) {
|
|
||||||
// Default values so the client doesn't fail if the user doesn't have an entry in the
|
|
||||||
// UserCoaching model yet, with the assumption that they'll be eligible for coaching
|
|
||||||
// when they hit this form.
|
|
||||||
data = {
|
|
||||||
coaching_consent: false,
|
|
||||||
user: userId,
|
|
||||||
eligible_for_coaching: true,
|
|
||||||
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);
|
|
||||||
// eslint-disable-next-line prefer-destructuring
|
|
||||||
apiError.fieldErrors.coaching = apiError.fieldErrors.phone_number[0];
|
|
||||||
delete apiError.fieldErrors.phone_number;
|
|
||||||
throw apiError;
|
|
||||||
});
|
|
||||||
return commitValues;
|
|
||||||
}
|
|
||||||
@@ -37,7 +37,7 @@ import { getSettings, patchSettings, getTimeZones } from './service';
|
|||||||
export function* handleFetchSettings() {
|
export function* handleFetchSettings() {
|
||||||
try {
|
try {
|
||||||
yield put(fetchSettingsBegin());
|
yield put(fetchSettingsBegin());
|
||||||
const { username, userId, roles: userRoles } = getAuthenticatedUser();
|
const { username, roles: userRoles } = getAuthenticatedUser();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
thirdPartyAuthProviders, profileDataManager, timeZones, ...values
|
thirdPartyAuthProviders, profileDataManager, timeZones, ...values
|
||||||
@@ -45,7 +45,6 @@ export function* handleFetchSettings() {
|
|||||||
getSettings,
|
getSettings,
|
||||||
username,
|
username,
|
||||||
userRoles,
|
userRoles,
|
||||||
userId,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (values.country) yield put(fetchTimeZones(values.country));
|
if (values.country) yield put(fetchTimeZones(values.country));
|
||||||
@@ -66,7 +65,7 @@ export function* handleSaveSettings(action) {
|
|||||||
try {
|
try {
|
||||||
yield put(saveSettingsBegin());
|
yield put(saveSettingsBegin());
|
||||||
|
|
||||||
const { username, userId } = getAuthenticatedUser();
|
const { username } = getAuthenticatedUser();
|
||||||
const { commitValues, formId } = action.payload;
|
const { commitValues, formId } = action.payload;
|
||||||
const commitData = { [formId]: commitValues };
|
const commitData = { [formId]: commitValues };
|
||||||
let savedValues = null;
|
let savedValues = null;
|
||||||
@@ -84,7 +83,7 @@ export function* handleSaveSettings(action) {
|
|||||||
handleRtl();
|
handleRtl();
|
||||||
savedValues = commitData;
|
savedValues = commitData;
|
||||||
} else {
|
} else {
|
||||||
savedValues = yield call(patchSettings, username, commitData, userId);
|
savedValues = yield call(patchSettings, username, commitData);
|
||||||
}
|
}
|
||||||
yield put(saveSettingsSuccess(savedValues, commitData));
|
yield put(saveSettingsSuccess(savedValues, commitData));
|
||||||
if (savedValues.country) yield put(fetchTimeZones(savedValues.country));
|
if (savedValues.country) yield put(fetchTimeZones(savedValues.country));
|
||||||
|
|||||||
@@ -41,16 +41,6 @@ const isEditingSelector = createSelector(
|
|||||||
(name, accountSettings) => accountSettings.openFormId === name,
|
(name, accountSettings) => accountSettings.openFormId === name,
|
||||||
);
|
);
|
||||||
|
|
||||||
const confirmationValuesSelector = createSelector(
|
|
||||||
accountSettingsSelector,
|
|
||||||
accountSettings => accountSettings.confirmationValues,
|
|
||||||
);
|
|
||||||
|
|
||||||
const errorSelector = createSelector(
|
|
||||||
accountSettingsSelector,
|
|
||||||
accountSettings => accountSettings.errors,
|
|
||||||
);
|
|
||||||
|
|
||||||
const saveStateSelector = createSelector(
|
const saveStateSelector = createSelector(
|
||||||
accountSettingsSelector,
|
accountSettingsSelector,
|
||||||
accountSettings => accountSettings.saveState,
|
accountSettings => accountSettings.saveState,
|
||||||
@@ -169,32 +159,3 @@ export const accountSettingsPageSelector = createSelector(
|
|||||||
tpaProviders: accountSettings.thirdPartyAuth.providers,
|
tpaProviders: accountSettings.thirdPartyAuth.providers,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const coachingConsentPageSelector = createSelector(
|
|
||||||
accountSettingsSelector,
|
|
||||||
formValuesSelector,
|
|
||||||
hiddenFieldsSelector,
|
|
||||||
activeAccountSelector,
|
|
||||||
saveStateSelector,
|
|
||||||
confirmationValuesSelector,
|
|
||||||
errorSelector,
|
|
||||||
(
|
|
||||||
accountSettings,
|
|
||||||
formValues,
|
|
||||||
hiddenFields,
|
|
||||||
activeAccount,
|
|
||||||
saveState,
|
|
||||||
confirmationValues,
|
|
||||||
errors,
|
|
||||||
) => ({
|
|
||||||
loading: accountSettings.loading,
|
|
||||||
loaded: accountSettings.loaded,
|
|
||||||
loadingError: accountSettings.loadingError,
|
|
||||||
isActive: activeAccount,
|
|
||||||
formValues,
|
|
||||||
hiddenFields,
|
|
||||||
saveState,
|
|
||||||
confirmationValues,
|
|
||||||
formErrors: errors,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import isEmpty from 'lodash.isempty';
|
|||||||
|
|
||||||
import { handleRequestError, unpackFieldErrors } from './utils';
|
import { handleRequestError, unpackFieldErrors } from './utils';
|
||||||
import { getThirdPartyAuthProviders } from '../third-party-auth';
|
import { getThirdPartyAuthProviders } from '../third-party-auth';
|
||||||
import { getCoachingPreferences, patchCoachingPreferences } from '../coaching/data/service';
|
|
||||||
|
|
||||||
const SOCIAL_PLATFORMS = [
|
const SOCIAL_PLATFORMS = [
|
||||||
{ id: 'twitter', key: 'social_link_twitter' },
|
{ id: 'twitter', key: 'social_link_twitter' },
|
||||||
@@ -152,16 +151,15 @@ export async function getProfileDataManager(username, userRoles) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* A single function to GET everything considered a setting.
|
* A single function to GET everything considered a setting.
|
||||||
* Currently encapsulates Account, Preferences, Coaching, and ThirdPartyAuth
|
* Currently encapsulates Account, Preferences, and ThirdPartyAuth
|
||||||
*/
|
*/
|
||||||
export async function getSettings(username, userRoles, userId) {
|
export async function getSettings(username, userRoles) {
|
||||||
const results = await Promise.all([
|
const results = await Promise.all([
|
||||||
getAccount(username),
|
getAccount(username),
|
||||||
getPreferences(username),
|
getPreferences(username),
|
||||||
getThirdPartyAuthProviders(),
|
getThirdPartyAuthProviders(),
|
||||||
getProfileDataManager(username, userRoles),
|
getProfileDataManager(username, userRoles),
|
||||||
getTimeZones(),
|
getTimeZones(),
|
||||||
getConfig().COACHING_ENABLED && getCoachingPreferences(userId),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -170,23 +168,20 @@ export async function getSettings(username, userRoles, userId) {
|
|||||||
thirdPartyAuthProviders: results[2],
|
thirdPartyAuthProviders: results[2],
|
||||||
profileDataManager: results[3],
|
profileDataManager: results[3],
|
||||||
timeZones: results[4],
|
timeZones: results[4],
|
||||||
coaching: results[5],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A single function to PATCH everything considered a setting.
|
* A single function to PATCH everything considered a setting.
|
||||||
* Currently encapsulates Account, Preferences, coaching and ThirdPartyAuth
|
* Currently encapsulates Account, Preferences, and ThirdPartyAuth
|
||||||
*/
|
*/
|
||||||
export async function patchSettings(username, commitValues, userId) {
|
export async function patchSettings(username, commitValues) {
|
||||||
// Note: time_zone exists in the return value from user/v1/accounts
|
// 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
|
// 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.
|
// user/v1/preferences where it does update. This is the one we use.
|
||||||
const preferenceKeys = ['time_zone'];
|
const preferenceKeys = ['time_zone'];
|
||||||
const coachingKeys = ['coaching'];
|
|
||||||
const accountCommitValues = omit(commitValues, preferenceKeys);
|
const accountCommitValues = omit(commitValues, preferenceKeys);
|
||||||
const preferenceCommitValues = pick(commitValues, preferenceKeys);
|
const preferenceCommitValues = pick(commitValues, preferenceKeys);
|
||||||
const coachingCommitValues = pick(commitValues, coachingKeys);
|
|
||||||
const patchRequests = [];
|
const patchRequests = [];
|
||||||
|
|
||||||
if (!isEmpty(accountCommitValues)) {
|
if (!isEmpty(accountCommitValues)) {
|
||||||
@@ -195,9 +190,6 @@ export async function patchSettings(username, commitValues, userId) {
|
|||||||
if (!isEmpty(preferenceCommitValues)) {
|
if (!isEmpty(preferenceCommitValues)) {
|
||||||
patchRequests.push(patchPreferences(username, preferenceCommitValues));
|
patchRequests.push(patchPreferences(username, preferenceCommitValues));
|
||||||
}
|
}
|
||||||
if (!isEmpty(coachingCommitValues)) {
|
|
||||||
patchRequests.push(patchCoachingPreferences(userId, coachingCommitValues));
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = await Promise.all(patchRequests);
|
const results = await Promise.all(patchRequests);
|
||||||
// Assigns in order of requests. Preference keys
|
// Assigns in order of requests. Preference keys
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.0 KiB |
@@ -5,7 +5,12 @@ import {
|
|||||||
storeName as accountSettingsStoreName,
|
storeName as accountSettingsStoreName,
|
||||||
} from '../account-settings';
|
} from '../account-settings';
|
||||||
|
|
||||||
|
import {
|
||||||
|
reducer as registrationReducer,
|
||||||
|
} from '../registration';
|
||||||
|
|
||||||
const createRootReducer = () => combineReducers({
|
const createRootReducer = () => combineReducers({
|
||||||
[accountSettingsStoreName]: accountSettingsReducer,
|
[accountSettingsStoreName]: accountSettingsReducer,
|
||||||
|
registration: registrationReducer,
|
||||||
});
|
});
|
||||||
export default createRootReducer;
|
export default createRootReducer;
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { all } from 'redux-saga/effects';
|
import { all } from 'redux-saga/effects';
|
||||||
import { saga as accountSettingsSaga } from '../account-settings';
|
import { saga as accountSettingsSaga } from '../account-settings';
|
||||||
|
import { saga as registrationSaga } from '../registration';
|
||||||
|
|
||||||
export default function* rootSaga() {
|
export default function* rootSaga() {
|
||||||
yield all([accountSettingsSaga()]);
|
yield all([
|
||||||
|
accountSettingsSaga(),
|
||||||
|
registrationSaga(),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,11 +76,6 @@
|
|||||||
"account.settings.editable.field.action.edit": "Edit",
|
"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 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.admin": "No value set.",
|
||||||
"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 in English and Spanish languages. 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.before.proceeding": "Before proceeding, please {actionLink}.",
|
||||||
"account.settings.delete.account.header": "Delete My Account",
|
"account.settings.delete.account.header": "Delete My Account",
|
||||||
"account.settings.delete.account.subheader": "We're sorry to see you go!",
|
"account.settings.delete.account.subheader": "We're sorry to see you go!",
|
||||||
|
|||||||
@@ -76,11 +76,6 @@
|
|||||||
"account.settings.editable.field.action.edit": "Editar",
|
"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 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.admin": "No value set.",
|
||||||
"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 in English and Spanish languages. 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.before.proceeding": "Antes de continuar, por favor {actionLink}.",
|
||||||
"account.settings.delete.account.header": "Eliminar mi cuenta",
|
"account.settings.delete.account.header": "Eliminar mi cuenta",
|
||||||
"account.settings.delete.account.subheader": "¡Sentimos que te vayas!",
|
"account.settings.delete.account.subheader": "¡Sentimos que te vayas!",
|
||||||
|
|||||||
@@ -76,11 +76,6 @@
|
|||||||
"account.settings.editable.field.action.edit": "Edit",
|
"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 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.admin": "No value set.",
|
||||||
"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 in English and Spanish languages. 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.before.proceeding": "Before proceeding, please {actionLink}.",
|
||||||
"account.settings.delete.account.header": "Delete My Account",
|
"account.settings.delete.account.header": "Delete My Account",
|
||||||
"account.settings.delete.account.subheader": "We're sorry to see you go!",
|
"account.settings.delete.account.subheader": "We're sorry to see you go!",
|
||||||
|
|||||||
@@ -76,11 +76,6 @@
|
|||||||
"account.settings.editable.field.action.edit": "Edit",
|
"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 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.admin": "No value set.",
|
||||||
"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 in English and Spanish languages. 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.before.proceeding": "Before proceeding, please {actionLink}.",
|
||||||
"account.settings.delete.account.header": "Delete My Account",
|
"account.settings.delete.account.header": "Delete My Account",
|
||||||
"account.settings.delete.account.subheader": "We're sorry to see you go!",
|
"account.settings.delete.account.subheader": "We're sorry to see you go!",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'babel-polyfill';
|
import 'babel-polyfill';
|
||||||
import 'formdata-polyfill';
|
import 'formdata-polyfill';
|
||||||
import { AppProvider, ErrorPage } from '@edx/frontend-platform/react';
|
import { AppProvider, ErrorPage, AuthenticatedPageRoute } from '@edx/frontend-platform/react';
|
||||||
import { subscribe, initialize, APP_INIT_ERROR, APP_READY, mergeConfig } from '@edx/frontend-platform';
|
import { subscribe, initialize, APP_INIT_ERROR, APP_READY, mergeConfig, getConfig } from '@edx/frontend-platform';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom';
|
import ReactDOM from 'react-dom';
|
||||||
import { Route, Switch } from 'react-router-dom';
|
import { Route, Switch } from 'react-router-dom';
|
||||||
@@ -11,33 +11,59 @@ import Footer, { messages as footerMessages } from '@edx/frontend-component-foot
|
|||||||
|
|
||||||
import configureStore from './data/configureStore';
|
import configureStore from './data/configureStore';
|
||||||
import AccountSettingsPage, { NotFoundPage } from './account-settings';
|
import AccountSettingsPage, { NotFoundPage } from './account-settings';
|
||||||
import CoachingConsent from './account-settings/coaching/CoachingConsent';
|
import LoginPage from './registration/LoginPage';
|
||||||
|
import RegistrationPage from './registration/RegistrationPage';
|
||||||
import appMessages from './i18n';
|
import appMessages from './i18n';
|
||||||
|
|
||||||
import './index.scss';
|
import './index.scss';
|
||||||
import './assets/favicon.ico';
|
import './assets/favicon.ico';
|
||||||
|
import logo from './assets/headerlogo.svg';
|
||||||
const HeaderFooterLayout = ({ children }) => (
|
|
||||||
<div>
|
|
||||||
<Header />
|
|
||||||
<main>
|
|
||||||
{children}
|
|
||||||
</main>
|
|
||||||
<Footer />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
subscribe(APP_READY, () => {
|
subscribe(APP_READY, () => {
|
||||||
ReactDOM.render(
|
ReactDOM.render(
|
||||||
<AppProvider store={configureStore()}>
|
<AppProvider store={configureStore()}>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path="coaching_consent" component={CoachingConsent} />
|
<AuthenticatedPageRoute exact path="/">
|
||||||
<HeaderFooterLayout>
|
<Header />
|
||||||
<Route exact path="" component={AccountSettingsPage} />
|
<main>
|
||||||
<Route path="notfound" component={NotFoundPage} />
|
<AccountSettingsPage />
|
||||||
<Route path="*" component={NotFoundPage} />
|
</main>
|
||||||
</HeaderFooterLayout>
|
</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>
|
||||||
</Switch>
|
</Switch>
|
||||||
|
<Footer />
|
||||||
</AppProvider>,
|
</AppProvider>,
|
||||||
document.getElementById('root'),
|
document.getElementById('root'),
|
||||||
);
|
);
|
||||||
@@ -53,13 +79,13 @@ initialize({
|
|||||||
headerMessages,
|
headerMessages,
|
||||||
footerMessages,
|
footerMessages,
|
||||||
],
|
],
|
||||||
requireAuthenticatedUser: true,
|
requireAuthenticatedUser: false,
|
||||||
hydrateAuthenticatedUser: true,
|
hydrateAuthenticatedUser: true,
|
||||||
handlers: {
|
handlers: {
|
||||||
config: () => {
|
config: () => {
|
||||||
mergeConfig({
|
mergeConfig({
|
||||||
SUPPORT_URL: process.env.SUPPORT_URL,
|
SUPPORT_URL: process.env.SUPPORT_URL,
|
||||||
COACHING_ENABLED: (process.env.COACHING_ENABLED || false),
|
ENABLE_LOGIN_AND_REGISTRATION: process.env.ENABLE_LOGIN_AND_REGISTRATION,
|
||||||
}, 'App loadConfig override handler');
|
}, 'App loadConfig override handler');
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ $fa-font-path: "~font-awesome/fonts";
|
|||||||
@import "~@edx/frontend-component-footer/dist/footer";
|
@import "~@edx/frontend-component-footer/dist/footer";
|
||||||
|
|
||||||
@import "./account-settings/style";
|
@import "./account-settings/style";
|
||||||
|
@import "./registration/style";
|
||||||
|
|
||||||
.word-break-all {
|
.word-break-all {
|
||||||
word-break: break-all !important;
|
word-break: break-all !important;
|
||||||
@@ -35,18 +36,3 @@ $fa-font-path: "~font-awesome/fonts";
|
|||||||
flex-grow: 1;
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
115
src/registration/LoginPage.jsx
Normal file
115
src/registration/LoginPage.jsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
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>❤️</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
256
src/registration/RegistrationPage.jsx
Normal file
256
src/registration/RegistrationPage.jsx
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
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);
|
||||||
72
src/registration/_style.scss
Normal file
72
src/registration/_style.scss
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
253
src/registration/countryList.js
Normal file
253
src/registration/countryList.js
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
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;
|
||||||
41
src/registration/data/actions.js
Normal file
41
src/registration/data/actions.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
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,
|
||||||
|
});
|
||||||
41
src/registration/data/reducers.js
Normal file
41
src/registration/data/reducers.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
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;
|
||||||
48
src/registration/data/sagas.js
Normal file
48
src/registration/data/sagas.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
41
src/registration/data/service.js
Normal file
41
src/registration/data/service.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// 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?"`;
|
||||||
38
src/registration/data/utils/dataUtils.js
Normal file
38
src/registration/data/utils/dataUtils.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
90
src/registration/data/utils/dataUtils.test.js
Normal file
90
src/registration/data/utils/dataUtils.test.js
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
12
src/registration/data/utils/index.js
Normal file
12
src/registration/data/utils/index.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export {
|
||||||
|
camelCaseObject,
|
||||||
|
convertKeyNames,
|
||||||
|
modifyObjectKeys,
|
||||||
|
snakeCaseObject,
|
||||||
|
} from './dataUtils';
|
||||||
|
export {
|
||||||
|
AsyncActionType,
|
||||||
|
getModuleState,
|
||||||
|
} from './reduxUtils';
|
||||||
|
export { default as handleFailure } from './sagaUtils';
|
||||||
|
export { unpackFieldErrors, handleRequestError } from './serviceUtils';
|
||||||
62
src/registration/data/utils/reduxUtils.js
Normal file
62
src/registration/data/utils/reduxUtils.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
51
src/registration/data/utils/reduxUtils.test.js
Normal file
51
src/registration/data/utils/reduxUtils.test.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
16
src/registration/data/utils/sagaUtils.js
Normal file
16
src/registration/data/utils/sagaUtils.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
48
src/registration/data/utils/serviceUtils.js
Normal file
48
src/registration/data/utils/serviceUtils.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
4
src/registration/index.js
Normal file
4
src/registration/index.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { default as LoginPage } from './LoginPage';
|
||||||
|
export { RegistrationPage } from './RegistrationPage';
|
||||||
|
export { default as reducer } from './data/reducers';
|
||||||
|
export { default as saga } from './data/sagas';
|
||||||
Reference in New Issue
Block a user