Compare commits
19 Commits
registrati
...
kdmccormic
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de2a751e36 | ||
|
|
0375ebecda | ||
|
|
f088e2783b | ||
|
|
abc29bbcee | ||
|
|
0b00fa21a2 | ||
|
|
1aa6a22c1c | ||
|
|
d3d0bf97b6 | ||
|
|
7361674019 | ||
|
|
10a3f1fb35 | ||
|
|
06d018fc62 | ||
|
|
3e5bf2b19a | ||
|
|
724a7f9201 | ||
|
|
66b27a01d0 | ||
|
|
9c9725c86c | ||
|
|
bc8d41cd66 | ||
|
|
3a49fb3296 | ||
|
|
c1d1af4943 | ||
|
|
a9d3463619 | ||
|
|
d53c9c11a9 |
1
.env
1
.env
@@ -15,4 +15,3 @@ SEGMENT_KEY=null
|
||||
SITE_NAME=null
|
||||
SUPPORT_URL=null
|
||||
USER_INFO_COOKIE_NAME=null
|
||||
ENABLE_LOGIN_AND_REGISTRATION=false
|
||||
@@ -1,19 +1,20 @@
|
||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||
BASE_URL='localhost:1997'
|
||||
BASE_URL='localhost:19000/account/'
|
||||
CREDENTIALS_BASE_URL='http://localhost:18150'
|
||||
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
|
||||
ECOMMERCE_BASE_URL='http://localhost:18130'
|
||||
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
|
||||
LMS_BASE_URL='http://localhost:18000'
|
||||
LOGIN_URL='http://localhost:1997/login'
|
||||
LOGOUT_URL='http://localhost:18000/login'
|
||||
LOGIN_URL='http://localhost:18000/login'
|
||||
LOGOUT_URL='http://localhost:18000/logout'
|
||||
MARKETING_SITE_BASE_URL='http://localhost:18000'
|
||||
NODE_ENV='development'
|
||||
ORDER_HISTORY_URL='localhost:1996/orders'
|
||||
PORT=1997
|
||||
ORDER_HISTORY_URL='localhost:19000/ecommerce/orders'
|
||||
PORT=1997 # For standalone dev server only.
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
||||
SEGMENT_KEY=null
|
||||
SITE_NAME='edX'
|
||||
SUPPORT_URL='http://localhost:18000/support'
|
||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||
ENABLE_LOGIN_AND_REGISTRATION=true
|
||||
# Temporary, Remove this once we are ready to release the feature.
|
||||
COACHING_ENABLED=''
|
||||
|
||||
@@ -15,4 +15,4 @@ SEGMENT_KEY=null
|
||||
SITE_NAME='edX'
|
||||
SUPPORT_URL='http://localhost:18000/support'
|
||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||
ENABLE_LOGIN_AND_REGISTRATION=false #hackathon 23 todo
|
||||
COACHING_ENABLED=''
|
||||
|
||||
30
docs/decisions/0002-coaching-addition.rst
Normal file
30
docs/decisions/0002-coaching-addition.rst
Normal file
@@ -0,0 +1,30 @@
|
||||
1. Add Coaching Consent
|
||||
--------------------------------
|
||||
|
||||
Status
|
||||
------
|
||||
|
||||
Accepted
|
||||
|
||||
Context
|
||||
-------
|
||||
|
||||
We need to provide users who are eligible for coaching with both an always available
|
||||
coaching toggle and a one-time form they can view to signup for coaching.
|
||||
|
||||
Decision
|
||||
--------
|
||||
|
||||
While the coaching functionality is currently both limited, closed source, and the form
|
||||
exists outside of the standard design of this MFE, it was decided to add it here as a
|
||||
temporary measure due to it being at it's core, an account setting.
|
||||
|
||||
The longer term solutions include either:
|
||||
- using the frontend plugins feature when they become available to inject our coaching
|
||||
work into the account MFE
|
||||
- roll it into it's own MFE if enough additional coaching frontend work is required
|
||||
|
||||
Consequences
|
||||
------------
|
||||
|
||||
Code will exist inside this Open edX MFE that integrates with a closed source app.
|
||||
9175
package-lock.json
generated
9175
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@@ -10,6 +10,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"build": "fedx-scripts webpack",
|
||||
"dev-build": "fedx-scripts webpack-dev",
|
||||
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
|
||||
"is-es5": "es-check es5 ./dist/*.js",
|
||||
"lint": "fedx-scripts eslint",
|
||||
@@ -29,15 +30,15 @@
|
||||
"ie 11"
|
||||
],
|
||||
"dependencies": {
|
||||
"@edx/frontend-component-footer": "10.0.7",
|
||||
"@edx/frontend-component-footer": "10.0.9",
|
||||
"@edx/frontend-component-header": "2.0.5",
|
||||
"@edx/frontend-platform": "1.2.0",
|
||||
"@edx/frontend-platform": "1.1.14",
|
||||
"@edx/paragon": "7.1.5",
|
||||
"@fortawesome/fontawesome-svg-core": "1.2.27",
|
||||
"@fortawesome/fontawesome-svg-core": "1.2.28",
|
||||
"@fortawesome/free-brands-svg-icons": "5.8.2",
|
||||
"@fortawesome/free-regular-svg-icons": "5.7.2",
|
||||
"@fortawesome/free-solid-svg-icons": "5.8.2",
|
||||
"@fortawesome/react-fontawesome": "0.1.8",
|
||||
"@fortawesome/react-fontawesome": "0.1.9",
|
||||
"babel-polyfill": "6.26.0",
|
||||
"classnames": "2.2.6",
|
||||
"font-awesome": "4.7.0",
|
||||
@@ -45,6 +46,7 @@
|
||||
"formdata-polyfill": "3.0.19",
|
||||
"history": "4.10.1",
|
||||
"lodash.camelcase": "4.3.0",
|
||||
"lodash.debounce": "4.0.8",
|
||||
"lodash.findindex": "4.6.0",
|
||||
"lodash.get": "4.4.2",
|
||||
"lodash.isempty": "4.4.0",
|
||||
@@ -55,7 +57,6 @@
|
||||
"memoize-one": "5.1.1",
|
||||
"newrelic": "5.13.1",
|
||||
"prop-types": "15.7.2",
|
||||
"querystring": "0.2.0",
|
||||
"react": "16.10.2",
|
||||
"react-dom": "16.10.2",
|
||||
"react-redux": "7.1.3",
|
||||
@@ -73,7 +74,7 @@
|
||||
"universal-cookie": "4.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edx/frontend-build": "2.0.6",
|
||||
"@edx/frontend-build": "github:edx/frontend-build#kdmccormick/devstack-frontends",
|
||||
"codecov": "3.6.5",
|
||||
"enzyme": "3.10.0",
|
||||
"enzyme-adapter-react-16": "1.15.2",
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
GENDER_OPTIONS,
|
||||
} from './data/constants';
|
||||
import { fetchSiteLanguages } from './site-language';
|
||||
import CoachingToggle from './coaching/CoachingToggle';
|
||||
|
||||
class AccountSettingsPage extends React.Component {
|
||||
constructor(props, context) {
|
||||
@@ -183,7 +184,7 @@ class AccountSettingsPage extends React.Component {
|
||||
}
|
||||
|
||||
renderSecondaryEmailField(editableFieldProps) {
|
||||
if (this.props.hiddenFields.includes('secondary_email')) {
|
||||
if (!Boolean(this.props.formValues.secondary_email_enabled)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -327,6 +328,14 @@ class AccountSettingsPage extends React.Component {
|
||||
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.language.proficiencies.empty'])}
|
||||
{...editableFieldProps}
|
||||
/>
|
||||
{getConfig().COACHING_ENABLED &&
|
||||
this.props.formValues.coaching.eligible_for_coaching &&
|
||||
<CoachingToggle
|
||||
name="coaching"
|
||||
phone_number={this.props.formValues.phone_number}
|
||||
coaching={this.props.formValues.coaching}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="account-section" id="social-media">
|
||||
@@ -379,7 +388,7 @@ class AccountSettingsPage extends React.Component {
|
||||
<EditableField
|
||||
name="time_zone"
|
||||
type="select"
|
||||
value={this.props.formValues.time_zone || ''}
|
||||
value={this.props.formValues.time_zone}
|
||||
options={timeZoneOptions}
|
||||
label={this.props.intl.formatMessage(messages['account.settings.field.time.zone'])}
|
||||
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.time.zone.empty'])}
|
||||
@@ -474,10 +483,16 @@ AccountSettingsPage.propTypes = {
|
||||
level_of_education: PropTypes.string,
|
||||
gender: PropTypes.string,
|
||||
language_proficiencies: PropTypes.string,
|
||||
phone_number: PropTypes.string,
|
||||
social_link_linkedin: PropTypes.string,
|
||||
social_link_facebook: PropTypes.string,
|
||||
social_link_twitter: PropTypes.string,
|
||||
time_zone: PropTypes.string,
|
||||
coaching: PropTypes.objectOf(PropTypes.shape({
|
||||
coaching_consent: PropTypes.string.isRequired,
|
||||
user: PropTypes.number.isRequired,
|
||||
eligible_for_coaching: PropTypes.bool.isRequired,
|
||||
})),
|
||||
}).isRequired,
|
||||
siteLanguage: PropTypes.shape({
|
||||
previousValue: PropTypes.string,
|
||||
@@ -489,8 +504,8 @@ AccountSettingsPage.propTypes = {
|
||||
})),
|
||||
profileDataManager: PropTypes.string,
|
||||
staticFields: PropTypes.arrayOf(PropTypes.string),
|
||||
hiddenFields: PropTypes.arrayOf(PropTypes.string),
|
||||
isActive: PropTypes.bool,
|
||||
secondary_email_enabled: PropTypes.bool,
|
||||
|
||||
timeZoneOptions: PropTypes.arrayOf(PropTypes.shape({
|
||||
label: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
@@ -517,9 +532,9 @@ AccountSettingsPage.defaultProps = {
|
||||
countryTimeZoneOptions: [],
|
||||
profileDataManager: null,
|
||||
staticFields: [],
|
||||
hiddenFields: ['secondary_email'],
|
||||
tpaProviders: [],
|
||||
isActive: true,
|
||||
secondary_email_enabled: false,
|
||||
};
|
||||
|
||||
export default connect(accountSettingsPageSelector, {
|
||||
|
||||
@@ -35,4 +35,13 @@
|
||||
margin-bottom: map-get($spacers, 5);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.custom-switch {
|
||||
padding: 0;
|
||||
max-width: 500px;
|
||||
.custom-control-label {
|
||||
left: 2.25rem;
|
||||
line-height: 1.6rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
293
src/account-settings/coaching/CoachingConsent.jsx
Normal file
293
src/account-settings/coaching/CoachingConsent.jsx
Normal file
@@ -0,0 +1,293 @@
|
||||
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));
|
||||
61
src/account-settings/coaching/CoachingConsent.messages.js
Normal file
61
src/account-settings/coaching/CoachingConsent.messages.js
Normal file
@@ -0,0 +1,61 @@
|
||||
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;
|
||||
104
src/account-settings/coaching/CoachingConsentForm.jsx
Normal file
104
src/account-settings/coaching/CoachingConsentForm.jsx
Normal file
@@ -0,0 +1,104 @@
|
||||
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);
|
||||
76
src/account-settings/coaching/CoachingToggle.jsx
Normal file
76
src/account-settings/coaching/CoachingToggle.jsx
Normal file
@@ -0,0 +1,76 @@
|
||||
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));
|
||||
31
src/account-settings/coaching/CoachingToggle.messages.js
Normal file
31
src/account-settings/coaching/CoachingToggle.messages.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
'account.settings.field.phone_number': {
|
||||
id: 'account.settings.field.phone_number',
|
||||
defaultMessage: 'Phone Number',
|
||||
description: 'The label for a phone numbers setting in the user profile',
|
||||
},
|
||||
'account.settings.field.phone_number.empty': {
|
||||
id: 'account.settings.field.phone_number.empty',
|
||||
defaultMessage: 'Add a phone number',
|
||||
description: 'placeholder for a profiles empty phone number field',
|
||||
},
|
||||
'account.settings.field.coaching_consent': {
|
||||
id: 'account.settings.field.coaching_consent',
|
||||
defaultMessage: 'Coaching consent',
|
||||
description: 'The label for the coaching consent setting in the user profile',
|
||||
},
|
||||
'account.settings.field.coaching_consent.tooltip': {
|
||||
id: 'account.settings.field.coaching_consent.tooltip',
|
||||
defaultMessage: 'MicroBachelors programs include text message based coaching that helps you pair educational experiences with your career goals through one-on-one advice. Coaching services are included at no additional cost, and are available 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;
|
||||
49
src/account-settings/coaching/data/service.js
Normal file
49
src/account-settings/coaching/data/service.js
Normal file
@@ -0,0 +1,49 @@
|
||||
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() {
|
||||
try {
|
||||
yield put(fetchSettingsBegin());
|
||||
const { username, roles: userRoles } = getAuthenticatedUser();
|
||||
const { username, userId, roles: userRoles } = getAuthenticatedUser();
|
||||
|
||||
const {
|
||||
thirdPartyAuthProviders, profileDataManager, timeZones, ...values
|
||||
@@ -45,6 +45,7 @@ export function* handleFetchSettings() {
|
||||
getSettings,
|
||||
username,
|
||||
userRoles,
|
||||
userId,
|
||||
);
|
||||
|
||||
if (values.country) yield put(fetchTimeZones(values.country));
|
||||
@@ -65,7 +66,7 @@ export function* handleSaveSettings(action) {
|
||||
try {
|
||||
yield put(saveSettingsBegin());
|
||||
|
||||
const { username } = getAuthenticatedUser();
|
||||
const { username, userId } = getAuthenticatedUser();
|
||||
const { commitValues, formId } = action.payload;
|
||||
const commitData = { [formId]: commitValues };
|
||||
let savedValues = null;
|
||||
@@ -83,7 +84,7 @@ export function* handleSaveSettings(action) {
|
||||
handleRtl();
|
||||
savedValues = commitData;
|
||||
} else {
|
||||
savedValues = yield call(patchSettings, username, commitData);
|
||||
savedValues = yield call(patchSettings, username, commitData, userId);
|
||||
}
|
||||
yield put(saveSettingsSuccess(savedValues, commitData));
|
||||
if (savedValues.country) yield put(fetchTimeZones(savedValues.country));
|
||||
|
||||
@@ -41,6 +41,16 @@ const isEditingSelector = createSelector(
|
||||
(name, accountSettings) => accountSettings.openFormId === name,
|
||||
);
|
||||
|
||||
const confirmationValuesSelector = createSelector(
|
||||
accountSettingsSelector,
|
||||
accountSettings => accountSettings.confirmationValues,
|
||||
);
|
||||
|
||||
const errorSelector = createSelector(
|
||||
accountSettingsSelector,
|
||||
accountSettings => accountSettings.errors,
|
||||
);
|
||||
|
||||
const saveStateSelector = createSelector(
|
||||
accountSettingsSelector,
|
||||
accountSettings => accountSettings.saveState,
|
||||
@@ -63,10 +73,6 @@ export const staticFieldsSelector = createSelector(
|
||||
accountSettings => (accountSettings.profileDataManager ? ['name', 'email', 'country'] : []),
|
||||
);
|
||||
|
||||
export const hiddenFieldsSelector = createSelector(
|
||||
accountSettingsSelector,
|
||||
accountSettings => (accountSettings.profileDataManager ? [] : ['secondary_email']),
|
||||
);
|
||||
|
||||
/**
|
||||
* If there's no draft present at all (undefined), use the original committed value.
|
||||
@@ -128,7 +134,6 @@ export const accountSettingsPageSelector = createSelector(
|
||||
formValuesSelector,
|
||||
profileDataManagerSelector,
|
||||
staticFieldsSelector,
|
||||
hiddenFieldsSelector,
|
||||
timeZonesSelector,
|
||||
countryTimeZonesSelector,
|
||||
activeAccountSelector,
|
||||
@@ -139,7 +144,6 @@ export const accountSettingsPageSelector = createSelector(
|
||||
formValues,
|
||||
profileDataManager,
|
||||
staticFields,
|
||||
hiddenFields,
|
||||
timeZoneOptions,
|
||||
countryTimeZoneOptions,
|
||||
activeAccount,
|
||||
@@ -155,7 +159,32 @@ export const accountSettingsPageSelector = createSelector(
|
||||
formValues,
|
||||
profileDataManager,
|
||||
staticFields,
|
||||
hiddenFields,
|
||||
tpaProviders: accountSettings.thirdPartyAuth.providers,
|
||||
}),
|
||||
);
|
||||
|
||||
export const coachingConsentPageSelector = createSelector(
|
||||
accountSettingsSelector,
|
||||
formValuesSelector,
|
||||
activeAccountSelector,
|
||||
saveStateSelector,
|
||||
confirmationValuesSelector,
|
||||
errorSelector,
|
||||
(
|
||||
accountSettings,
|
||||
formValues,
|
||||
activeAccount,
|
||||
saveState,
|
||||
confirmationValues,
|
||||
errors,
|
||||
) => ({
|
||||
loading: accountSettings.loading,
|
||||
loaded: accountSettings.loaded,
|
||||
loadingError: accountSettings.loadingError,
|
||||
isActive: activeAccount,
|
||||
formValues,
|
||||
saveState,
|
||||
confirmationValues,
|
||||
formErrors: errors,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import isEmpty from 'lodash.isempty';
|
||||
|
||||
import { handleRequestError, unpackFieldErrors } from './utils';
|
||||
import { getThirdPartyAuthProviders } from '../third-party-auth';
|
||||
import { getCoachingPreferences, patchCoachingPreferences } from '../coaching/data/service';
|
||||
|
||||
const SOCIAL_PLATFORMS = [
|
||||
{ id: 'twitter', key: 'social_link_twitter' },
|
||||
@@ -151,15 +152,16 @@ export async function getProfileDataManager(username, userRoles) {
|
||||
|
||||
/**
|
||||
* A single function to GET everything considered a setting.
|
||||
* Currently encapsulates Account, Preferences, and ThirdPartyAuth
|
||||
* Currently encapsulates Account, Preferences, Coaching, and ThirdPartyAuth
|
||||
*/
|
||||
export async function getSettings(username, userRoles) {
|
||||
export async function getSettings(username, userRoles, userId) {
|
||||
const results = await Promise.all([
|
||||
getAccount(username),
|
||||
getPreferences(username),
|
||||
getThirdPartyAuthProviders(),
|
||||
getProfileDataManager(username, userRoles),
|
||||
getTimeZones(),
|
||||
getConfig().COACHING_ENABLED && getCoachingPreferences(userId),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -168,20 +170,23 @@ export async function getSettings(username, userRoles) {
|
||||
thirdPartyAuthProviders: results[2],
|
||||
profileDataManager: results[3],
|
||||
timeZones: results[4],
|
||||
coaching: results[5],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A single function to PATCH everything considered a setting.
|
||||
* Currently encapsulates Account, Preferences, and ThirdPartyAuth
|
||||
* Currently encapsulates Account, Preferences, coaching and ThirdPartyAuth
|
||||
*/
|
||||
export async function patchSettings(username, commitValues) {
|
||||
export async function patchSettings(username, commitValues, userId) {
|
||||
// Note: time_zone exists in the return value from user/v1/accounts
|
||||
// but it is always null and won't update. It also exists in
|
||||
// user/v1/preferences where it does update. This is the one we use.
|
||||
const preferenceKeys = ['time_zone'];
|
||||
const coachingKeys = ['coaching'];
|
||||
const accountCommitValues = omit(commitValues, preferenceKeys);
|
||||
const preferenceCommitValues = pick(commitValues, preferenceKeys);
|
||||
const coachingCommitValues = pick(commitValues, coachingKeys);
|
||||
const patchRequests = [];
|
||||
|
||||
if (!isEmpty(accountCommitValues)) {
|
||||
@@ -190,6 +195,9 @@ export async function patchSettings(username, commitValues) {
|
||||
if (!isEmpty(preferenceCommitValues)) {
|
||||
patchRequests.push(patchPreferences(username, preferenceCommitValues));
|
||||
}
|
||||
if (!isEmpty(coachingCommitValues)) {
|
||||
patchRequests.push(patchCoachingPreferences(userId, coachingCommitValues));
|
||||
}
|
||||
|
||||
const results = await Promise.all(patchRequests);
|
||||
// Assigns in order of requests. Preference keys
|
||||
|
||||
@@ -5,12 +5,7 @@ import {
|
||||
storeName as accountSettingsStoreName,
|
||||
} from '../account-settings';
|
||||
|
||||
import {
|
||||
reducer as registrationReducer,
|
||||
} from '../registration';
|
||||
|
||||
const createRootReducer = () => combineReducers({
|
||||
[accountSettingsStoreName]: accountSettingsReducer,
|
||||
registration: registrationReducer,
|
||||
});
|
||||
export default createRootReducer;
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { all } from 'redux-saga/effects';
|
||||
import { saga as accountSettingsSaga } from '../account-settings';
|
||||
import { saga as registrationSaga } from '../registration';
|
||||
|
||||
export default function* rootSaga() {
|
||||
yield all([
|
||||
accountSettingsSaga(),
|
||||
registrationSaga(),
|
||||
]);
|
||||
yield all([accountSettingsSaga()]);
|
||||
}
|
||||
|
||||
@@ -76,6 +76,22 @@
|
||||
"account.settings.editable.field.action.edit": "Edit",
|
||||
"account.settings.static.field.empty": "No value set. Contact your {enterprise} administrator to make changes.",
|
||||
"account.settings.static.field.empty.no.admin": "No value set.",
|
||||
"account.settings.coaching.consent.welcome.header": "Let’s get started.",
|
||||
"account.settings.coaching.consent.welcome.subheader": "We're here for you from start to finish",
|
||||
"account.settings.coaching.consent.description": "MicroBachelors programs include coaching that focuses on your career, education, and how you'll achieve results through one-on-one communication with an experienced professional. If 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.*",
|
||||
"account.settings.coaching.consent.text-messaging.disclaimer": "* Coaching services are included at no additional cost to learners with US phone numbers. Coaching includes recurring text messages. Message and data rates may apply. Text STOP to opt-out.",
|
||||
"account.settings.coaching.consent.accept-coaching": "Sign up for coaching",
|
||||
"account.settings.coaching.consent.decline-coaching": "I prefer not to be contacted with free coaching services",
|
||||
"account.settings.coaching.consent.label.name": "Please confirm your name",
|
||||
"account.settings.coaching.consent.label.phone-number": "Enter your mobile number",
|
||||
"account.settings.coaching.consent.success.header": "Success!",
|
||||
"account.settings.coaching.consent.success.message": "You're signed up for coaching. You will receive a text message confirmation.",
|
||||
"account.settings.coaching.consent.success.continue": "Start my course",
|
||||
"account.settings.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.header": "Delete My Account",
|
||||
"account.settings.delete.account.subheader": "We're sorry to see you go!",
|
||||
|
||||
@@ -76,6 +76,22 @@
|
||||
"account.settings.editable.field.action.edit": "Editar",
|
||||
"account.settings.static.field.empty": "No value set. Contact your {enterprise} administrator to make changes.",
|
||||
"account.settings.static.field.empty.no.admin": "No value set.",
|
||||
"account.settings.coaching.consent.welcome.header": "Let’s get started.",
|
||||
"account.settings.coaching.consent.welcome.subheader": "We're here for you from start to finish",
|
||||
"account.settings.coaching.consent.description": "MicroBachelors programs include coaching that focuses on your career, education, and how you'll achieve results through one-on-one communication with an experienced professional. If 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.*",
|
||||
"account.settings.coaching.consent.text-messaging.disclaimer": "* Coaching services are included at no additional cost to learners with US phone numbers. Coaching includes recurring text messages. Message and data rates may apply. Text STOP to opt-out.",
|
||||
"account.settings.coaching.consent.accept-coaching": "Sign up for coaching",
|
||||
"account.settings.coaching.consent.decline-coaching": "I prefer not to be contacted with free coaching services",
|
||||
"account.settings.coaching.consent.label.name": "Please confirm your name",
|
||||
"account.settings.coaching.consent.label.phone-number": "Enter your mobile number",
|
||||
"account.settings.coaching.consent.success.header": "Success!",
|
||||
"account.settings.coaching.consent.success.message": "You're signed up for coaching. You will receive a text message confirmation.",
|
||||
"account.settings.coaching.consent.success.continue": "Start my course",
|
||||
"account.settings.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.header": "Eliminar mi cuenta",
|
||||
"account.settings.delete.account.subheader": "¡Sentimos que te vayas!",
|
||||
|
||||
@@ -76,6 +76,22 @@
|
||||
"account.settings.editable.field.action.edit": "Edit",
|
||||
"account.settings.static.field.empty": "No value set. Contact your {enterprise} administrator to make changes.",
|
||||
"account.settings.static.field.empty.no.admin": "No value set.",
|
||||
"account.settings.coaching.consent.welcome.header": "Let’s get started.",
|
||||
"account.settings.coaching.consent.welcome.subheader": "We're here for you from start to finish",
|
||||
"account.settings.coaching.consent.description": "MicroBachelors programs include coaching that focuses on your career, education, and how you'll achieve results through one-on-one communication with an experienced professional. If 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.*",
|
||||
"account.settings.coaching.consent.text-messaging.disclaimer": "* Coaching services are included at no additional cost to learners with US phone numbers. Coaching includes recurring text messages. Message and data rates may apply. Text STOP to opt-out.",
|
||||
"account.settings.coaching.consent.accept-coaching": "Sign up for coaching",
|
||||
"account.settings.coaching.consent.decline-coaching": "I prefer not to be contacted with free coaching services",
|
||||
"account.settings.coaching.consent.label.name": "Please confirm your name",
|
||||
"account.settings.coaching.consent.label.phone-number": "Enter your mobile number",
|
||||
"account.settings.coaching.consent.success.header": "Success!",
|
||||
"account.settings.coaching.consent.success.message": "You're signed up for coaching. You will receive a text message confirmation.",
|
||||
"account.settings.coaching.consent.success.continue": "Start my course",
|
||||
"account.settings.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.header": "Delete My Account",
|
||||
"account.settings.delete.account.subheader": "We're sorry to see you go!",
|
||||
|
||||
@@ -76,6 +76,22 @@
|
||||
"account.settings.editable.field.action.edit": "Edit",
|
||||
"account.settings.static.field.empty": "No value set. Contact your {enterprise} administrator to make changes.",
|
||||
"account.settings.static.field.empty.no.admin": "No value set.",
|
||||
"account.settings.coaching.consent.welcome.header": "Let’s get started.",
|
||||
"account.settings.coaching.consent.welcome.subheader": "We're here for you from start to finish",
|
||||
"account.settings.coaching.consent.description": "MicroBachelors programs include coaching that focuses on your career, education, and how you'll achieve results through one-on-one communication with an experienced professional. If 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.*",
|
||||
"account.settings.coaching.consent.text-messaging.disclaimer": "* Coaching services are included at no additional cost to learners with US phone numbers. Coaching includes recurring text messages. Message and data rates may apply. Text STOP to opt-out.",
|
||||
"account.settings.coaching.consent.accept-coaching": "Sign up for coaching",
|
||||
"account.settings.coaching.consent.decline-coaching": "I prefer not to be contacted with free coaching services",
|
||||
"account.settings.coaching.consent.label.name": "Please confirm your name",
|
||||
"account.settings.coaching.consent.label.phone-number": "Enter your mobile number",
|
||||
"account.settings.coaching.consent.success.header": "Success!",
|
||||
"account.settings.coaching.consent.success.message": "You're signed up for coaching. You will receive a text message confirmation.",
|
||||
"account.settings.coaching.consent.success.continue": "Start my course",
|
||||
"account.settings.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.header": "Delete My Account",
|
||||
"account.settings.delete.account.subheader": "We're sorry to see you go!",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'babel-polyfill';
|
||||
import 'formdata-polyfill';
|
||||
import { AppProvider, ErrorPage, AuthenticatedPageRoute } from '@edx/frontend-platform/react';
|
||||
import { subscribe, initialize, APP_INIT_ERROR, APP_READY, mergeConfig, getConfig } from '@edx/frontend-platform';
|
||||
import { AppProvider, ErrorPage } from '@edx/frontend-platform/react';
|
||||
import { subscribe, initialize, APP_INIT_ERROR, APP_READY, mergeConfig } from '@edx/frontend-platform';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
@@ -11,59 +11,33 @@ import Footer, { messages as footerMessages } from '@edx/frontend-component-foot
|
||||
|
||||
import configureStore from './data/configureStore';
|
||||
import AccountSettingsPage, { NotFoundPage } from './account-settings';
|
||||
import LoginPage from './registration/LoginPage';
|
||||
import RegistrationPage from './registration/RegistrationPage';
|
||||
import CoachingConsent from './account-settings/coaching/CoachingConsent';
|
||||
import appMessages from './i18n';
|
||||
|
||||
import './index.scss';
|
||||
import './assets/favicon.ico';
|
||||
import logo from './assets/headerlogo.svg';
|
||||
|
||||
const HeaderFooterLayout = ({ children }) => (
|
||||
<div>
|
||||
<Header />
|
||||
<main>
|
||||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
|
||||
subscribe(APP_READY, () => {
|
||||
ReactDOM.render(
|
||||
<AppProvider store={configureStore()}>
|
||||
<Switch>
|
||||
<AuthenticatedPageRoute exact path="/">
|
||||
<Header />
|
||||
<main>
|
||||
<AccountSettingsPage />
|
||||
</main>
|
||||
</AuthenticatedPageRoute>
|
||||
<Route path="/notfound">
|
||||
<Header />
|
||||
<main>
|
||||
<NotFoundPage />
|
||||
</main>
|
||||
</Route>
|
||||
{
|
||||
getConfig().ENABLE_LOGIN_AND_REGISTRATION &&
|
||||
<>
|
||||
<Route path="/login" >
|
||||
<div className="registration-header">
|
||||
<img src={logo} alt="edX" className="logo" />
|
||||
</div>
|
||||
<main>
|
||||
<LoginPage />
|
||||
</main>
|
||||
</Route>
|
||||
<Route path="/registration">
|
||||
<div className="registration-header">
|
||||
<img src={logo} alt="edX" className="logo" />
|
||||
</div>
|
||||
<main>
|
||||
<RegistrationPage />
|
||||
</main>
|
||||
</Route>
|
||||
</>
|
||||
}
|
||||
<Route path="*">
|
||||
<Header />
|
||||
<main>
|
||||
<NotFoundPage />
|
||||
</main>
|
||||
</Route>
|
||||
<Route path="coaching_consent" component={CoachingConsent} />
|
||||
<HeaderFooterLayout>
|
||||
<Route exact path="" component={AccountSettingsPage} />
|
||||
<Route path="notfound" component={NotFoundPage} />
|
||||
<Route path="*" component={NotFoundPage} />
|
||||
</HeaderFooterLayout>
|
||||
</Switch>
|
||||
<Footer />
|
||||
</AppProvider>,
|
||||
document.getElementById('root'),
|
||||
);
|
||||
@@ -79,13 +53,13 @@ initialize({
|
||||
headerMessages,
|
||||
footerMessages,
|
||||
],
|
||||
requireAuthenticatedUser: false,
|
||||
requireAuthenticatedUser: true,
|
||||
hydrateAuthenticatedUser: true,
|
||||
handlers: {
|
||||
config: () => {
|
||||
mergeConfig({
|
||||
SUPPORT_URL: process.env.SUPPORT_URL,
|
||||
ENABLE_LOGIN_AND_REGISTRATION: process.env.ENABLE_LOGIN_AND_REGISTRATION,
|
||||
COACHING_ENABLED: (process.env.COACHING_ENABLED || false),
|
||||
}, 'App loadConfig override handler');
|
||||
},
|
||||
},
|
||||
|
||||
@@ -8,7 +8,6 @@ $fa-font-path: "~font-awesome/fonts";
|
||||
@import "~@edx/frontend-component-footer/dist/footer";
|
||||
|
||||
@import "./account-settings/style";
|
||||
@import "./registration/style";
|
||||
|
||||
.word-break-all {
|
||||
word-break: break-all !important;
|
||||
@@ -36,3 +35,18 @@ $fa-font-path: "~font-awesome/fonts";
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.coaching-header {
|
||||
.logo {
|
||||
display: block;
|
||||
box-sizing: content-box;
|
||||
height: 1.75rem;
|
||||
padding: .75rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
.coaching-consent {
|
||||
.disclaimer {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,4 +12,4 @@
|
||||
<path d="M710.619925,85.5 L664.019925,85.5 L664.019925,0.02 L576.019925,0.02 L576.019925,85.5 L632.859925,85.5 L632.859925,159.2 C598.417874,134.487772 557.04992,121.286425 514.659925,121.48 C456.044663,121.405246 400.107354,146.01621 360.559925,189.28 C381.937732,213.470272 397.422343,242.283149 405.799925,273.46 C426.944121,233.500977 468.451514,208.51034 513.659925,208.52 C581.059925,208.52 632.879925,263.16 632.879925,330.52 L632.879925,331.2 C632.539925,398.28 580.879925,452.56 513.659925,452.56 C468.477451,452.593197 426.976426,427.652566 405.799925,387.74 L405.799925,387.74 C401.869213,380.340239 398.718926,372.551658 396.399925,364.5 L308.399925,364.5 C309.686934,372.450225 311.443338,380.317312 313.659925,388.06 C315.970162,396.190434 318.775397,404.171995 322.059925,411.96 L397.659925,411.96 C388.805702,433.929527 376.258024,454.222269 360.559925,471.96 C400.107354,515.22379 456.044663,539.834754 514.659925,539.76 C571.465111,540.091874 625.745998,516.316729 664.019925,474.34 L664.019925,452.04 L705.059925,452.04 L718.899925,434.96 L718.899925,95.74 L710.619925,85.5 Z M632.879925,501.9 L632.879925,539.74 L664.019925,539.74 L664.019925,474.18 C654.623775,484.469293 644.18821,493.758755 632.879925,501.9 L632.879925,501.9 Z M313.599925,273.14 C311.569597,280.231983 309.927163,287.429316 308.679925,294.7 L322.179925,294.7 C320.007771,287.25831 317.134915,280.039338 313.599925,273.14 L313.599925,273.14 Z" id="Shape" fill="#8A8C8F" fill-rule="nonzero"></path>
|
||||
<path d="M410.399925,294.7 C409.199925,287.5 407.659925,280.4 405.799925,273.46 C402.19356,280.242036 399.246706,287.354671 396.999925,294.7 C390.06397,317.386469 389.855554,341.597488 396.399925,364.4 L410.719925,364.4 C414.264276,341.293291 414.156293,317.77319 410.399925,294.7 L410.399925,294.7 Z M209.059925,121.48 C107.422724,121.487508 20.5081632,194.571683 3.05992537,294.7 L91.3999254,294.7 C107.135726,243.467257 154.465065,208.503753 208.059925,208.52 C252.638644,208.335148 293.496156,233.351373 313.599925,273.14 C322.501981,241.908137 338.58337,213.190393 360.559925,189.28 C322.206855,145.880863 266.976617,121.163964 209.059925,121.48 L209.059925,121.48 Z M297.479925,411.86 C275.077969,437.877726 242.392659,452.761934 208.059925,452.58 C153.691226,452.598435 105.87164,416.63791 90.7999254,364.4 L308.339925,364.4 C304.575868,341.309464 304.690779,317.752715 308.679925,294.7 L3.05992537,294.7 C-0.902504563,317.755068 -1.01739385,341.307372 2.71992537,364.4 L2.71992537,364.4 C19.3292424,465.441984 106.661918,539.594765 209.059925,539.6 C266.986094,539.900862 322.217868,515.161403 360.559925,471.74 C344.364089,454.096816 331.320914,433.801419 321.999925,411.74 L297.479925,411.86 Z" id="Shape" fill="#B72768" fill-rule="nonzero"></path>
|
||||
</g>
|
||||
</svg>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.0 KiB |
@@ -1,115 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Button, Input, ValidationFormGroup } from '@edx/paragon';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faFacebookF, faGoogle, faMicrosoft } from '@fortawesome/free-brands-svg-icons';
|
||||
|
||||
export default class LoginPage extends React.Component {
|
||||
state = {
|
||||
password: '',
|
||||
email: '',
|
||||
errors: {
|
||||
email: '',
|
||||
password: '',
|
||||
},
|
||||
emailValid: false,
|
||||
passwordValid: false,
|
||||
formValid: false,
|
||||
}
|
||||
|
||||
handleOnChange(e) {
|
||||
this.setState({
|
||||
[e.target.name]: e.target.value,
|
||||
});
|
||||
this.validateInput(e.target.name, e.target.value);
|
||||
}
|
||||
|
||||
validateInput(inputName, value) {
|
||||
let inputErrors = this.state.errors;
|
||||
let emailValid = this.state.emailValid;
|
||||
let passwordValid = this.state.passwordValid;
|
||||
|
||||
switch (inputName) {
|
||||
case 'email':
|
||||
emailValid = value.match(/^([\w.%+-]+)@([\w-]+\.)+([\w]{2,})$/i);
|
||||
inputErrors.email = emailValid ? '' : null;
|
||||
break;
|
||||
case 'password':
|
||||
passwordValid = value.length >= 8 && value.match(/\d+/g);
|
||||
inputErrors.password = passwordValid ? '' : null;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
errors: inputErrors,
|
||||
emailValid,
|
||||
passwordValid,
|
||||
}, this.validateForm);
|
||||
}
|
||||
|
||||
validateForm() {
|
||||
this.setState({
|
||||
formValid: this.state.emailValid && this.state.passwordValid,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div className="d-flex justify-content-center registration-container">
|
||||
<div className="d-flex flex-column" style={{ width: '400px' }}>
|
||||
<div className="d-flex flex-row">
|
||||
<p>We <span>❤️</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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,256 +0,0 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Button, Input, ValidationFormGroup, StatusAlert } from '@edx/paragon';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faFacebookF, faGoogle, faMicrosoft } from '@fortawesome/free-brands-svg-icons';
|
||||
import { faGraduationCap } from '@fortawesome/free-solid-svg-icons';
|
||||
import countryList from './countryList';
|
||||
|
||||
import { registerNewUser } from './data/actions';
|
||||
|
||||
export class RegistrationPage extends React.Component {
|
||||
state = {
|
||||
email: '',
|
||||
name: '',
|
||||
username: '',
|
||||
password: '',
|
||||
country: '',
|
||||
errors: {
|
||||
email: '',
|
||||
name: '',
|
||||
username: '',
|
||||
password: '',
|
||||
country: '',
|
||||
},
|
||||
emailValid: false,
|
||||
nameValid: false,
|
||||
usernameValid: false,
|
||||
passwordValid: false,
|
||||
countryValid: false,
|
||||
formValid: false,
|
||||
open: false,
|
||||
}
|
||||
|
||||
handleSelectCountry = (e) => {
|
||||
this.setState({
|
||||
country: e.target.value,
|
||||
});
|
||||
}
|
||||
|
||||
handleSubmit = (e) => {
|
||||
console.log('clicked submit', e);
|
||||
e.preventDefault();
|
||||
this.setState({ open: true });
|
||||
|
||||
const payload = {
|
||||
email: this.state.email,
|
||||
username: this.state.username,
|
||||
password: this.state.password,
|
||||
name: this.state.name,
|
||||
honor_code: true,
|
||||
country: this.state.country,
|
||||
};
|
||||
|
||||
this.props.registerNewUser(payload);
|
||||
}
|
||||
|
||||
resetStatusAlertWrapperState() {
|
||||
this.setState({ open: false });
|
||||
this.button.focus();
|
||||
}
|
||||
|
||||
handleOnChange(e) {
|
||||
this.setState({
|
||||
[e.target.name]: e.target.value,
|
||||
});
|
||||
this.validateInput(e.target.name, e.target.value);
|
||||
}
|
||||
|
||||
validateInput(inputName, value) {
|
||||
let inputErrors = this.state.errors;
|
||||
let emailValid = this.state.emailValid;
|
||||
let nameValid = this.state.nameValid;
|
||||
let usernameValid = this.state.usernameValid;
|
||||
let passwordValid = this.state.passwordValid;
|
||||
let countryValid = this.state.countryValid;
|
||||
|
||||
switch (inputName) {
|
||||
case 'email':
|
||||
emailValid = value.match(/^([\w.%+-]+)@([\w-]+\.)+([\w]{2,})$/i);
|
||||
inputErrors.email = emailValid ? '' : null;
|
||||
break;
|
||||
case 'name':
|
||||
nameValid = value.length >= 1;
|
||||
inputErrors.name = nameValid ? '' : null;
|
||||
break;
|
||||
case 'username':
|
||||
usernameValid = value.length >= 2 && value.length <= 30;
|
||||
inputErrors.username = usernameValid ? '' : null;
|
||||
break;
|
||||
case 'password':
|
||||
passwordValid = value.length >= 8 && value.match(/\d+/g);
|
||||
inputErrors.password = passwordValid ? '' : null;
|
||||
break;
|
||||
case 'country':
|
||||
countryValid = value !== 'Country or Region of Residence (required)';
|
||||
inputErrors.country = countryValid ? '' : null;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
errors: inputErrors,
|
||||
emailValid,
|
||||
nameValid,
|
||||
usernameValid,
|
||||
passwordValid,
|
||||
countryValid,
|
||||
}, this.validateForm);
|
||||
}
|
||||
|
||||
validateForm() {
|
||||
this.setState({
|
||||
formValid: this.state.emailValid && this.state.nameValid &&
|
||||
this.state.usernameValid && this.state.passwordValid && this.state.countryValid,
|
||||
});
|
||||
}
|
||||
|
||||
renderCountryList() {
|
||||
const items = [{ value: 'Country or Region of Residence (required)', label: 'Country or Region of Residence (required)' }];
|
||||
const countries = Object.values(countryList);
|
||||
for (let i = 0; i < countries.length; i += 1) {
|
||||
items.push({ value: countries[i], label: countries[i] });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div className="registration-container d-flex flex-column align-items-center mx-auto" style={{ width: '30rem' }}>
|
||||
<div className="mb-4">
|
||||
<FontAwesomeIcon className="d-block mx-auto fa-2x" icon={faGraduationCap} />
|
||||
<h4 className="d-block mx-auto">Start learning now!</h4>
|
||||
</div>
|
||||
<div className="d-block mb-4">
|
||||
<span className="d-block mx-auto mb-4 section-heading-line">Create an account using</span>
|
||||
<button className="btn-social facebook"><FontAwesomeIcon className="mr-2" icon={faFacebookF} />Facebook</button>
|
||||
<button className="btn-social google"><FontAwesomeIcon className="mr-2" icon={faGoogle} />Google</button>
|
||||
<button className="btn-social microsoft"><FontAwesomeIcon className="mr-2" icon={faMicrosoft} />Microsoft</button>
|
||||
<span className="d-block mx-auto text-center mt-4 section-heading-line">or create a new one here</span>
|
||||
</div>
|
||||
|
||||
<form className="mb-4 mx-auto form-group">
|
||||
<ValidationFormGroup
|
||||
for="email"
|
||||
invalid={this.state.errors.email !== ''}
|
||||
invalidMessage="Enter a valid email address that contains at least 3 characters."
|
||||
>
|
||||
<label htmlFor="registrationEmail" className="h6 pt-3">Email (required)</label>
|
||||
<Input
|
||||
name="email"
|
||||
id="registrationEmail"
|
||||
type="email"
|
||||
placeholder="email@domain.com"
|
||||
value={this.state.email}
|
||||
onChange={e => this.handleOnChange(e)}
|
||||
required
|
||||
/>
|
||||
</ValidationFormGroup>
|
||||
<ValidationFormGroup
|
||||
for="name"
|
||||
invalid={this.state.errors.name !== ''}
|
||||
invalidMessage="Enter your full name."
|
||||
>
|
||||
<label htmlFor="registrationName" className="h6 pt-3">Full Name (required)</label>
|
||||
<Input
|
||||
name="name"
|
||||
id="registrationName"
|
||||
type="text"
|
||||
placeholder="Name"
|
||||
value={this.state.name}
|
||||
onChange={e => this.handleOnChange(e)}
|
||||
required
|
||||
/>
|
||||
</ValidationFormGroup>
|
||||
<ValidationFormGroup
|
||||
for="username"
|
||||
invalid={this.state.errors.username !== ''}
|
||||
invalidMessage="Username must be between 2 and 30 characters long."
|
||||
>
|
||||
<label htmlFor="registrationUsername" className="h6 pt-3">Public Username (required)</label>
|
||||
<Input
|
||||
name="username"
|
||||
id="registrationUsername"
|
||||
type="text"
|
||||
placeholder="Username"
|
||||
value={this.state.username}
|
||||
onChange={e => this.handleOnChange(e)}
|
||||
required
|
||||
/>
|
||||
</ValidationFormGroup>
|
||||
<ValidationFormGroup
|
||||
for="password"
|
||||
invalid={this.state.errors.password !== ''}
|
||||
invalidMessage="This password is too short. It must contain at least 8 characters. This password must contain at least 1 number."
|
||||
>
|
||||
<label htmlFor="registrationPassword" className="h6 pt-3">Password (required)</label>
|
||||
<Input
|
||||
name="password"
|
||||
id="registrationPassword"
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
value={this.state.password}
|
||||
onChange={e => this.handleOnChange(e)}
|
||||
required
|
||||
/>
|
||||
</ValidationFormGroup>
|
||||
<ValidationFormGroup
|
||||
for="country"
|
||||
invalid={this.state.errors.country !== ''}
|
||||
invalidMessage="Select your country or region of residence."
|
||||
>
|
||||
<label htmlFor="registrationCountry" className="h6 pt-3">Country (required)</label>
|
||||
<Input
|
||||
type="select"
|
||||
placeholder="Country or Region of Residence"
|
||||
value={this.state.country}
|
||||
options={this.renderCountryList()}
|
||||
onChange={this.handleSelectCountry}
|
||||
required
|
||||
/>
|
||||
</ValidationFormGroup>
|
||||
<span>By creating an account, you agree to the <a href="https://www.edx.org/edx-terms-service">Terms of Service and Honor Code</a> and you acknowledge that edX and each Member process your personal data in accordance with the <a href="https://www.edx.org/edx-privacy-policy">Privacy Policy</a>.</span>
|
||||
<Button
|
||||
className="btn-primary mt-4 submit"
|
||||
onClick={this.handleSubmit}
|
||||
inputRef={(input) => {
|
||||
this.button = input;
|
||||
}}
|
||||
>
|
||||
Create Account
|
||||
</Button>
|
||||
<StatusAlert
|
||||
alertType="danger"
|
||||
open={this.state.open}
|
||||
dialog="❤️❤️❤️ Your account was has been created! Welcome to our learning community! ❤️❤️❤️"
|
||||
onClose={this.resetStatusAlertWrapperState}
|
||||
/>
|
||||
</form>
|
||||
<div className="text-center mb-2 pt-2">
|
||||
<span>Already have an edX account?</span>
|
||||
<a href="/login"> Sign in.</a>
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
() => ({}),
|
||||
{
|
||||
registerNewUser,
|
||||
},
|
||||
)(RegistrationPage);
|
||||
@@ -1,72 +0,0 @@
|
||||
.registration-container {
|
||||
margin: 4rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.registration-header {
|
||||
border-bottom: 1px solid #e7e7e7;
|
||||
height: 3.75rem;
|
||||
position: relative;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.registration-header img {
|
||||
height: 1.75rem;
|
||||
margin-left: 2rem;
|
||||
padding: 1rem 0;
|
||||
display: block;
|
||||
position: relative;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
.btn-social {
|
||||
padding: 0.5rem 1rem;
|
||||
color: white;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.facebook {
|
||||
border-color: #4267b2;
|
||||
background-color: #4267b2;
|
||||
}
|
||||
|
||||
.google {
|
||||
border-color: #4285f4;
|
||||
background-color: #4285f4;
|
||||
}
|
||||
|
||||
.microsoft {
|
||||
border-color: #2f2f2f;
|
||||
background-color: #2f2f2f;
|
||||
}
|
||||
|
||||
.submit {
|
||||
display: inherit;
|
||||
margin: 0 auto;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.section-heading-line{
|
||||
position: relative;
|
||||
text-align: center;
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
width: 20%;
|
||||
background-color: gray;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
width: 20%;
|
||||
background-color: gray;
|
||||
height: 1px;
|
||||
}
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
const countryList = {
|
||||
AF: 'Afghanistan',
|
||||
AL: 'Albania',
|
||||
DZ: 'Algeria',
|
||||
AS: 'American Samoa',
|
||||
AD: 'Andorra',
|
||||
AO: 'Angola',
|
||||
AI: 'Anguilla',
|
||||
AQ: 'Antarctica',
|
||||
AG: 'Antigua and Barbuda',
|
||||
AR: 'Argentina',
|
||||
AM: 'Armenia',
|
||||
AW: 'Aruba',
|
||||
AU: 'Australia',
|
||||
AT: 'Austria',
|
||||
AZ: 'Azerbaijan',
|
||||
BS: 'Bahamas (the)',
|
||||
BH: 'Bahrain',
|
||||
BD: 'Bangladesh',
|
||||
BB: 'Barbados',
|
||||
BY: 'Belarus',
|
||||
BE: 'Belgium',
|
||||
BZ: 'Belize',
|
||||
BJ: 'Benin',
|
||||
BM: 'Bermuda',
|
||||
BT: 'Bhutan',
|
||||
BO: 'Bolivia (Plurinational State of)',
|
||||
BQ: 'Bonaire, Sint Eustatius and Saba',
|
||||
BA: 'Bosnia and Herzegovina',
|
||||
BW: 'Botswana',
|
||||
BV: 'Bouvet Island',
|
||||
BR: 'Brazil',
|
||||
IO: 'British Indian Ocean Territory (the)',
|
||||
BN: 'Brunei Darussalam',
|
||||
BG: 'Bulgaria',
|
||||
BF: 'Burkina Faso',
|
||||
BI: 'Burundi',
|
||||
CV: 'Cabo Verde',
|
||||
KH: 'Cambodia',
|
||||
CM: 'Cameroon',
|
||||
CA: 'Canada',
|
||||
KY: 'Cayman Islands (the)',
|
||||
CF: 'Central African Republic (the)',
|
||||
TD: 'Chad',
|
||||
CL: 'Chile',
|
||||
CN: 'China',
|
||||
CX: 'Christmas Island',
|
||||
CC: 'Cocos (Keeling) Islands (the)',
|
||||
CO: 'Colombia',
|
||||
KM: 'Comoros (the)',
|
||||
CD: 'Congo (the Democratic Republic of the)',
|
||||
CG: 'Congo (the)',
|
||||
CK: 'Cook Islands (the)',
|
||||
CR: 'Costa Rica',
|
||||
HR: 'Croatia',
|
||||
CU: 'Cuba',
|
||||
CW: 'Curaçao',
|
||||
CY: 'Cyprus',
|
||||
CZ: 'Czechia',
|
||||
CI: 'Côte d\'Ivoire',
|
||||
DK: 'Denmark',
|
||||
DJ: 'Djibouti',
|
||||
DM: 'Dominica',
|
||||
DO: 'Dominican Republic (the)',
|
||||
EC: 'Ecuador',
|
||||
EG: 'Egypt',
|
||||
SV: 'El Salvador',
|
||||
GQ: 'Equatorial Guinea',
|
||||
ER: 'Eritrea',
|
||||
EE: 'Estonia',
|
||||
SZ: 'Eswatini',
|
||||
ET: 'Ethiopia',
|
||||
FK: 'Falkland Islands (the) [Malvinas]',
|
||||
FO: 'Faroe Islands (the)',
|
||||
FJ: 'Fiji',
|
||||
FI: 'Finland',
|
||||
FR: 'France',
|
||||
GF: 'French Guiana',
|
||||
PF: 'French Polynesia',
|
||||
TF: 'French Southern Territories (the)',
|
||||
GA: 'Gabon',
|
||||
GM: 'Gambia (the)',
|
||||
GE: 'Georgia',
|
||||
DE: 'Germany',
|
||||
GH: 'Ghana',
|
||||
GI: 'Gibraltar',
|
||||
GR: 'Greece',
|
||||
GL: 'Greenland',
|
||||
GD: 'Grenada',
|
||||
GP: 'Guadeloupe',
|
||||
GU: 'Guam',
|
||||
GT: 'Guatemala',
|
||||
GG: 'Guernsey',
|
||||
GN: 'Guinea',
|
||||
GW: 'Guinea-Bissau',
|
||||
GY: 'Guyana',
|
||||
HT: 'Haiti',
|
||||
HM: 'Heard Island and McDonald Islands',
|
||||
VA: 'Holy See (the)',
|
||||
HN: 'Honduras',
|
||||
HK: 'Hong Kong',
|
||||
HU: 'Hungary',
|
||||
IS: 'Iceland',
|
||||
IN: 'India',
|
||||
ID: 'Indonesia',
|
||||
IR: 'Iran (Islamic Republic of)',
|
||||
IQ: 'Iraq',
|
||||
IE: 'Ireland',
|
||||
IM: 'Isle of Man',
|
||||
IL: 'Israel',
|
||||
IT: 'Italy',
|
||||
JM: 'Jamaica',
|
||||
JP: 'Japan',
|
||||
JE: 'Jersey',
|
||||
JO: 'Jordan',
|
||||
KZ: 'Kazakhstan',
|
||||
KE: 'Kenya',
|
||||
KI: 'Kiribati',
|
||||
KP: 'Korea (the Democratic People\'s Republic of)',
|
||||
KR: 'Korea (the Republic of)',
|
||||
KW: 'Kuwait',
|
||||
KG: 'Kyrgyzstan',
|
||||
LA: 'Lao People\'s Democratic Republic (the)',
|
||||
LV: 'Latvia',
|
||||
LB: 'Lebanon',
|
||||
LS: 'Lesotho',
|
||||
LR: 'Liberia',
|
||||
LY: 'Libya',
|
||||
LI: 'Liechtenstein',
|
||||
LT: 'Lithuania',
|
||||
LU: 'Luxembourg',
|
||||
MO: 'Macao',
|
||||
MG: 'Madagascar',
|
||||
MW: 'Malawi',
|
||||
MY: 'Malaysia',
|
||||
MV: 'Maldives',
|
||||
ML: 'Mali',
|
||||
MT: 'Malta',
|
||||
MH: 'Marshall Islands (the)',
|
||||
MQ: 'Martinique',
|
||||
MR: 'Mauritania',
|
||||
MU: 'Mauritius',
|
||||
YT: 'Mayotte',
|
||||
MX: 'Mexico',
|
||||
FM: 'Micronesia (Federated States of)',
|
||||
MD: 'Moldova (the Republic of)',
|
||||
MC: 'Monaco',
|
||||
MN: 'Mongolia',
|
||||
ME: 'Montenegro',
|
||||
MS: 'Montserrat',
|
||||
MA: 'Morocco',
|
||||
MZ: 'Mozambique',
|
||||
MM: 'Myanmar',
|
||||
NA: 'Namibia',
|
||||
NR: 'Nauru',
|
||||
NP: 'Nepal',
|
||||
NL: 'Netherlands (the)',
|
||||
NC: 'New Caledonia',
|
||||
NZ: 'New Zealand',
|
||||
NI: 'Nicaragua',
|
||||
NE: 'Niger (the)',
|
||||
NG: 'Nigeria',
|
||||
NU: 'Niue',
|
||||
NF: 'Norfolk Island',
|
||||
MP: 'Northern Mariana Islands (the)',
|
||||
NO: 'Norway',
|
||||
OM: 'Oman',
|
||||
PK: 'Pakistan',
|
||||
PW: 'Palau',
|
||||
PS: 'Palestine, State of',
|
||||
PA: 'Panama',
|
||||
PG: 'Papua New Guinea',
|
||||
PY: 'Paraguay',
|
||||
PE: 'Peru',
|
||||
PH: 'Philippines (the)',
|
||||
PN: 'Pitcairn',
|
||||
PL: 'Poland',
|
||||
PT: 'Portugal',
|
||||
PR: 'Puerto Rico',
|
||||
QA: 'Qatar',
|
||||
MK: 'Republic of North Macedonia',
|
||||
RO: 'Romania',
|
||||
RU: 'Russian Federation (the)',
|
||||
RW: 'Rwanda',
|
||||
RE: 'Réunion',
|
||||
BL: 'Saint Barthélemy',
|
||||
SH: 'Saint Helena, Ascension and Tristan da Cunha',
|
||||
KN: 'Saint Kitts and Nevis',
|
||||
LC: 'Saint Lucia',
|
||||
MF: 'Saint Martin (French part)',
|
||||
PM: 'Saint Pierre and Miquelon',
|
||||
VC: 'Saint Vincent and the Grenadines',
|
||||
WS: 'Samoa',
|
||||
SM: 'San Marino',
|
||||
ST: 'Sao Tome and Principe',
|
||||
SA: 'Saudi Arabia',
|
||||
SN: 'Senegal',
|
||||
RS: 'Serbia',
|
||||
SC: 'Seychelles',
|
||||
SL: 'Sierra Leone',
|
||||
SG: 'Singapore',
|
||||
SX: 'Sint Maarten (Dutch part)',
|
||||
SK: 'Slovakia',
|
||||
SI: 'Slovenia',
|
||||
SB: 'Solomon Islands',
|
||||
SO: 'Somalia',
|
||||
ZA: 'South Africa',
|
||||
GS: 'South Georgia and the South Sandwich Islands',
|
||||
SS: 'South Sudan',
|
||||
ES: 'Spain',
|
||||
LK: 'Sri Lanka',
|
||||
SD: 'Sudan (the)',
|
||||
SR: 'Suriname',
|
||||
SJ: 'Svalbard and Jan Mayen',
|
||||
SE: 'Sweden',
|
||||
CH: 'Switzerland',
|
||||
SY: 'Syrian Arab Republic',
|
||||
TW: 'Taiwan (Province of China)',
|
||||
TJ: 'Tajikistan',
|
||||
TZ: 'Tanzania, United Republic of',
|
||||
TH: 'Thailand',
|
||||
TL: 'Timor-Leste',
|
||||
TG: 'Togo',
|
||||
TK: 'Tokelau',
|
||||
TO: 'Tonga',
|
||||
TT: 'Trinidad and Tobago',
|
||||
TN: 'Tunisia',
|
||||
TR: 'Turkey',
|
||||
TM: 'Turkmenistan',
|
||||
TC: 'Turks and Caicos Islands (the)',
|
||||
TV: 'Tuvalu',
|
||||
UG: 'Uganda',
|
||||
UA: 'Ukraine',
|
||||
AE: 'United Arab Emirates (the)',
|
||||
GB: 'United Kingdom of Great Britain and Northern Ireland (the)',
|
||||
UM: 'United States Minor Outlying Islands (the)',
|
||||
US: 'United States of America (the)',
|
||||
UY: 'Uruguay',
|
||||
UZ: 'Uzbekistan',
|
||||
VU: 'Vanuatu',
|
||||
VE: 'Venezuela (Bolivarian Republic of)',
|
||||
VN: 'Viet Nam',
|
||||
VG: 'Virgin Islands (British)',
|
||||
VI: 'Virgin Islands (U.S.)',
|
||||
WF: 'Wallis and Futuna',
|
||||
EH: 'Western Sahara',
|
||||
YE: 'Yemen',
|
||||
ZM: 'Zambia',
|
||||
ZW: 'Zimbabwe',
|
||||
AX: 'Åland Islands',
|
||||
};
|
||||
|
||||
export default countryList;
|
||||
@@ -1,41 +0,0 @@
|
||||
import { AsyncActionType } from './utils';
|
||||
|
||||
export const REGISTER_NEW_USER = new AsyncActionType('REGISTRATION', 'REGISTER_NEW_USER');
|
||||
export const LOGIN_REQUEST = new AsyncActionType('LOGIN', 'REQUEST');
|
||||
|
||||
// Register
|
||||
|
||||
export const registerNewUser = registrationInfo => ({
|
||||
type: REGISTER_NEW_USER.BASE,
|
||||
payload: { registrationInfo },
|
||||
});
|
||||
|
||||
export const registerNewUserBegin = () => ({
|
||||
type: REGISTER_NEW_USER.BEGIN,
|
||||
});
|
||||
|
||||
export const registerNewUserSuccess = () => ({
|
||||
type: REGISTER_NEW_USER.SUCCESS,
|
||||
});
|
||||
|
||||
export const registerNewUserFailure = () => ({
|
||||
type: REGISTER_NEW_USER.FAILURE,
|
||||
});
|
||||
|
||||
// Login
|
||||
export const loginRequest = creds => ({
|
||||
type: LOGIN_REQUEST.BASE,
|
||||
payload: { creds },
|
||||
});
|
||||
|
||||
export const loginRequestBegin = () => ({
|
||||
type: LOGIN_REQUEST.BEGIN,
|
||||
});
|
||||
|
||||
export const loginRequestSuccess = () => ({
|
||||
type: LOGIN_REQUEST.SUCCESS,
|
||||
});
|
||||
|
||||
export const loginRequestFailure = () => ({
|
||||
type: LOGIN_REQUEST.FAILURE,
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
import {
|
||||
REGISTER_NEW_USER,
|
||||
LOGIN_REQUEST,
|
||||
} from './actions';
|
||||
|
||||
export const defaultState = {
|
||||
registrationResult: {},
|
||||
};
|
||||
|
||||
const reducer = (state = defaultState, action) => {
|
||||
switch (action.type) {
|
||||
case REGISTER_NEW_USER.BEGIN:
|
||||
return {
|
||||
...state,
|
||||
};
|
||||
case REGISTER_NEW_USER.SUCCESS:
|
||||
return {
|
||||
...state,
|
||||
};
|
||||
case REGISTER_NEW_USER.FAILURE:
|
||||
return {
|
||||
...state,
|
||||
};
|
||||
case LOGIN_REQUEST.BEGIN:
|
||||
return {
|
||||
...state,
|
||||
};
|
||||
case LOGIN_REQUEST.SUCCESS:
|
||||
return {
|
||||
...state,
|
||||
};
|
||||
case LOGIN_REQUEST.FAILURE:
|
||||
return {
|
||||
...state,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export default reducer;
|
||||
@@ -1,48 +0,0 @@
|
||||
import { call, put, takeEvery } from 'redux-saga/effects';
|
||||
|
||||
// Actions
|
||||
import {
|
||||
REGISTER_NEW_USER,
|
||||
registerNewUserBegin,
|
||||
registerNewUserFailure,
|
||||
registerNewUserSuccess,
|
||||
LOGIN_REQUEST,
|
||||
loginRequestBegin,
|
||||
loginRequestFailure,
|
||||
loginRequestSuccess,
|
||||
} from './actions';
|
||||
|
||||
|
||||
// Services
|
||||
import { postNewUser, login } from './service';
|
||||
|
||||
export function* handleNewUserRegistration(action) {
|
||||
try {
|
||||
yield put(registerNewUserBegin());
|
||||
|
||||
yield call(postNewUser, action.payload.registrationInfo);
|
||||
|
||||
yield put(registerNewUserSuccess());
|
||||
} catch (e) {
|
||||
yield put(registerNewUserFailure());
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export function* handleLoginRequest(action) {
|
||||
try {
|
||||
yield put(loginRequestBegin());
|
||||
|
||||
yield call(login, action.payload.creds);
|
||||
|
||||
yield put(loginRequestSuccess());
|
||||
} catch (e) {
|
||||
yield put(loginRequestFailure());
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export default function* saga() {
|
||||
yield takeEvery(REGISTER_NEW_USER.BASE, handleNewUserRegistration);
|
||||
yield takeEvery(LOGIN_REQUEST.BASE, handleLoginRequest);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getHttpClient } from '@edx/frontend-platform/auth';
|
||||
import querystring from 'querystring';
|
||||
|
||||
export async function postNewUser(registrationInformation) {
|
||||
const requestConfig = {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
};
|
||||
|
||||
const { data } = await getHttpClient()
|
||||
.post(
|
||||
`${getConfig().LMS_BASE_URL}/user_api/v1/account/registration/`,
|
||||
querystring.stringify(registrationInformation),
|
||||
requestConfig,
|
||||
)
|
||||
.catch((e) => {
|
||||
console.log('You messed up');
|
||||
throw (e);
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function login(creds) {
|
||||
const requestConfig = {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
};
|
||||
|
||||
const { data } = await getHttpClient()
|
||||
.post(
|
||||
`${getConfig().LMS_BASE_URL}/user_api/v1/account/registration/`,
|
||||
creds,
|
||||
requestConfig,
|
||||
)
|
||||
.catch((e) => {
|
||||
console.log('You messed up');
|
||||
throw (e);
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`getModuleState should throw an exception on a bad path 1`] = `"Unexpected state key uhoh given to getModuleState. Is your state path set up correctly?"`;
|
||||
@@ -1,38 +0,0 @@
|
||||
import camelCase from 'lodash.camelcase';
|
||||
import snakeCase from 'lodash.snakecase';
|
||||
|
||||
export function modifyObjectKeys(object, modify) {
|
||||
// If the passed in object is not an object, return it.
|
||||
if (
|
||||
object === undefined ||
|
||||
object === null ||
|
||||
(typeof object !== 'object' && !Array.isArray(object))
|
||||
) {
|
||||
return object;
|
||||
}
|
||||
|
||||
if (Array.isArray(object)) {
|
||||
return object.map(value => modifyObjectKeys(value, modify));
|
||||
}
|
||||
|
||||
// Otherwise, process all its keys.
|
||||
const result = {};
|
||||
Object.entries(object).forEach(([key, value]) => {
|
||||
result[modify(key)] = modifyObjectKeys(value, modify);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export function camelCaseObject(object) {
|
||||
return modifyObjectKeys(object, camelCase);
|
||||
}
|
||||
|
||||
export function snakeCaseObject(object) {
|
||||
return modifyObjectKeys(object, snakeCase);
|
||||
}
|
||||
|
||||
export function convertKeyNames(object, nameMap) {
|
||||
const transformer = key => (nameMap[key] === undefined ? key : nameMap[key]);
|
||||
|
||||
return modifyObjectKeys(object, transformer);
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import {
|
||||
modifyObjectKeys,
|
||||
camelCaseObject,
|
||||
snakeCaseObject,
|
||||
convertKeyNames,
|
||||
} from './dataUtils';
|
||||
|
||||
describe('modifyObjectKeys', () => {
|
||||
it('should use the provided modify function to change all keys in and object and its children', () => {
|
||||
function meowKeys(key) {
|
||||
return `${key}Meow`;
|
||||
}
|
||||
|
||||
const result = modifyObjectKeys(
|
||||
{
|
||||
one: undefined,
|
||||
two: null,
|
||||
three: '',
|
||||
four: 0,
|
||||
five: NaN,
|
||||
six: [1, 2, { seven: 'woof' }],
|
||||
eight: { nine: { ten: 'bark' }, eleven: true },
|
||||
},
|
||||
meowKeys,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
oneMeow: undefined,
|
||||
twoMeow: null,
|
||||
threeMeow: '',
|
||||
fourMeow: 0,
|
||||
fiveMeow: NaN,
|
||||
sixMeow: [1, 2, { sevenMeow: 'woof' }],
|
||||
eightMeow: { nineMeow: { tenMeow: 'bark' }, elevenMeow: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('camelCaseObject', () => {
|
||||
it('should make everything camelCase', () => {
|
||||
const result = camelCaseObject({
|
||||
what_now: 'brown cow',
|
||||
but_who: { says_you_people: 'okay then', but_how: { will_we_even_know: 'the song is over' } },
|
||||
'dot.dot.dot': 123,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
whatNow: 'brown cow',
|
||||
butWho: { saysYouPeople: 'okay then', butHow: { willWeEvenKnow: 'the song is over' } },
|
||||
dotDotDot: 123,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('snakeCaseObject', () => {
|
||||
it('should make everything snake_case', () => {
|
||||
const result = snakeCaseObject({
|
||||
whatNow: 'brown cow',
|
||||
butWho: { saysYouPeople: 'okay then', butHow: { willWeEvenKnow: 'the song is over' } },
|
||||
'dot.dot.dot': 123,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
what_now: 'brown cow',
|
||||
but_who: { says_you_people: 'okay then', but_how: { will_we_even_know: 'the song is over' } },
|
||||
dot_dot_dot: 123,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertKeyNames', () => {
|
||||
it('should replace the specified keynames', () => {
|
||||
const result = convertKeyNames(
|
||||
{
|
||||
one: { two: { three: 'four' } },
|
||||
five: 'six',
|
||||
},
|
||||
{
|
||||
two: 'blue',
|
||||
five: 'alive',
|
||||
seven: 'heaven',
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
one: { blue: { three: 'four' } },
|
||||
alive: 'six',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,12 +0,0 @@
|
||||
export {
|
||||
camelCaseObject,
|
||||
convertKeyNames,
|
||||
modifyObjectKeys,
|
||||
snakeCaseObject,
|
||||
} from './dataUtils';
|
||||
export {
|
||||
AsyncActionType,
|
||||
getModuleState,
|
||||
} from './reduxUtils';
|
||||
export { default as handleFailure } from './sagaUtils';
|
||||
export { unpackFieldErrors, handleRequestError } from './serviceUtils';
|
||||
@@ -1,62 +0,0 @@
|
||||
/**
|
||||
* Helper class to save time when writing out action types for asynchronous methods. Also helps
|
||||
* ensure that actions are namespaced.
|
||||
*/
|
||||
export class AsyncActionType {
|
||||
constructor(topic, name) {
|
||||
this.topic = topic;
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
get BASE() {
|
||||
return `${this.topic}__${this.name}`;
|
||||
}
|
||||
|
||||
get BEGIN() {
|
||||
return `${this.topic}__${this.name}__BEGIN`;
|
||||
}
|
||||
|
||||
get SUCCESS() {
|
||||
return `${this.topic}__${this.name}__SUCCESS`;
|
||||
}
|
||||
|
||||
get FAILURE() {
|
||||
return `${this.topic}__${this.name}__FAILURE`;
|
||||
}
|
||||
|
||||
get RESET() {
|
||||
return `${this.topic}__${this.name}__RESET`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a state tree and an array representing a set of keys to traverse in that tree, returns
|
||||
* the portion of the tree at that key path.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* const result = getModuleState(
|
||||
* {
|
||||
* first: { red: { awesome: 'sauce' }, blue: { weak: 'sauce' } },
|
||||
* second: { other: 'data', }
|
||||
* },
|
||||
* ['first', 'red']
|
||||
* );
|
||||
*
|
||||
* result will be:
|
||||
*
|
||||
* {
|
||||
* awesome: 'sauce'
|
||||
* }
|
||||
*/
|
||||
export function getModuleState(state, originalPath) {
|
||||
const path = [...originalPath]; // don't modify your argument
|
||||
if (path.length < 1) {
|
||||
return state;
|
||||
}
|
||||
const key = path.shift();
|
||||
if (state[key] === undefined) {
|
||||
throw new Error(`Unexpected state key ${key} given to getModuleState. Is your state path set up correctly?`);
|
||||
}
|
||||
return getModuleState(state[key], path);
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import {
|
||||
AsyncActionType,
|
||||
getModuleState,
|
||||
} from './reduxUtils';
|
||||
|
||||
describe('AsyncActionType', () => {
|
||||
it('should return well formatted action strings', () => {
|
||||
const actionType = new AsyncActionType('HOUSE_CATS', 'START_THE_RACE');
|
||||
|
||||
expect(actionType.BASE).toBe('HOUSE_CATS__START_THE_RACE');
|
||||
expect(actionType.BEGIN).toBe('HOUSE_CATS__START_THE_RACE__BEGIN');
|
||||
expect(actionType.SUCCESS).toBe('HOUSE_CATS__START_THE_RACE__SUCCESS');
|
||||
expect(actionType.FAILURE).toBe('HOUSE_CATS__START_THE_RACE__FAILURE');
|
||||
expect(actionType.RESET).toBe('HOUSE_CATS__START_THE_RACE__RESET');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getModuleState', () => {
|
||||
const state = {
|
||||
first: { red: { awesome: 'sauce' }, blue: { weak: 'sauce' } },
|
||||
second: { other: 'data' },
|
||||
};
|
||||
|
||||
it('should return everything if given an empty path', () => {
|
||||
expect(getModuleState(state, [])).toEqual(state);
|
||||
});
|
||||
|
||||
it('should resolve paths correctly', () => {
|
||||
expect(getModuleState(
|
||||
state,
|
||||
['first'],
|
||||
)).toEqual({ red: { awesome: 'sauce' }, blue: { weak: 'sauce' } });
|
||||
|
||||
expect(getModuleState(
|
||||
state,
|
||||
['first', 'red'],
|
||||
)).toEqual({ awesome: 'sauce' });
|
||||
|
||||
expect(getModuleState(state, ['second'])).toEqual({ other: 'data' });
|
||||
});
|
||||
|
||||
it('should throw an exception on a bad path', () => {
|
||||
expect(() => {
|
||||
getModuleState(state, ['uhoh']);
|
||||
}).toThrowErrorMatchingSnapshot();
|
||||
});
|
||||
|
||||
it('should return non-objects correctly', () => {
|
||||
expect(getModuleState(state, ['first', 'red', 'awesome'])).toEqual('sauce');
|
||||
});
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
import { put } from 'redux-saga/effects';
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
import { history } from '@edx/frontend-platform';
|
||||
|
||||
export default function* handleFailure(error, failureAction = null, failureRedirectPath = null) {
|
||||
if (error.fieldErrors && failureAction !== null) {
|
||||
yield put(failureAction({ fieldErrors: error.fieldErrors }));
|
||||
}
|
||||
logError(error);
|
||||
if (failureAction !== null) {
|
||||
yield put(failureAction(error.message));
|
||||
}
|
||||
if (failureRedirectPath !== null) {
|
||||
history.push(failureRedirectPath);
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* Turns field errors of the form:
|
||||
*
|
||||
* {
|
||||
* "name":{
|
||||
* "developer_message": "Nerdy message here",
|
||||
* "user_message": "This value is invalid."
|
||||
* },
|
||||
* "other_field": {
|
||||
* "developer_message": "Other Nerdy message here",
|
||||
* "user_message": "This other value is invalid."
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* Into:
|
||||
*
|
||||
* {
|
||||
* "name": "This value is invalid.",
|
||||
* "other_field": "This other value is invalid"
|
||||
* }
|
||||
*/
|
||||
export function unpackFieldErrors(fieldErrors) {
|
||||
return Object.entries(fieldErrors).reduce((acc, [k, v]) => {
|
||||
acc[k] = v.user_message;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes and re-throws request errors. If the response contains a field_errors field, will
|
||||
* massage the data into a form expected by the client.
|
||||
*
|
||||
* Field errors will be packaged as an api error with a fieldErrors field usable by the client.
|
||||
* Takes an optional unpack function which is used to process the field errors,
|
||||
* otherwise uses the default unpackFieldErrors function.
|
||||
*
|
||||
* @param error The original error object.
|
||||
* @param unpackFunction (Optional) A function to use to unpack the field errors as a replacement
|
||||
* for the default.
|
||||
*/
|
||||
export function handleRequestError(error, unpackFunction = unpackFieldErrors) {
|
||||
if (error.response && error.response.data.field_errors) {
|
||||
const apiError = Object.create(error);
|
||||
apiError.fieldErrors = unpackFunction(error.response.data.field_errors);
|
||||
throw apiError;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export { default as LoginPage } from './LoginPage';
|
||||
export { RegistrationPage } from './RegistrationPage';
|
||||
export { default as reducer } from './data/reducers';
|
||||
export { default as saga } from './data/sagas';
|
||||
Reference in New Issue
Block a user