Compare commits

..

17 Commits

Author SHA1 Message Date
Kyle McCormick
6f0e6b4c82 Update .env.development for devstack 2020-04-06 08:19:52 -04:00
Kyle McCormick
48c7204e04 npm install 2020-04-05 17:35:01 -04:00
Kyle McCormick
2b93e5fab0 Use kdmccormick's frontend-build; add dev-build command 2020-04-05 17:35:01 -04:00
Kyle McCormick
13c5b12500 Make React routes relative 2020-04-05 17:23:42 -04:00
Matt Tuchfarber
d3d0bf97b6 Merge pull request #205 from edx/aj/MICROBA-234_MB_phone
Add coaching consent form
2020-04-02 14:22:48 -04:00
Albert (AJ) St. Aubin
7361674019 Created the form to collect phone number and name for coaching. 2020-04-02 13:53:36 -04:00
Renovate Bot
10a3f1fb35 fix(deps): update dependency @edx/frontend-component-footer to v10.0.9 2020-03-23 17:44:10 +00:00
Renovate Bot
06d018fc62 fix(deps): update dependency @fortawesome/fontawesome-svg-core to v1.2.28 2020-03-23 16:19:03 +00:00
edX Transifex Bot
3e5bf2b19a fix(i18n): update translations 2020-03-22 17:06:24 -04:00
adeel khan
724a7f9201 Merge pull request #198 from edx/adeel/prod_1349_fix_timezone_dropdown
Fix timezone dropdown under Site Preferences
2020-03-20 00:59:10 +05:00
Adeel Khan
66b27a01d0 Fix timezone dropdown under Site Preferences
This patch would fix timezone dropdown which
doesn't change  value on selection.

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

MB-196

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

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

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

* Add phone number field and coaching consent toggle

MB-196

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

* Made requested changes and additional fixes

* Requested changes

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

1
.env
View File

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

View File

@@ -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'
LOGIN_URL='http://localhost:18000/login'
LOGOUT_URL='http://localhost:18000/login'
MARKETING_SITE_BASE_URL='http://localhost:18000'
NODE_ENV='development'
ORDER_HISTORY_URL='localhost:1996/orders'
PORT=1997
ORDER_HISTORY_URL='localhost:19000/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=''

View File

@@ -15,4 +15,4 @@ SEGMENT_KEY=null
SITE_NAME='edX'
SUPPORT_URL='http://localhost:18000/support'
USER_INFO_COOKIE_NAME='edx-user-info'
ENABLE_LOGIN_AND_REGISTRATION=false #hackathon 23 todo
COACHING_ENABLED=''

View File

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

9169
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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:kdmccormick/frontend-build#kdmccormick/devstack",
"codecov": "3.6.5",
"enzyme": "3.10.0",
"enzyme-adapter-react-16": "1.15.2",

View File

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

View File

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

View File

@@ -0,0 +1,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));

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

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

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

View File

@@ -0,0 +1,31 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'account.settings.field.phone_number': {
id: 'account.settings.field.phone_number',
defaultMessage: 'Phone Number',
description: 'The label for a phone numbers setting in the user profile',
},
'account.settings.field.phone_number.empty': {
id: 'account.settings.field.phone_number.empty',
defaultMessage: 'Add a phone number',
description: 'placeholder for a profiles empty phone number field',
},
'account.settings.field.coaching_consent': {
id: 'account.settings.field.coaching_consent',
defaultMessage: 'Coaching consent',
description: 'The label for the coaching consent setting in the user profile',
},
'account.settings.field.coaching_consent.tooltip': {
id: 'account.settings.field.coaching_consent.tooltip',
defaultMessage: 'MicroBachelors programs include text message based coaching that helps you pair educational experiences with your career goals through one-on-one advice. Coaching services are included at no additional cost, and are available 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;

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

View File

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

View File

@@ -41,6 +41,16 @@ const isEditingSelector = createSelector(
(name, accountSettings) => accountSettings.openFormId === name,
);
const confirmationValuesSelector = createSelector(
accountSettingsSelector,
accountSettings => accountSettings.confirmationValues,
);
const errorSelector = createSelector(
accountSettingsSelector,
accountSettings => accountSettings.errors,
);
const saveStateSelector = createSelector(
accountSettingsSelector,
accountSettings => accountSettings.saveState,
@@ -159,3 +169,32 @@ export const accountSettingsPageSelector = createSelector(
tpaProviders: accountSettings.thirdPartyAuth.providers,
}),
);
export const coachingConsentPageSelector = createSelector(
accountSettingsSelector,
formValuesSelector,
hiddenFieldsSelector,
activeAccountSelector,
saveStateSelector,
confirmationValuesSelector,
errorSelector,
(
accountSettings,
formValues,
hiddenFields,
activeAccount,
saveState,
confirmationValues,
errors,
) => ({
loading: accountSettings.loading,
loaded: accountSettings.loaded,
loadingError: accountSettings.loadingError,
isActive: activeAccount,
formValues,
hiddenFields,
saveState,
confirmationValues,
formErrors: errors,
}),
);

View File

@@ -6,6 +6,7 @@ import isEmpty from 'lodash.isempty';
import { handleRequestError, unpackFieldErrors } from './utils';
import { getThirdPartyAuthProviders } from '../third-party-auth';
import { getCoachingPreferences, patchCoachingPreferences } from '../coaching/data/service';
const SOCIAL_PLATFORMS = [
{ id: 'twitter', key: 'social_link_twitter' },
@@ -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

View File

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

View File

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

View File

@@ -76,6 +76,11 @@
"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.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!",

View File

@@ -76,6 +76,11 @@
"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.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!",

View File

@@ -76,6 +76,11 @@
"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.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!",

View File

@@ -76,6 +76,11 @@
"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.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!",

View File

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

View File

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

View File

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

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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