From 73616740197eb0f6cabfc5dd812279c36df6ddbe Mon Sep 17 00:00:00 2001 From: "Albert (AJ) St. Aubin" Date: Wed, 18 Mar 2020 12:48:37 -0400 Subject: [PATCH] Created the form to collect phone number and name for coaching. --- docs/decisions/0002-coaching-addition.rst | 30 ++ .../coaching/CoachingConsent.jsx | 293 ++++++++++++++++++ .../coaching/CoachingConsent.messages.js | 61 ++++ .../coaching/CoachingConsentForm.jsx | 104 +++++++ .../coaching/CoachingToggle.jsx | 8 +- src/account-settings/coaching/data/service.js | 17 +- src/account-settings/data/selectors.js | 39 +++ src/index.jsx | 22 +- src/index.scss | 15 + src/logo.svg | 15 + 10 files changed, 592 insertions(+), 12 deletions(-) create mode 100644 docs/decisions/0002-coaching-addition.rst create mode 100644 src/account-settings/coaching/CoachingConsent.jsx create mode 100644 src/account-settings/coaching/CoachingConsent.messages.js create mode 100644 src/account-settings/coaching/CoachingConsentForm.jsx create mode 100644 src/logo.svg diff --git a/docs/decisions/0002-coaching-addition.rst b/docs/decisions/0002-coaching-addition.rst new file mode 100644 index 0000000..eb21830 --- /dev/null +++ b/docs/decisions/0002-coaching-addition.rst @@ -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. diff --git a/src/account-settings/coaching/CoachingConsent.jsx b/src/account-settings/coaching/CoachingConsent.jsx new file mode 100644 index 0000000..c4889ad --- /dev/null +++ b/src/account-settings/coaching/CoachingConsent.jsx @@ -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 }) => ( + <> + {alt} + +); + +const SuccessMessage = props => ( +
+ +
{props.header}
+
{props.message}
+ + {props.continue} + +
+); + +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 ; + case VIEWS.LOADED: + return (); + case VIEWS.SUCCESS_PENDING: + return ; + case VIEWS.SUCCESS: + return (); + case VIEWS.DECLINE_PENDING: + return ; + case VIEWS.DECLINED: + return ; + 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 ( +
+
+ +
+ {this.renderView(currentView)} +
+ ); + } +} + +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)); diff --git a/src/account-settings/coaching/CoachingConsent.messages.js b/src/account-settings/coaching/CoachingConsent.messages.js new file mode 100644 index 0000000..0eae5ae --- /dev/null +++ b/src/account-settings/coaching/CoachingConsent.messages.js @@ -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; diff --git a/src/account-settings/coaching/CoachingConsentForm.jsx b/src/account-settings/coaching/CoachingConsentForm.jsx new file mode 100644 index 0000000..1403fc5 --- /dev/null +++ b/src/account-settings/coaching/CoachingConsentForm.jsx @@ -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 => ( +
{props.message}
+); + +const CoachingForm = props => ( +
+

+ {props.intl.formatMessage(messages['account.settings.coaching.consent.welcome.header'])} +

+

{props.intl.formatMessage(messages['account.settings.coaching.consent.description'])}

+
+
+
+ + + +
+
+ + + +
+
+

+ {props.intl.formatMessage(messages['account.settings.coaching.consent.text-messaging.disclaimer'])} +

+
+ +
+ +
+
+ + {props.intl.formatMessage(messages['account.settings.coaching.consent.decline-coaching'])} + +
+ +
+
+); + +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); diff --git a/src/account-settings/coaching/CoachingToggle.jsx b/src/account-settings/coaching/CoachingToggle.jsx index 333f4c4..86cd817 100644 --- a/src/account-settings/coaching/CoachingToggle.jsx +++ b/src/account-settings/coaching/CoachingToggle.jsx @@ -9,7 +9,7 @@ import { saveSettings, updateDraft } from '../data/actions'; import EditableField from '../EditableField'; -const CoatchingToggle = props => ( +const CoachingToggle = props => ( <> ( ); -CoatchingToggle.defaultProps = { +CoachingToggle.defaultProps = { phone_number: '', error: '', }; -CoatchingToggle.propTypes = { +CoachingToggle.propTypes = { name: PropTypes.string.isRequired, error: PropTypes.string, coaching: PropTypes.objectOf(PropTypes.shape({ @@ -73,4 +73,4 @@ CoatchingToggle.propTypes = { export default connect(editableFieldSelector, { saveSettings, updateDraft, -})(injectIntl(CoatchingToggle)); +})(injectIntl(CoachingToggle)); diff --git a/src/account-settings/coaching/data/service.js b/src/account-settings/coaching/data/service.js index b507622..4a94c1a 100644 --- a/src/account-settings/coaching/data/service.js +++ b/src/account-settings/coaching/data/service.js @@ -7,8 +7,21 @@ import { getConfig } from '@edx/frontend-platform'; * @param {Number} userId users are identified in the api by LMS id */ export async function getCoachingPreferences(userId) { - const { data } = await getAuthenticatedHttpClient() - .get(`${getConfig().LMS_BASE_URL}/api/coaching/v1/users/${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; } diff --git a/src/account-settings/data/selectors.js b/src/account-settings/data/selectors.js index 6d58b75..1305a5f 100644 --- a/src/account-settings/data/selectors.js +++ b/src/account-settings/data/selectors.js @@ -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, + }), +); diff --git a/src/index.jsx b/src/index.jsx index 3fe1662..f1803a9 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -11,23 +11,33 @@ import Footer, { messages as footerMessages } from '@edx/frontend-component-foot import configureStore from './data/configureStore'; import AccountSettingsPage, { NotFoundPage } from './account-settings'; +import CoachingConsent from './account-settings/coaching/CoachingConsent'; import appMessages from './i18n'; import './index.scss'; import './assets/favicon.ico'; +const HeaderFooterLayout = ({ children }) => ( +
+
+
+ {children} +
+
+
+); + subscribe(APP_READY, () => { ReactDOM.render( -
-
- + + + - -
-