From 85fbc543849e147510f66dc5a005dd77ff76e517 Mon Sep 17 00:00:00 2001 From: Zainab Amir Date: Fri, 2 Dec 2022 13:53:14 +0500 Subject: [PATCH] feat: refactor registration page --- .env | 2 + src/common-components/EnterpriseSSO.jsx | 3 + src/common-components/FormGroup.jsx | 1 - .../InstitutionLogistration.jsx | 6 + src/common-components/Logistration.jsx | 59 +- src/common-components/PasswordField.jsx | 4 +- src/common-components/ThirdPartyAuthAlert.jsx | 20 +- src/common-components/data/reducers.js | 8 + src/common-components/data/selectors.js | 8 + src/common-components/data/service.js | 4 +- src/common-components/messages.jsx | 5 + .../tests/Logistration.test.jsx | 14 +- .../ThirdPartyAuthAlert.test.jsx.snap | 35 +- src/data/constants.js | 2 +- src/data/utils/dataUtils.js | 2 +- src/data/utils/index.js | 2 +- src/index.jsx | 1 + src/login/LoginPage.jsx | 15 +- src/register/ConfigurableRegistrationForm.jsx | 205 +++ src/register/CountryDropdown.jsx | 229 --- src/register/RegistrationFailure.jsx | 4 + src/register/RegistrationPage.jsx | 1459 +++++++---------- src/register/ThirdPartyAuth.jsx | 73 + src/register/data/actions.js | 65 +- src/register/data/reducers.js | 88 +- src/register/data/selectors.js | 15 - src/register/data/tests/reducers.test.js | 256 +-- src/register/data/utils.js | 111 ++ src/register/messages.jsx | 5 - .../registrationFields/CountryField.jsx | 194 +++ .../registrationFields/EmailField.jsx | 82 + .../{ => registrationFields}/HonorCode.jsx | 10 +- .../TermsOfService.jsx | 2 +- .../UsernameField.jsx | 20 +- src/register/registrationFields/index.js | 5 + src/register/tests/HonorCode.test.jsx | 2 +- src/register/tests/RegistrationPage.test.jsx | 600 ++----- src/register/tests/TermsOfService.test.jsx | 2 +- src/register/utils.js | 43 - src/reset-password/ResetPasswordPage.jsx | 4 +- src/sass/_registration.scss | 3 + src/sass/_style.scss | 5 +- 42 files changed, 1683 insertions(+), 1990 deletions(-) create mode 100644 src/register/ConfigurableRegistrationForm.jsx delete mode 100644 src/register/CountryDropdown.jsx create mode 100644 src/register/ThirdPartyAuth.jsx create mode 100644 src/register/data/utils.js create mode 100644 src/register/registrationFields/CountryField.jsx create mode 100644 src/register/registrationFields/EmailField.jsx rename src/register/{ => registrationFields}/HonorCode.jsx (91%) rename src/register/{ => registrationFields}/TermsOfService.jsx (98%) rename src/register/{ => registrationFields}/UsernameField.jsx (76%) create mode 100644 src/register/registrationFields/index.js delete mode 100644 src/register/utils.js create mode 100644 src/sass/_registration.scss diff --git a/.env b/.env index 6d418553..ce338251 100644 --- a/.env +++ b/.env @@ -25,6 +25,8 @@ REGISTER_CONVERSION_COOKIE_NAME=null ENABLE_PROGRESSIVE_PROFILING='' MARKETING_EMAILS_OPT_IN='' ENABLE_COPPA_COMPLIANCE='' +SHOW_CONFIGURABLE_EDX_FIELDS='' +SHOW_DYNAMIC_PROFILING_PAGE='' ZENDESK_KEY='' ZENDESK_LOGO_URL='' APP_ID='' diff --git a/src/common-components/EnterpriseSSO.jsx b/src/common-components/EnterpriseSSO.jsx index 72746cfc..d89c2b4d 100644 --- a/src/common-components/EnterpriseSSO.jsx +++ b/src/common-components/EnterpriseSSO.jsx @@ -12,6 +12,9 @@ import PropTypes from 'prop-types'; import { LOGIN_PAGE, SUPPORTED_ICON_CLASSES } from '../data/constants'; import messages from './messages'; +/** + * This component renders the Single sign-on (SSO) button only for the tpa provider passed + * */ const EnterpriseSSO = (props) => { const { intl } = props; const tpaProvider = props.provider; diff --git a/src/common-components/FormGroup.jsx b/src/common-components/FormGroup.jsx index 5c3250a6..0c597238 100644 --- a/src/common-components/FormGroup.jsx +++ b/src/common-components/FormGroup.jsx @@ -37,7 +37,6 @@ const FormGroup = (props) => { onClick={handleClick} onChange={props.handleChange} controlClassName={props.borderClass} - trailingElement={props.trailingElement} floatingLabel={props.floatingLabel} > diff --git a/src/common-components/InstitutionLogistration.jsx b/src/common-components/InstitutionLogistration.jsx index 13d54bd4..cd5c17d2 100644 --- a/src/common-components/InstitutionLogistration.jsx +++ b/src/common-components/InstitutionLogistration.jsx @@ -8,6 +8,9 @@ import PropTypes from 'prop-types'; import messages from './messages'; +/** + * This component renders the Institution login button + * */ export const RenderInstitutionButton = props => { const { onSubmitHandler, buttonTitle } = props; @@ -24,6 +27,9 @@ export const RenderInstitutionButton = props => { ); }; +/** + * This component renders the page list of available institutions for login + * */ const InstitutionLogistration = props => { const lmsBaseUrl = getConfig().LMS_BASE_URL; const { diff --git a/src/common-components/Logistration.jsx b/src/common-components/Logistration.jsx index 93aba74a..10cd4c78 100644 --- a/src/common-components/Logistration.jsx +++ b/src/common-components/Logistration.jsx @@ -1,4 +1,5 @@ import React, { useEffect, useState } from 'react'; +import { connect } from 'react-redux'; import { getConfig } from '@edx/frontend-platform'; import { sendPageEvent, sendTrackEvent } from '@edx/frontend-platform/analytics'; @@ -15,14 +16,21 @@ import { Redirect } from 'react-router-dom'; import BaseComponent from '../base-component'; import { LOGIN_PAGE, REGISTER_PAGE } from '../data/constants'; -import { getTpaHint, updatePathWithQueryParams } from '../data/utils'; +import { getTpaHint, getTpaProvider, updatePathWithQueryParams } from '../data/utils'; import { LoginPage } from '../login'; import { RegistrationPage } from '../register'; +import { backupRegistrationForm } from '../register/data/actions'; +import { + tpaProvidersSelector, +} from './data/selectors'; import messages from './messages'; const Logistration = (props) => { - const { intl, selectedPage } = props; - const tpa = getTpaHint(); + const { intl, selectedPage, tpaProviders } = props; + const tpaHint = getTpaHint(); + const { + providers, secondaryProviders, + } = tpaProviders; const [institutionLogin, setInstitutionLogin] = useState(false); const [key, setKey] = useState(''); @@ -46,6 +54,9 @@ const Logistration = (props) => { const handleOnSelect = (tabKey) => { sendTrackEvent(`edx.bi.${tabKey.replace('/', '')}_form.toggled`, { category: 'user-engagement' }); + if (tabKey === LOGIN_PAGE) { + props.backupRegistrationForm(); + } setKey(tabKey); }; @@ -60,6 +71,11 @@ const Logistration = (props) => { ); + const isValidTpaHint = () => { + const { provider } = getTpaProvider(tpaHint, providers, secondaryProviders); + return !!provider; + }; + return (
@@ -69,16 +85,14 @@ const Logistration = (props) => { ) - : ( + : (!isValidTpaHint() && ( <> - {!tpa && ( - - - - - )} + + + + - )} + ))} { key && ( )} @@ -95,10 +109,31 @@ const Logistration = (props) => { Logistration.propTypes = { intl: intlShape.isRequired, selectedPage: PropTypes.string, + backupRegistrationForm: PropTypes.func.isRequired, + tpaProviders: PropTypes.shape({ + providers: PropTypes.array, + secondaryProviders: PropTypes.array, + }), +}; + +Logistration.defaultProps = { + tpaProviders: { + providers: [], + secondaryProviders: [], + }, }; Logistration.defaultProps = { selectedPage: REGISTER_PAGE, }; -export default injectIntl(Logistration); +const mapStateToProps = state => ({ + tpaProviders: tpaProvidersSelector(state), +}); + +export default connect( + mapStateToProps, + { + backupRegistrationForm, + }, +)(injectIntl(Logistration)); diff --git a/src/common-components/PasswordField.jsx b/src/common-components/PasswordField.jsx index 26c447eb..bcdc00ff 100644 --- a/src/common-components/PasswordField.jsx +++ b/src/common-components/PasswordField.jsx @@ -30,11 +30,11 @@ const PasswordField = (props) => { }; const HideButton = ( - + ); const ShowButton = ( - + ); const placement = window.innerWidth < 768 ? 'top' : 'left'; const tooltip = ( diff --git a/src/common-components/ThirdPartyAuthAlert.jsx b/src/common-components/ThirdPartyAuthAlert.jsx index a74cb399..a7b0b35e 100644 --- a/src/common-components/ThirdPartyAuthAlert.jsx +++ b/src/common-components/ThirdPartyAuthAlert.jsx @@ -19,22 +19,32 @@ const ThirdPartyAuthAlert = (props) => { message = intl.formatMessage(messages['register.third.party.auth.account.not.linked'], { currentProvider, platformName }); } + if (!currentProvider) { + return null; + } + return ( - + <> + + {referrer === REGISTER_PAGE ? ( + {intl.formatMessage(messages['tpa.alert.heading'])} + ) : null} +

{ message }

+
{referrer === REGISTER_PAGE ? ( - {intl.formatMessage(messages['tpa.alert.heading'])} +

{intl.formatMessage(messages['registration.using.tpa.form.heading'])}

) : null} -

{ message }

-
+ ); }; ThirdPartyAuthAlert.defaultProps = { + currentProvider: '', referrer: LOGIN_PAGE, }; ThirdPartyAuthAlert.propTypes = { - currentProvider: PropTypes.string.isRequired, + currentProvider: PropTypes.string, intl: intlShape.isRequired, referrer: PropTypes.string, }; diff --git a/src/common-components/data/reducers.js b/src/common-components/data/reducers.js index d8435341..e773f1ef 100644 --- a/src/common-components/data/reducers.js +++ b/src/common-components/data/reducers.js @@ -6,6 +6,14 @@ export const defaultState = { fieldDescriptions: {}, optionalFields: {}, thirdPartyAuthApiStatus: null, + thirdPartyAuthContext: { + currentProvider: null, + finishAuthUrl: null, + countryCode: null, + providers: [], + secondaryProviders: [], + pipelineUserDetails: null, + }, }; const reducer = (state = defaultState, action) => { diff --git a/src/common-components/data/selectors.js b/src/common-components/data/selectors.js index bfbef09f..d1b32aa4 100644 --- a/src/common-components/data/selectors.js +++ b/src/common-components/data/selectors.js @@ -23,3 +23,11 @@ export const optionalFieldsSelector = createSelector( commonComponentsSelector, commonComponents => commonComponents.optionalFields, ); + +export const tpaProvidersSelector = createSelector( + commonComponentsSelector, + commonComponents => ({ + providers: commonComponents.thirdPartyAuthContext.providers, + secondaryProviders: commonComponents.thirdPartyAuthContext.secondaryProviders, + }), +); diff --git a/src/common-components/data/service.js b/src/common-components/data/service.js index 4f41cc7f..67ed4a88 100644 --- a/src/common-components/data/service.js +++ b/src/common-components/data/service.js @@ -20,10 +20,8 @@ export async function getThirdPartyAuthContext(urlParams) { return { fieldDescriptions: data.registration_fields || {}, optionalFields: data.optional_fields || {}, - // For backward compatibility with the API, once https://github.com/openedx/edx-platform/pull/30198 is merged - // and deployed update it to use data.context_data thirdPartyAuthContext: camelCaseObject( - convertKeyNames(data.context_data || data, { fullname: 'name' }), + convertKeyNames(data.context_data, { fullname: 'name' }), ), }; } diff --git a/src/common-components/messages.jsx b/src/common-components/messages.jsx index 37770bbb..e1029f74 100644 --- a/src/common-components/messages.jsx +++ b/src/common-components/messages.jsx @@ -123,6 +123,11 @@ const messages = defineMessages({ description: 'Message that appears on register page if user has successfully authenticated with TPA ' + 'but no associated platform account exists', }, + 'registration.using.tpa.form.heading': { + id: 'registration.using.tpa.form.heading', + defaultMessage: 'Finish creating your account', + description: 'Heading that appears above form when user is trying to create account using social auth', + }, }); export default messages; diff --git a/src/common-components/tests/Logistration.test.jsx b/src/common-components/tests/Logistration.test.jsx index 3433097e..786d7f4d 100644 --- a/src/common-components/tests/Logistration.test.jsx +++ b/src/common-components/tests/Logistration.test.jsx @@ -40,8 +40,6 @@ describe('Logistration', () => { beforeEach(() => { auth.getAuthenticatedUser = jest.fn(() => ({ userId: 3, username: 'test-user' })); - }); - it('should render registration page', () => { configure({ loggingService: { logError: jest.fn() }, config: { @@ -50,14 +48,19 @@ describe('Logistration', () => { }, messages: { 'es-419': {}, de: {}, 'en-us': {} }, }); + }); + it('should render registration page', () => { store = mockStore({ register: { registrationResult: { success: false, redirectUrl: '' }, registrationError: {}, }, commonComponents: { - thirdPartyAuthApiStatus: null, + thirdPartyAuthContext: { + providers: [], + secondaryProviders: [], + }, }, }); const logistration = mount(reduxWrapper()); @@ -71,7 +74,10 @@ describe('Logistration', () => { loginResult: { success: false, redirectUrl: '' }, }, commonComponents: { - thirdPartyAuthApiStatus: null, + thirdPartyAuthContext: { + providers: [], + secondaryProviders: [], + }, }, }); diff --git a/src/common-components/tests/__snapshots__/ThirdPartyAuthAlert.test.jsx.snap b/src/common-components/tests/__snapshots__/ThirdPartyAuthAlert.test.jsx.snap index 52de073e..05f169c3 100644 --- a/src/common-components/tests/__snapshots__/ThirdPartyAuthAlert.test.jsx.snap +++ b/src/common-components/tests/__snapshots__/ThirdPartyAuthAlert.test.jsx.snap @@ -21,26 +21,33 @@ exports[`ThirdPartyAuthAlert should match login page third party auth alert mess `; exports[`ThirdPartyAuthAlert should match register page third party auth alert message snapshot 1`] = ` - +
, +

+ Finish creating your account +

, +] `; diff --git a/src/data/constants.js b/src/data/constants.js index 66a79619..4d927c2c 100644 --- a/src/data/constants.js +++ b/src/data/constants.js @@ -30,7 +30,7 @@ export const VALID_EMAIL_REGEX = '(^[-!#$%&\'*+/=?^_`{}|~0-9A-Z]+(\\.[-!#$%&\'*+ + '|\\[(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)){3}\\]$'; export const LETTER_REGEX = /[a-zA-Z]/; export const NUMBER_REGEX = /\d/; -export const VALID_NAME_REGEX = /[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)?/gi; // eslint-disable-line no-useless-escape +export const INVALID_NAME_REGEX = /[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)?/gi; // eslint-disable-line no-useless-escape // Query string parameters that can be passed to LMS to manage // things like auto-enrollment upon login and registration. diff --git a/src/data/utils/dataUtils.js b/src/data/utils/dataUtils.js index ab8d7729..2442fe5e 100644 --- a/src/data/utils/dataUtils.js +++ b/src/data/utils/dataUtils.js @@ -49,7 +49,7 @@ export const updatePathWithQueryParams = (path) => { return `${path}${queryParams}`; }; -export const getAllPossibleQueryParam = () => { +export const getAllPossibleQueryParams = () => { const urlParams = QueryString.parse(window.location.search); const params = {}; Object.entries(urlParams).forEach(([key, value]) => { diff --git a/src/data/utils/index.js b/src/data/utils/index.js index 2161cd5a..5f24690f 100644 --- a/src/data/utils/index.js +++ b/src/data/utils/index.js @@ -2,7 +2,7 @@ export { getTpaProvider, getTpaHint, updatePathWithQueryParams, - getAllPossibleQueryParam, + getAllPossibleQueryParams, getActivationStatus, windowScrollTo, } from './dataUtils'; diff --git a/src/index.jsx b/src/index.jsx index 1c883e9b..e31851c2 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -44,6 +44,7 @@ initialize({ MARKETING_EMAILS_OPT_IN: process.env.MARKETING_EMAILS_OPT_IN || '', ENABLE_COPPA_COMPLIANCE: process.env.ENABLE_COPPA_COMPLIANCE || '', ENABLE_DYNAMIC_REGISTRATION_FIELDS: process.env.ENABLE_DYNAMIC_REGISTRATION_FIELDS || false, + SHOW_CONFIGURABLE_EDX_FIELDS: process.env.SHOW_CONFIGURABLE_EDX_FIELDS || false, }); }, }, diff --git a/src/login/LoginPage.jsx b/src/login/LoginPage.jsx index aba6639a..21152309 100644 --- a/src/login/LoginPage.jsx +++ b/src/login/LoginPage.jsx @@ -25,7 +25,7 @@ import { } from '../data/constants'; import { getActivationStatus, - getAllPossibleQueryParam, + getAllPossibleQueryParams, getTpaHint, getTpaProvider, setSurveyCookie, @@ -54,7 +54,7 @@ class LoginPage extends React.Component { }, isSubmitted: false, }; - this.queryParams = getAllPossibleQueryParam(); + this.queryParams = getAllPossibleQueryParams(); this.tpaHint = getTpaHint(); } @@ -248,13 +248,10 @@ class LoginPage extends React.Component { finishAuthUrl={thirdPartyAuthContext.finishAuthUrl} />
- {thirdPartyAuthContext.currentProvider - && ( - - )} + {this.props.loginError ? : null} {submitState === DEFAULT_STATE && this.state.isSubmitted ? windowScrollTo({ left: 0, top: 0, behavior: 'smooth' }) : null} {activationMsgType && } diff --git a/src/register/ConfigurableRegistrationForm.jsx b/src/register/ConfigurableRegistrationForm.jsx new file mode 100644 index 00000000..fa7c7427 --- /dev/null +++ b/src/register/ConfigurableRegistrationForm.jsx @@ -0,0 +1,205 @@ +import React, { useEffect } from 'react'; + +import { getConfig } from '@edx/frontend-platform'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import PropTypes from 'prop-types'; + +import FormFieldRenderer from '../field-renderer'; +import { FIELDS } from './data/constants'; +import { validateCountryField } from './data/utils'; +import messages from './messages'; +import { HonorCode, TermsOfService } from './registrationFields'; +import CountryField from './registrationFields/CountryField'; + +/** + * Fields on registration page that are not the default required fields (name, email, username, password). + * These configurable required fields are defined on the backend using REGISTRATION_EXTRA_FIELDS setting. + * + * Country and Honor Code/Terms of Services (if enabled) will appear at the bottom of the form, even if they + * appear higher in order returned by backend. This is to make the user experience better. + * + * For edX only: + * Country and honor code fields are required by default, and we will continue to show them on + * frontend even if the API doesn't return it. The `SHOW_CONFIGURABLE_EDX_FIELDS` flag will enable + * it for edX. + * */ +const ConfigurableRegistrationForm = (props) => { + const { + countryList, + email, + fieldDescriptions, + fieldErrors, + formFields, + intl, + setFieldErrors, + setFocusedField, + setFormFields, + } = props; + + let showTermsOfServiceAndHonorCode = false; + let showCountryField = false; + + const formFieldDescriptions = []; + const honorCode = []; + const flags = { + showConfigurableRegistrationFields: getConfig().ENABLE_DYNAMIC_REGISTRATION_FIELDS, + showConfigurableEdxFields: getConfig().SHOW_CONFIGURABLE_EDX_FIELDS, + }; + + useEffect(() => { + if (!formFields.country) { + setFormFields({ ...formFields, country: { countryCode: '', displayValue: '' } }); + } + }); + + const handleOnChange = (event, countryValue = null) => { + const { name, type } = event.target; + let value; + if (countryValue) { + value = { ...countryValue }; + } else { + value = event.target.type === 'checkbox' ? event.target.checked : event.target.value; + if (type === 'checkbox') { + setFieldErrors({ ...fieldErrors, [name]: '' }); + } + } + setFormFields({ ...formFields, [name]: value }); + }; + + const handleOnBlur = (event) => { + const { name, value } = event.target; + let error = ''; + if (name === 'country') { + const countryValidation = validateCountryField( + value.trim(), countryList, intl.formatMessage(messages['empty.country.field.error']), + ); + const { countryCode, displayValue } = countryValidation; + error = countryValidation.error; + setFormFields({ ...formFields, country: { countryCode, displayValue } }); + } else if (!value || !value.trim()) { + error = fieldDescriptions[name].error_message; + } else if (name === 'confirm_email' && value !== email) { + error = intl.formatMessage(messages['email.do.not.match']); + } + setFocusedField(null); + setFieldErrors({ ...fieldErrors, [name]: error }); + }; + + const handleOnFocus = (event) => { + const { name } = event.target; + setFieldErrors({ ...fieldErrors, [name]: '' }); + // Since we are removing the form errors from the focused field, we will + // need to rerun the validation for focused field on form submission. + setFocusedField(name); + }; + + if (flags.showConfigurableRegistrationFields) { + Object.keys(fieldDescriptions).forEach(fieldName => { + const fieldData = fieldDescriptions[fieldName]; + switch (fieldData.name) { + case FIELDS.COUNTRY: + showCountryField = true; + break; + case FIELDS.HONOR_CODE: + if (fieldData.type === 'tos_and_honor_code') { + showTermsOfServiceAndHonorCode = true; + } else { + honorCode.push( + + + , + ); + } + break; + case FIELDS.TERMS_OF_SERVICE: + honorCode.push( + + + , + ); + break; + default: + formFieldDescriptions.push( + + + , + ); + } + }); + } + + if (flags.showConfigurableEdxFields || showCountryField) { + formFieldDescriptions.push( + + + , + ); + } + + if (flags.showConfigurableEdxFields || showTermsOfServiceAndHonorCode) { + formFieldDescriptions.push( + + + , + ); + } + + return ( + <> + {formFieldDescriptions} +
+ {honorCode} +
+ + ); +}; + +ConfigurableRegistrationForm.propTypes = { + countryList: PropTypes.arrayOf(PropTypes.object).isRequired, + email: PropTypes.string.isRequired, + fieldDescriptions: PropTypes.shape({}), + fieldErrors: PropTypes.shape({ + country: PropTypes.string, + }).isRequired, + formFields: PropTypes.shape({ + country: PropTypes.shape({ + displayValue: PropTypes.string, + countryCode: PropTypes.string, + }), + honor_code: PropTypes.bool, + }).isRequired, + intl: intlShape.isRequired, + setFieldErrors: PropTypes.func.isRequired, + setFocusedField: PropTypes.func.isRequired, + setFormFields: PropTypes.func.isRequired, +}; + +ConfigurableRegistrationForm.defaultProps = { + fieldDescriptions: {}, +}; + +export default injectIntl(ConfigurableRegistrationForm); diff --git a/src/register/CountryDropdown.jsx b/src/register/CountryDropdown.jsx deleted file mode 100644 index de472c96..00000000 --- a/src/register/CountryDropdown.jsx +++ /dev/null @@ -1,229 +0,0 @@ -import React from 'react'; - -import { Icon, IconButton } from '@edx/paragon'; -import { ExpandLess, ExpandMore } from '@edx/paragon/icons'; -import PropTypes from 'prop-types'; -import onClickOutside from 'react-onclickoutside'; - -import { FormGroup } from '../common-components'; -import { COUNTRY_CODE_KEY, COUNTRY_DISPLAY_KEY, FORM_SUBMISSION_ERROR } from './data/constants'; - -class CountryDropdown extends React.Component { - constructor(props) { - super(props); - this.handleFocus = this.handleFocus.bind(this); - this.handleOnBlur = this.handleOnBlur.bind(this); - - this.state = { - displayValue: '', - icon: this.expandMoreButton(), - errorMessage: '', - showFieldError: true, - }; - } - - shouldComponentUpdate(nextProps) { - const selectedCountry = this.props.options.find((o) => o[COUNTRY_CODE_KEY] === nextProps.value); - if (this.props.value !== nextProps.value) { - if (selectedCountry) { - this.setState({ - displayValue: selectedCountry[COUNTRY_DISPLAY_KEY], - showFieldError: false, - errorMessage: nextProps.errorMessage, - }); - return false; - } - // Set persisted country value as display value. - this.setState({ displayValue: nextProps.value, showFieldError: true, errorMessage: nextProps.errorMessage }); - return false; - // eslint-disable-next-line no-else-return - } else if (nextProps.value && selectedCountry && this.state.displayValue === nextProps.value) { - // Handling a case here where user enters a valid country code that needs to be - // evaluated and set its display value. - this.setState({ displayValue: selectedCountry[COUNTRY_DISPLAY_KEY] }); - return false; - } - if (this.props.errorCode !== nextProps.errorCode && nextProps.errorCode === 'invalid-country') { - this.setState({ showFieldError: true, errorMessage: nextProps.errorMessage }); - return false; - } - if (this.state.errorMessage !== nextProps.errorMessage) { - this.setState({ showFieldError: true, errorMessage: nextProps.errorMessage }); - return false; - } - return true; - } - - static getDerivedStateFromProps(props, state) { - if (props.errorCode === FORM_SUBMISSION_ERROR && state.showFieldError) { - return { errorMessage: props.errorMessage }; - } - return null; - } - - getItems(strToFind = '') { - let { options } = this.props; - if (strToFind.length > 0) { - options = options.filter((option) => (option.name.toLowerCase().includes(strToFind.toLowerCase()))); - } - - return options.map((opt) => { - const value = opt[COUNTRY_CODE_KEY]; - const displayValue = opt[COUNTRY_DISPLAY_KEY]; - - return ( - - ); - }); - } - - handleOnChange = (e) => { - const filteredItems = this.getItems(e.target.value); - this.setState({ - dropDownItems: filteredItems, - displayValue: e.target.value, - }); - } - - handleClickOutside = () => { - if (this.state.dropDownItems?.length > 0) { - this.setState(() => ({ - icon: this.expandMoreButton(), - dropDownItems: '', - })); - } - } - - handleExpandLess() { - this.setState({ dropDownItems: '', icon: this.expandMoreButton() }); - } - - handleExpandMore() { - this.setState(prevState => ({ - dropDownItems: this.getItems(prevState.displayValue), icon: this.expandLessButton(), errorMessage: '', showFieldError: false, - })); - } - - handleFocus(e) { - const { name, value } = e.target; - this.setState(prevState => ({ - dropDownItems: this.getItems(name === 'country' ? value : prevState.displayValue), - icon: this.expandLessButton(), - errorMessage: '', - showFieldError: false, - })); - if (this.props.handleFocus) { this.props.handleFocus(e); } - } - - handleOnBlur(e, itemClicked = false, country = '') { - const { name } = e.target; - let countryValue = ''; - if (country) { - countryValue = country; - } else { - countryValue = itemClicked ? e.target.value : this.state.displayValue; - } - // For a better user experience, do not validate when focus out from 'country' field - // and focus on 'countryItem' or 'countryExpand' button. - if (e.relatedTarget && e.relatedTarget.name === 'countryItem' && (name === 'country' || name === 'countryExpand')) { - return; - } - const normalized = countryValue.toLowerCase(); - const selectedCountry = this.props.options.find((o) => o[COUNTRY_DISPLAY_KEY].toLowerCase() === normalized); - if (!selectedCountry) { - this.setState({ errorMessage: this.props.errorMessage, showFieldError: true }); - } - if (this.props.handleBlur) { this.props.handleBlur({ target: { name: 'country', value: countryValue } }); } - } - - handleItemClick(e) { - let countryValue = ''; - if (!e.target.value) { - countryValue = e.target.parentElement.parentElement.value; - } - this.setState({ dropDownItems: '', icon: this.expandMoreButton() }); - this.handleOnBlur(e, true, countryValue); - } - - expandMoreButton() { - return ( - { this.handleExpandMore(e); }} - /> - ); - } - - expandLessButton() { - return ( - { this.handleExpandLess(e); }} - /> - ); - } - - render() { - return ( -
- -
- { this.state.dropDownItems?.length > 0 ? this.state.dropDownItems : null } -
-
- ); - } -} - -CountryDropdown.defaultProps = { - options: null, - floatingLabel: null, - handleFocus: null, - handleBlur: null, - value: null, - errorMessage: null, - errorCode: null, -}; - -CountryDropdown.propTypes = { - options: PropTypes.arrayOf(PropTypes.object), - floatingLabel: PropTypes.string, - handleFocus: PropTypes.func, - handleBlur: PropTypes.func, - value: PropTypes.string, - errorMessage: PropTypes.string, - errorCode: PropTypes.string, - name: PropTypes.string.isRequired, -}; - -export default onClickOutside(CountryDropdown); diff --git a/src/register/RegistrationFailure.jsx b/src/register/RegistrationFailure.jsx index 910d09d3..d9ed255e 100644 --- a/src/register/RegistrationFailure.jsx +++ b/src/register/RegistrationFailure.jsx @@ -18,6 +18,10 @@ const RegistrationFailureMessage = (props) => { windowScrollTo({ left: 0, top: 0, behavior: 'smooth' }); }, [errorCode, failureCount]); + if (!errorCode) { + return null; + } + let errorMessage; switch (errorCode) { case INTERNAL_SERVER_ERROR: diff --git a/src/register/RegistrationPage.jsx b/src/register/RegistrationPage.jsx index ac7989fd..c6ec4ea4 100644 --- a/src/register/RegistrationPage.jsx +++ b/src/register/RegistrationPage.jsx @@ -1,659 +1,183 @@ -import React from 'react'; +import React, { + useEffect, useMemo, useState, +} from 'react'; import { connect } from 'react-redux'; import { getConfig, snakeCaseObject } from '@edx/frontend-platform'; -import { sendPageEvent, sendTrackEvent } from '@edx/frontend-platform/analytics'; +import { sendPageEvent } from '@edx/frontend-platform/analytics'; import { getCountryList, getLocale, injectIntl, intlShape, } from '@edx/frontend-platform/i18n'; -import { - Alert, Form, Icon, StatefulButton, -} from '@edx/paragon'; -import { Close, Error } from '@edx/paragon/icons'; -import PropTypes, { string } from 'prop-types'; +import { Form, StatefulButton } from '@edx/paragon'; +import PropTypes from 'prop-types'; import { Helmet } from 'react-helmet'; import Skeleton from 'react-loading-skeleton'; import { - FormGroup, InstitutionLogistration, PasswordField, RedirectLogistration, - RenderInstitutionButton, SocialAuthProviders, ThirdPartyAuthAlert, + FormGroup, InstitutionLogistration, PasswordField, RedirectLogistration, ThirdPartyAuthAlert, } from '../common-components'; import { getThirdPartyAuthContext } from '../common-components/data/actions'; import { - extendedProfileSelector, - fieldDescriptionSelector, - optionalFieldsSelector, - thirdPartyAuthContextSelector, + extendedProfileSelector, fieldDescriptionSelector, optionalFieldsSelector, thirdPartyAuthContextSelector, } from '../common-components/data/selectors'; import EnterpriseSSO from '../common-components/EnterpriseSSO'; import { - DEFAULT_STATE, LETTER_REGEX, NUMBER_REGEX, PENDING_STATE, REGISTER_PAGE, VALID_EMAIL_REGEX, VALID_NAME_REGEX, + DEFAULT_STATE, INVALID_NAME_REGEX, LETTER_REGEX, NUMBER_REGEX, PENDING_STATE, REGISTER_PAGE, VALID_EMAIL_REGEX, } from '../data/constants'; import { - getAllPossibleQueryParam, getTpaHint, getTpaProvider, setCookie, setSurveyCookie, + getAllPossibleQueryParams, getTpaHint, getTpaProvider, setCookie, setSurveyCookie, } from '../data/utils'; -import FormFieldRenderer from '../field-renderer'; -import CountryDropdown from './CountryDropdown'; +import ConfigurableRegistrationForm from './ConfigurableRegistrationForm'; import { + backupRegistrationFormBegin, clearUsernameSuggestions, fetchRealtimeValidations, registerNewUser, - resetRegistrationForm, - setRegistrationFormData, + setUserPipelineDataLoaded, } from './data/actions'; import { - COMMON_EMAIL_PROVIDERS, COUNTRY_CODE_KEY, COUNTRY_DISPLAY_KEY, DEFAULT_SERVICE_PROVIDER_DOMAINS, - DEFAULT_TOP_LEVEL_DOMAINS, FIELDS, FORM_SUBMISSION_ERROR, + COUNTRY_CODE_KEY, COUNTRY_DISPLAY_KEY, FORM_SUBMISSION_ERROR, } from './data/constants'; -import { - registrationErrorSelector, - registrationFormDataSelector, - registrationRequestSelector, - usernameSuggestionsSelector, - validationsSelector, -} from './data/selectors'; -import HonorCode from './HonorCode'; +import { registrationErrorSelector, validationsSelector } from './data/selectors'; +import { getSuggestionForInvalidEmail, validateCountryField, validateEmailAddress } from './data/utils'; import messages from './messages'; import RegistrationFailure from './RegistrationFailure'; -import TermsOfService from './TermsOfService'; -import UsernameField from './UsernameField'; -import { getLevenshteinSuggestion, getSuggestionForInvalidEmail } from './utils'; +import { EmailField, UsernameField } from './registrationFields'; +import ThirdPartyAuth from './ThirdPartyAuth'; -class RegistrationPage extends React.Component { - constructor(props, context) { - super(props, context); - this.handleOnClose = this.handleOnClose.bind(this); - this.countryList = getCountryList(getLocale()); - this.queryParams = getAllPossibleQueryParam(); - // TODO: Once we have tested it and ready for openedX we can remove this flag and make the code - // permanent part of Authn and remove extra code - this.showDynamicRegistrationFields = getConfig().ENABLE_DYNAMIC_REGISTRATION_FIELDS; - this.tpaHint = getTpaHint(); - const { registrationFormData } = this.props; - this.state = { - country: '', - email: registrationFormData.email, - name: registrationFormData.name, - password: registrationFormData.password, - username: registrationFormData.username, - marketingOptIn: registrationFormData.marketingOptIn, - errors: { - email: registrationFormData.errors.email, - name: registrationFormData.errors.name, - username: registrationFormData.errors.username, - password: registrationFormData.errors.password, - country: '', - }, - emailFieldBorderClass: registrationFormData.emailFieldBorderClass, - emailErrorSuggestion: registrationFormData.emailErrorSuggestion, - emailWarningSuggestion: registrationFormData.emailWarningSuggestion, - errorCode: null, - failureCount: 0, - startTime: Date.now(), - totalRegistrationTime: 0, - validatePassword: false, - values: {}, - focusedField: '', - }; - } +const emailRegex = new RegExp(VALID_EMAIL_REGEX, 'i'); +const urlRegex = new RegExp(INVALID_NAME_REGEX); - componentDidMount() { - sendPageEvent('login_and_registration', 'register'); - const payload = { ...this.queryParams }; - window.optimizely = window.optimizely || []; - window.optimizely.push({ - type: 'page', - pageName: 'authn_registration_page', - isActive: true, - }); +const RegistrationPage = (props) => { + const { + backedUpFormData, + backendCountryCode, + backendValidations, + fieldDescriptions, + handleInstitutionLogin, + intl, + institutionLogin, + optionalFields, + registrationErrorCode, + registrationResult, + shouldBackupState, + submitState, + thirdPartyAuthApiStatus, + thirdPartyAuthContext, + usernameSuggestions, + validationApiRateLimited, + // Actions + backupFormState, + setUserPipelineDetailsLoaded, + getRegistrationDataFromBackend, + userPipelineDataLoaded, + validateFromBackend, + } = props; - if (payload.save_for_later === 'true') { - sendTrackEvent('edx.bi.user.saveforlater.course.enroll.clicked', { category: 'save-for-later' }); - } - - if (this.tpaHint) { - payload.tpa_hint = this.tpaHint; - } - payload.is_register_page = true; - this.props.resetRegistrationForm(); - this.props.getThirdPartyAuthContext(payload); - } - - shouldComponentUpdate(nextProps) { - if (nextProps.registrationFormData && this.props.registrationFormData !== nextProps.registrationFormData) { - // Ensuring browser's autofill user credentials get filled and their state persists in the redux store. - const nextState = { - username: nextProps.registrationFormData.username || this.state.username, - password: nextProps.registrationFormData.password || this.state.password, - }; - // Do not set focused field's value from redux store to retain entered data in focused field. - let { focusedField } = this.state; - - // Exemption for country field's value as we need to set updated value from the store. - if (focusedField === 'country') { focusedField = ''; } - const { [focusedField]: _, ...registrationData } = { ...nextProps.registrationFormData, ...nextState }; - this.setState({ - ...registrationData, - }); - } - - if (this.props.usernameSuggestions.length > 0 && this.state.username === '') { - this.props.setRegistrationFormData({ - username: ' ', - }); - return false; - } - - if (this.props.validationDecisions !== nextProps.validationDecisions) { - if (nextProps.validationDecisions) { - const state = {}; - const errors = { ...this.state.errors, ...nextProps.validationDecisions }; - let validatePassword = false; - - if (errors.password) { - validatePassword = true; - } - - if (nextProps.registrationErrorCode) { - state.errorCode = nextProps.registrationErrorCode; - } - - let { - suggestedTldMessage, - suggestedTopLevelDomain, - suggestedSldMessage, - suggestedServiceLevelDomain, - } = this.state; - - if (errors.email) { - suggestedTldMessage = ''; - suggestedTopLevelDomain = ''; - suggestedSldMessage = ''; - suggestedServiceLevelDomain = ''; - } - - this.setState({ - ...state, - suggestedTldMessage, - suggestedTopLevelDomain, - suggestedSldMessage, - suggestedServiceLevelDomain, - validatePassword, - }); - - this.props.setRegistrationFormData({ - errors, - }, true); - } - return false; - } - - if (this.props.thirdPartyAuthContext.pipelineUserDetails !== nextProps.thirdPartyAuthContext.pipelineUserDetails) { - const { pipelineUserDetails } = nextProps.thirdPartyAuthContext; - const { registrationFormData } = this.props; - - // Added a conditional errors check to not fall back on pipelines data when a user explicitly edits the form. - this.props.setRegistrationFormData({ - name: registrationFormData.errors.name ? registrationFormData.name - : (registrationFormData.name || pipelineUserDetails.name || ''), - email: registrationFormData.errors.email ? registrationFormData.email - : (registrationFormData.email || pipelineUserDetails.email || ''), - username: registrationFormData.errors.username ? registrationFormData.username - : (registrationFormData.username || pipelineUserDetails.username || ''), - country: registrationFormData.country || nextProps.thirdPartyAuthContext.countryCode, - }); - return false; - } - - if (this.props.thirdPartyAuthContext.countryCode !== nextProps.thirdPartyAuthContext.countryCode) { - this.props.setRegistrationFormData({ - country: this.props.registrationFormData.country || nextProps.thirdPartyAuthContext.countryCode, - }); - return false; - } - - return true; - } - - onChangeHandler = (e) => { - const { name, value, checked } = e.target; - const { errors, values } = this.state; - if (e.target.type === 'checkbox') { - errors[name] = ''; - values[name] = checked; - } else { - values[name] = value; - } - const state = { errors, values }; - this.setState({ ...state }); + const countryList = useMemo(() => getCountryList(getLocale()), []); + const queryParams = useMemo(() => getAllPossibleQueryParams(), []); + const tpaHint = useMemo(() => getTpaHint(), []); + const flags = { + showConfigurableEdxFields: getConfig().SHOW_CONFIGURABLE_EDX_FIELDS, + showConfigurableRegistrationFields: getConfig().ENABLE_DYNAMIC_REGISTRATION_FIELDS, }; - handleSubmit = (e) => { - e.preventDefault(); + const [formFields, setFormFields] = useState({ ...backedUpFormData.formFields }); + const [configurableFormFields, setConfigurableFormFields] = useState({ ...backedUpFormData.configurableFormFields }); + const [errors, setErrors] = useState({ ...backedUpFormData.errors }); + const [emailSuggestion, setEmailSuggestion] = useState({ ...backedUpFormData.emailSuggestion }); - const { startTime } = this.state; - const totalRegistrationTime = (Date.now() - startTime) / 1000; - const dynamicFieldErrorMessages = {}; + const [errorCode, setErrorCode] = useState({ type: '', count: 0 }); + const [formStartTime, setFormStartTime] = useState(null); + const [focusedField, setFocusedField] = useState(null); - let payload = { - name: this.state.name, - username: this.state.username, - email: this.state.email, - is_authn_mfe: true, - }; + const { + providers, currentProvider, secondaryProviders, finishAuthUrl, + } = thirdPartyAuthContext; + const platformName = getConfig().SITE_NAME; - if (this.props.thirdPartyAuthContext.currentProvider) { - payload.social_auth_provider = this.props.thirdPartyAuthContext.currentProvider; - } else { - payload.password = this.state.password; - } - - if (this.showDynamicRegistrationFields) { - payload.extendedProfile = []; - Object.keys(this.props.fieldDescriptions).forEach((fieldName) => { - if (this.props.extendedProfile.includes(fieldName)) { - payload.extendedProfile.push({ fieldName, fieldValue: this.state.values[fieldName] }); - } else { - payload[fieldName] = this.state.values[fieldName]; - } - dynamicFieldErrorMessages[fieldName] = this.props.fieldDescriptions[fieldName].error_message; - }); - if ( - this.props.fieldDescriptions[FIELDS.HONOR_CODE] - && this.props.fieldDescriptions[FIELDS.HONOR_CODE].type === 'tos_and_honor_code' - ) { - payload[FIELDS.HONOR_CODE] = true; - } - } else { - payload.country = this.state.country; - payload.honor_code = true; - } - - if (!this.isFormValid(payload, dynamicFieldErrorMessages)) { - this.setState(prevState => ({ - errorCode: FORM_SUBMISSION_ERROR, - failureCount: prevState.failureCount + 1, - })); - return; - } - - if (getConfig().MARKETING_EMAILS_OPT_IN) { - payload.marketing_emails_opt_in = this.state.marketingOptIn; - } - - payload = snakeCaseObject(payload); - payload.totalRegistrationTime = totalRegistrationTime; - - // add query params to the payload - payload = { ...payload, ...this.queryParams }; - this.setState({ - totalRegistrationTime, - }, () => { - this.props.registerNewUser(payload); - }); - } - - handleOnBlur = (e) => { - let { name, value } = e.target; - this.setState({ - focusedField: '', - }); - - if (name === 'passwordValidation') { - name = 'password'; - value = this.state.password; - } - const payload = { - is_authn_mfe: true, - form_field_key: name, - email: this.state.email, - username: this.state.username, - password: this.state.password, - name: this.state.name, - honor_code: true, - country: this.state.country, - }; - this.validateInput(name, value, payload); - } - - handleOnChange = (e) => { - let { value } = e.target; - if (e.target.name === 'username') { - if (value.length > 30) { - return; - } - if (value.startsWith(' ')) { - value = value.substring(1); + /** + * Set the userPipelineDetails data in formFields for only first time + */ + useEffect(() => { + if (!userPipelineDataLoaded) { + const { pipelineUserDetails } = thirdPartyAuthContext; + if (pipelineUserDetails && Object.keys(pipelineUserDetails).length !== 0) { + setFormFields(prevState => ({ ...prevState, ...pipelineUserDetails })); + setUserPipelineDetailsLoaded(true); } } + }, [thirdPartyAuthContext, userPipelineDataLoaded, setUserPipelineDetailsLoaded]); - this.setState({ - [e.target.name]: value, - }); - } - - handleOnFocus = (e) => { - const fieldName = e.target.name; - this.setState({ - focusedField: fieldName, - }); - const { errors } = this.state; - errors[fieldName] = ''; - if (fieldName === 'username') { - this.props.clearUsernameSuggestions(); - } - if (fieldName === 'countryExpand') { - errors.country = ''; - } - if (fieldName === 'passwordValidation') { - errors.password = ''; + useEffect(() => { + if (!formStartTime) { + sendPageEvent('login_and_registration', 'register'); + const payload = { ...queryParams, is_register_page: true }; + if (tpaHint) { + payload.tpa_hint = tpaHint; + } + getRegistrationDataFromBackend(payload); + setFormStartTime(Date.now()); } + }, [formStartTime, getRegistrationDataFromBackend, queryParams, tpaHint]); - this.props.setRegistrationFormData({ - errors, - }); - } - - handleSuggestionClick = (e, suggestion) => { - const { errors } = this.state; - if (e.target.name === 'username') { - errors.username = ''; - this.props.setRegistrationFormData({ - username: suggestion, - errors, - }); - this.props.clearUsernameSuggestions(); - } else if (e.target.name === 'email') { - e.preventDefault(); - errors.email = ''; - this.props.setRegistrationFormData({ - email: suggestion, - emailErrorSuggestion: null, - emailWarningSuggestion: null, - emailFieldBorderClass: '', - errors, + /** + * Backup the registration form in redux when register page is toggled. + */ + useEffect(() => { + if (shouldBackupState) { + backupFormState({ + configurableFormFields: { ...configurableFormFields }, + formFields: { ...formFields }, + emailSuggestion: { ...emailSuggestion }, + errors: { ...errors }, }); } - } + }, [shouldBackupState, configurableFormFields, formFields, errors, emailSuggestion, backupFormState]); - handleUsernameSuggestionClose = () => { - this.props.setRegistrationFormData({ - username: '', - }); - this.props.clearUsernameSuggestions(); - } - - validateDynamicFields = (e) => { - const { intl } = this.props; - const { errors } = this.state; - const { name, value } = e.target; - if (!value.trim()) { - errors[name] = this.props.fieldDescriptions[name].error_message; + useEffect(() => { + if (backendValidations) { + setErrors(prevErrors => ({ ...prevErrors, ...backendValidations })); } - if (name === 'confirm_email' && value.length > 0 && this.state.email && value !== this.state.email) { - errors.confirm_email = intl.formatMessage(messages['email.do.not.match']); + }, [backendValidations]); + + useEffect(() => { + if (registrationErrorCode) { + setErrorCode(prevState => ({ type: registrationErrorCode, count: prevState.count + 1 })); } - this.setState({ errors }); - } + }, [registrationErrorCode]); - isFormValid(payload, dynamicFieldError) { - const { errors } = this.state; - let isValid = true; - Object.keys(payload).forEach(key => { - if (!payload[key]) { - errors[key] = (key in dynamicFieldError) ? dynamicFieldError[key] : this.props.intl.formatMessage(messages[`empty.${key}.field.error`]); - } - // Mark form invalid, if there was already a validation error for this key or we added empty field error - if (errors[key]) { - isValid = false; - } - }); - - const state = { ...payload, errors }; - this.props.setRegistrationFormData({ - ...state, - }); - return isValid; - } - - validateInput(fieldName, value, payload) { - let state = {}; - const { errors } = this.state; - const { intl, statusCode } = this.props; - const emailRegex = new RegExp(VALID_EMAIL_REGEX, 'i'); - const urlRegex = new RegExp(VALID_NAME_REGEX); - - switch (fieldName) { - case 'email': - if (!value) { - errors.email = intl.formatMessage(messages['empty.email.field.error']); - } else if (value.length <= 2) { - errors.email = intl.formatMessage(messages['email.invalid.format.error']); - } else { - let emailWarningSuggestion = null; - let emailErrorSuggestion = null; - - const [username, domainName] = value.split('@'); - - // Check if email address is invalid. If we have a suggestion for invalid email provide that along with - // error message. - if (!emailRegex.test(value)) { - errors.email = intl.formatMessage(messages['email.invalid.format.error']); - emailErrorSuggestion = getSuggestionForInvalidEmail(domainName, username); - } else { - let suggestion = null; - const hasMultipleSubdomains = value.match(/\./g).length > 1; - const [serviceLevelDomain, topLevelDomain] = domainName.split('.'); - const tldSuggestion = !DEFAULT_TOP_LEVEL_DOMAINS.includes(topLevelDomain); - const serviceSuggestion = getLevenshteinSuggestion(serviceLevelDomain, DEFAULT_SERVICE_PROVIDER_DOMAINS, 2); - - if (DEFAULT_SERVICE_PROVIDER_DOMAINS.includes(serviceSuggestion || serviceLevelDomain)) { - suggestion = `${username}@${serviceSuggestion || serviceLevelDomain}.com`; - } - - if (!hasMultipleSubdomains && tldSuggestion) { - emailErrorSuggestion = suggestion; - } else if (serviceSuggestion) { - emailWarningSuggestion = suggestion; - } else { - suggestion = getLevenshteinSuggestion(domainName, COMMON_EMAIL_PROVIDERS, 3); - if (suggestion) { - emailWarningSuggestion = `${username}@${suggestion}`; - } - } - - if (!hasMultipleSubdomains && tldSuggestion) { - errors.email = intl.formatMessage(messages['email.invalid.format.error']); - } else if (payload && statusCode !== 403) { - this.props.fetchRealtimeValidations(payload); - } else { - errors.email = ''; - } - if (this.state.values && this.state.values.confirm_email && value !== this.state.values.confirm_email) { - errors.confirm_email = intl.formatMessage(messages['email.do.not.match']); - } + useEffect(() => { + if (backendCountryCode !== '') { + const selectedCountry = countryList.find( + (country) => (country[COUNTRY_CODE_KEY].toLowerCase() === backendCountryCode.toLowerCase()), + ); + if (selectedCountry) { + setConfigurableFormFields(prevState => ( + { + ...prevState, + country: { + countryCode: selectedCountry[COUNTRY_CODE_KEY], displayValue: selectedCountry[COUNTRY_DISPLAY_KEY], + }, } - state = { - emailWarningSuggestion, - emailErrorSuggestion, - emailFieldBorderClass: emailWarningSuggestion ? 'yellow-border' : null, - }; - } - break; - case 'name': - if (!value.trim()) { - errors.name = intl.formatMessage(messages['empty.name.field.error']); - } else if (value && value.match(urlRegex)) { - errors.name = intl.formatMessage(messages['name.validation.message']); - } else { - errors.name = ''; - } - - if (!this.state.username.trim() && value) { - // fetch username suggestions based on the full name - this.props.fetchRealtimeValidations(payload); - } - break; - case 'username': - if (value === ' ' && this.props.usernameSuggestions.length > 0) { - errors.username = ''; - break; - } - if (!value || value.length <= 1 || value.length > 30) { - errors.username = intl.formatMessage(messages['username.validation.message']); - } else if (!value.match(/^[a-zA-Z0-9_-]*$/i)) { - errors.username = intl.formatMessage(messages['username.format.validation.message']); - } else if (payload && statusCode !== 403) { - this.props.fetchRealtimeValidations(payload); - } else { - errors.username = ''; - } - - if (this.state.validatePassword) { - this.props.fetchRealtimeValidations({ ...payload, form_field_key: 'password' }); - } - break; - case 'password': - errors.password = ''; - if (!value || !LETTER_REGEX.test(value) || !NUMBER_REGEX.test(value) || value.length < 8) { - errors.password = intl.formatMessage(messages['password.validation.message']); - } else if (payload && statusCode !== 403) { - this.props.fetchRealtimeValidations(payload); - } - break; - case 'country': - value = value.trim(); // eslint-disable-line no-param-reassign - if (value) { - const normalizedValue = value.toLowerCase(); - let selectedCountry = ( - this.countryList.find((o) => o[COUNTRY_DISPLAY_KEY].toLowerCase().trim() === normalizedValue)); - if (selectedCountry) { - value = selectedCountry[COUNTRY_CODE_KEY]; // eslint-disable-line no-param-reassign - errors.country = ''; - break; - } else { - // Handling a case here where user enters a valid country code that needs to be - // evaluated and set its value as a valid value. - selectedCountry = ( - this.countryList.find((o) => o[COUNTRY_CODE_KEY].toLowerCase().trim() === normalizedValue)); - if (selectedCountry) { - value = selectedCountry[COUNTRY_CODE_KEY]; // eslint-disable-line no-param-reassign - errors.country = ''; - break; - } - } - } - errors.country = intl.formatMessage(messages['empty.country.field.error']); - break; - default: - break; + )); + } } + }, [backendCountryCode, countryList]); - state = { - ...state, - [fieldName]: value, - }; - this.props.setRegistrationFormData({ - ...state, - errors, - }); - - return errors; - } - - handleOnClose() { - this.props.setRegistrationFormData({ - emailErrorSuggestion: null, - }); - } - - renderEmailFeedback() { - if (this.state.emailErrorSuggestion) { - return ( - - - {this.props.intl.formatMessage(messages['did.you.mean.alert.text'])}{' '} - { this.handleSuggestionClick(e, this.state.emailErrorSuggestion); }} - > - {this.state.emailErrorSuggestion} - ? - - - ); - } - if (this.state.emailWarningSuggestion) { - return ( - - {this.props.intl.formatMessage(messages['did.you.mean.alert.text'])}:{' '} - { this.handleSuggestionClick(e, this.state.emailWarningSuggestion); }} - > - {this.state.emailWarningSuggestion} - ? - - ); + /** + * We need to remove the placeholder from the field, adding a space will do that. + * This is needed because we are placing the username suggestions on top of the field. + */ + useEffect(() => { + if (usernameSuggestions.length && !formFields.username) { + setFormFields({ ...formFields, username: ' ' }); } + }, [usernameSuggestions, formFields]); - return null; - } - - renderThirdPartyAuth(providers, secondaryProviders, currentProvider, thirdPartyAuthApiStatus, intl) { - const isInstitutionAuthActive = !!secondaryProviders.length && !currentProvider; - const isSocialAuthActive = !!providers.length && !currentProvider; - const isEnterpriseLoginDisabled = getConfig().DISABLE_ENTERPRISE_LOGIN; - - return ( - <> - {((isEnterpriseLoginDisabled && isInstitutionAuthActive) || isSocialAuthActive) && ( -
- {intl.formatMessage(messages['registration.other.options.heading'])} -
- )} - - {thirdPartyAuthApiStatus === PENDING_STATE ? ( - - ) : ( - <> - {(isEnterpriseLoginDisabled && isInstitutionAuthActive) && ( - - )} - {isSocialAuthActive && ( -
- -
- )} - - )} - - ); - } - - renderForm(currentProvider, - providers, - secondaryProviders, - thirdPartyAuthApiStatus, - finishAuthUrl, - submitState, - intl) { - if (this.props.institutionLogin) { - return ( - - ); - } - - if (this.props.registrationResult.success) { + useEffect(() => { + if (registrationResult.success) { + // TODO: Do we still need this cookie? setSurveyCookie('register'); setCookie(getConfig().REGISTER_CONVERSION_COOKIE_NAME, true); setCookie('authn-returning-user'); @@ -663,200 +187,364 @@ class RegistrationPage extends React.Component { window.optimizely.push({ type: 'event', eventName: 'authn-register-conversion', - tags: { - value: this.state.totalRegistrationTime, - }, + }); + } + }, [registrationResult]); + + const validateInput = (fieldName, value, payload, shouldValidateFromBackend, setError = true) => { + let fieldError = ''; + let confirmEmailError = ''; // This is to handle the use case where the form contains "confirm email" field + let countryFieldCode = ''; + + switch (fieldName) { + case 'name': + if (!value.trim()) { + fieldError = intl.formatMessage(messages['empty.name.field.error']); + } else if (value && value.match(urlRegex)) { + fieldError = intl.formatMessage(messages['name.validation.message']); + } else if (value && !payload.username.trim() && shouldValidateFromBackend) { + validateFromBackend(payload); + } + break; + case 'email': + if (!value) { + fieldError = intl.formatMessage(messages['empty.email.field.error']); + } else if (value.length <= 2) { + fieldError = intl.formatMessage(messages['email.invalid.format.error']); + } else { + const [username, domainName] = value.split('@'); + // Check if email address is invalid. If we have a suggestion for invalid email + // provide that along with the error message. + if (!emailRegex.test(value)) { + fieldError = intl.formatMessage(messages['email.invalid.format.error']); + setEmailSuggestion({ + suggestion: getSuggestionForInvalidEmail(domainName, username), + type: 'error', + }); + } else { + const response = validateEmailAddress(value, username, domainName); + if (response.hasError) { + fieldError = intl.formatMessage(messages['email.invalid.format.error']); + delete response.hasError; + } else if (shouldValidateFromBackend) { + validateFromBackend(payload); + } + setEmailSuggestion({ ...response }); + + if (configurableFormFields.confirm_email && value !== configurableFormFields.confirm_email) { + confirmEmailError = intl.formatMessage(messages['email.do.not.match']); + } + } + } + break; + case 'username': + if (!value || value.length <= 1 || value.length > 30) { + fieldError = intl.formatMessage(messages['username.validation.message']); + } else if (!value.match(/^[a-zA-Z\d_-]*$/i)) { + fieldError = intl.formatMessage(messages['username.format.validation.message']); + } else if (shouldValidateFromBackend) { + validateFromBackend(payload); + } + break; + case 'password': + if (!value || !LETTER_REGEX.test(value) || !NUMBER_REGEX.test(value) || value.length < 8) { + fieldError = intl.formatMessage(messages['password.validation.message']); + } else if (shouldValidateFromBackend) { + validateFromBackend(payload); + } + break; + case 'country': + if (flags.showConfigurableEdxFields || flags.showConfigurableRegistrationFields) { + const { countryCode, displayValue, error } = validateCountryField( + value.displayValue.trim(), countryList, intl.formatMessage(messages['empty.country.field.error']), + ); + fieldError = error; + countryFieldCode = countryCode; + setConfigurableFormFields({ ...configurableFormFields, country: { countryCode, displayValue } }); + } + break; + default: + if (flags.showConfigurableRegistrationFields) { + if (!value && fieldDescriptions[fieldName].error_message) { + fieldError = fieldDescriptions[fieldName].error_message; + } else if (fieldName === 'confirm_email' && formFields.email && value !== formFields.email) { + fieldError = intl.formatMessage(messages['email.do.not.match']); + } + } + break; + } + if (setError) { + setErrors({ + ...errors, + confirm_email: flags.showConfigurableRegistrationFields ? confirmEmailError : '', + [fieldName]: fieldError, + }); + } + return { fieldError, countryFieldCode }; + }; + + const isFormValid = (payload, focusedFieldError) => { + const fieldErrors = { ...errors }; + let isValid = !focusedFieldError; + Object.keys(payload).forEach(key => { + if (!payload[key]) { + fieldErrors[key] = intl.formatMessage(messages[`empty.${key}.field.error`]); + } + if (fieldErrors[key]) { + isValid = false; + } + }); + + if (flags.showConfigurableEdxFields) { + if (!configurableFormFields.country.displayValue) { + fieldErrors.country = intl.formatMessage(messages['empty.country.field.error']); + } + if (fieldErrors.country) { + isValid = false; + } + } + + if (flags.showConfigurableRegistrationFields) { + Object.keys(fieldDescriptions).forEach(key => { + if (key === 'country' && !configurableFormFields.country.displayValue) { + fieldErrors[key] = intl.formatMessage(messages['empty.country.field.error']); + } else if (!configurableFormFields[key]) { + fieldErrors[key] = fieldDescriptions[key].error_message; + } + if (fieldErrors[key]) { + isValid = false; + } }); } - const honorCode = []; - const formFields = this.showDynamicRegistrationFields ? ( - Object.keys(this.props.fieldDescriptions).map((fieldName) => { - const fieldData = this.props.fieldDescriptions[fieldName]; - switch (fieldData.name) { - case FIELDS.COUNTRY: - return ( - - this.setState(prevState => ({ values: { ...prevState.values, country: value } })) - } - errorCode={this.state.errorCode} - /> - - ); - case FIELDS.HONOR_CODE: - honorCode.push( - - - , - ); - return null; - case FIELDS.TERMS_OF_SERVICE: - honorCode.push( - - - , - ); - return null; - default: - return ( - - - - ); - } - }) - ) : null; + if (focusedField) { + fieldErrors[focusedField] = focusedFieldError; + } + setErrors({ ...fieldErrors }); + return isValid; + }; + const handleSuggestionClick = (event, fieldName, suggestion = '') => { + event.preventDefault(); + setErrors({ ...errors, [fieldName]: '' }); + switch (fieldName) { + case 'email': + setFormFields({ ...formFields, email: emailSuggestion.suggestion }); + setEmailSuggestion({ suggestion: '', type: '' }); + break; + case 'username': + setFormFields({ ...formFields, username: suggestion }); + props.resetUsernameSuggestions(); + break; + default: + break; + } + }; + + const handleEmailSuggestionClosed = () => setEmailSuggestion({ suggestion: '', type: '' }); + const handleUsernameSuggestionClosed = () => props.resetUsernameSuggestions(); + + const handleOnChange = (event) => { + let value = event.target.type === 'checkbox' ? event.target.checked : event.target.value; + + if (event.target.name === 'username') { + if (value.length > 30) { + return; + } + if (value.startsWith(' ')) { + value = value.trim(); + } + } + + setFormFields({ ...formFields, [event.target.name]: value }); + }; + + const handleOnBlur = (event) => { + const { name, value } = event.target; + const payload = { + name: formFields.name, + email: formFields.email, + username: formFields.username, + password: formFields.password, + form_field_key: name, + }; + + setFocusedField(null); + validateInput(name, name === 'password' ? formFields.password : value, payload, !validationApiRateLimited); + }; + + const handleOnFocus = (event) => { + const { name, value } = event.target; + setErrors({ ...errors, [name]: '' }); + // Since we are removing the form errors from the focused field, we will + // need to rerun the validation for focused field on form submission. + setFocusedField(name); + + if (name === 'username') { + props.resetUsernameSuggestions(); + // If we added a space character to username field to display the suggestion + // remove it before user enters the input. This is to ensure user doesn't + // have a space prefixed to the username. + if (value === ' ') { + setFormFields({ ...formFields, [name]: '' }); + } + } + }; + + const handleSubmit = (e) => { + e.preventDefault(); + + const totalRegistrationTime = (Date.now() - formStartTime) / 1000; + let payload = { ...formFields }; + + if (currentProvider) { + delete payload.password; + payload.social_auth_provider = currentProvider; + } + + const { focusedFieldError, countryFieldCode } = focusedField ? ( + validateInput( + focusedField, + (focusedField in fieldDescriptions || focusedField === 'country') ? ( + configurableFormFields[focusedField] + ) : formFields[focusedField], + payload, + false, + false, + ) + ) : ''; + + if (!isFormValid(payload, focusedFieldError)) { + setErrorCode(prevState => ({ type: FORM_SUBMISSION_ERROR, count: prevState.count + 1 })); + return; + } + + payload.extendedProfile = []; + Object.keys(configurableFormFields).forEach((fieldName) => { + if (props.extendedProfile.includes(fieldName)) { + payload.extendedProfile.push({ fieldName, fieldValue: configurableFormFields[fieldName] }); + } else if (fieldName === 'country') { + payload[fieldName] = focusedField === 'country' ? countryFieldCode : configurableFormFields[fieldName].countryCode; + } else { + payload[fieldName] = configurableFormFields[fieldName]; + } + }); + + payload = snakeCaseObject(payload); + payload.totalRegistrationTime = totalRegistrationTime; + + // add query params to the payload + payload = { ...payload, ...queryParams }; + props.registerNewUser(payload); + }; + + const renderForm = () => { + if (institutionLogin) { + return ( + + ); + } return ( <> - {intl.formatMessage(messages['register.page.title'], - { siteName: getConfig().SITE_NAME })} - + {intl.formatMessage(messages['register.page.title'], { siteName: getConfig().SITE_NAME })}
- {this.state.errorCode ? ( - - ) : null} - {currentProvider && ( - <> - -

{intl.formatMessage(messages['registration.using.tpa.form.heading'])}

- - )} + +
- handleSuggestionClick(e, 'email')} + handleOnClose={handleEmailSuggestionClosed} + emailSuggestion={emailSuggestion} + errorMessage={errors.email} helpText={[intl.formatMessage(messages['help.text.email'])]} floatingLabel={intl.formatMessage(messages['registration.email.label'])} - borderClass={this.state.emailFieldBorderClass} - > - {this.renderEmailFeedback()} - - + /> - {!currentProvider && ( )} - {!(this.showDynamicRegistrationFields) - && ( - - )} - {formFields} - {(getConfig().MARKETING_EMAILS_OPT_IN) - && ( - this.props.setRegistrationFormData({ - marketingOptIn: e.target.checked, - })} - > - {intl.formatMessage(messages['registration.opt.in.label'], { siteName: getConfig().SITE_NAME })} - - )} - {!(this.showDynamicRegistrationFields) ? ( - - ) :
{honorCode}
} + {getConfig().MARKETING_EMAILS_OPT_IN + && ( + + {intl.formatMessage(messages['registration.opt.in.label'], { siteName: getConfig().SITE_NAME })} + + )} + e.preventDefault()} /> - {this.renderThirdPartyAuth(providers, - secondaryProviders, - currentProvider, - thirdPartyAuthApiStatus, - intl)} +
); - } + }; - render() { - const { intl, submitState, thirdPartyAuthApiStatus } = this.props; - const { - currentProvider, finishAuthUrl, providers, secondaryProviders, - } = this.props.thirdPartyAuthContext; - - if (this.tpaHint) { - if (thirdPartyAuthApiStatus === PENDING_STATE) { - return ; - } - const { provider, skipHintedLogin } = getTpaProvider(this.tpaHint, providers, secondaryProviders); - if (skipHintedLogin) { - window.location.href = getConfig().LMS_BASE_URL + provider.registerUrl; - return null; - } - return provider ? () - : this.renderForm( - currentProvider, - providers, - secondaryProviders, - thirdPartyAuthApiStatus, - finishAuthUrl, - submitState, - intl, - ); + if (tpaHint) { + if (thirdPartyAuthApiStatus === PENDING_STATE) { + return ; } - return this.renderForm( - currentProvider, - providers, - secondaryProviders, - thirdPartyAuthApiStatus, - finishAuthUrl, - submitState, - intl, - ); + const { provider, skipHintedLogin } = getTpaProvider(tpaHint, providers, secondaryProviders); + if (skipHintedLogin) { + window.location.href = getConfig().LMS_BASE_URL + provider.registerUrl; + return null; + } + return provider ? : renderForm(); } -} + return ( + renderForm() + ); +}; -RegistrationPage.defaultProps = { - extendedProfile: [], - fieldDescriptions: {}, - optionalFields: {}, - registrationResult: null, - registerNewUser: null, - registrationErrorCode: null, - submitState: DEFAULT_STATE, - thirdPartyAuthApiStatus: 'pending', - thirdPartyAuthContext: { - currentProvider: null, - finishAuthUrl: null, - countryCode: null, - providers: [], - secondaryProviders: [], - pipelineUserDetails: null, - }, - registrationFormData: { - country: '', - email: '', - name: '', - password: '', - username: '', - marketingOptIn: true, - errors: { - email: '', - name: '', - username: '', - password: '', - country: '', - }, - emailFieldBorderClass: '', - emailErrorSuggestion: null, - emailWarningSuggestion: null, - }, - validationDecisions: null, - statusCode: null, - usernameSuggestions: [], +const mapStateToProps = state => { + const registerPageState = state.register; + return { + backedUpFormData: registerPageState.registrationFormData, + backendCountryCode: registerPageState.backendCountryCode, + backendValidations: validationsSelector(state), + fieldDescriptions: fieldDescriptionSelector(state), + extendedProfile: extendedProfileSelector(state), + optionalFields: optionalFieldsSelector(state), + registrationErrorCode: registrationErrorSelector(state), + registrationResult: registerPageState.registrationResult, + shouldBackupState: registerPageState.shouldBackupState, + userPipelineDataLoaded: registerPageState.userPipelineDataLoaded, + submitState: registerPageState.submitState, + thirdPartyAuthApiStatus: state.commonComponents.thirdPartyAuthApiStatus, + thirdPartyAuthContext: thirdPartyAuthContextSelector(state), + validationApiRateLimited: registerPageState.validationApiRateLimited, + usernameSuggestions: registerPageState.usernameSuggestions, + }; }; RegistrationPage.propTypes = { + backedUpFormData: PropTypes.shape({ + configurableFormFields: PropTypes.shape({}), + formFields: PropTypes.shape({}), + errors: PropTypes.shape({}), + emailSuggestion: PropTypes.shape({}), + }), + backendCountryCode: PropTypes.string, + backendValidations: PropTypes.shape({ + name: PropTypes.string, + email: PropTypes.string, + username: PropTypes.string, + password: PropTypes.string, + }), extendedProfile: PropTypes.arrayOf(PropTypes.string), fieldDescriptions: PropTypes.shape({}), - optionalFields: PropTypes.shape({}), + institutionLogin: PropTypes.bool.isRequired, intl: intlShape.isRequired, - getThirdPartyAuthContext: PropTypes.func.isRequired, - registerNewUser: PropTypes.func, - resetRegistrationForm: PropTypes.func.isRequired, - setRegistrationFormData: PropTypes.func.isRequired, + optionalFields: PropTypes.shape({}), + registrationErrorCode: PropTypes.string, registrationResult: PropTypes.shape({ redirectUrl: PropTypes.string, success: PropTypes.bool, }), - registrationErrorCode: PropTypes.string, + shouldBackupState: PropTypes.bool, submitState: PropTypes.string, thirdPartyAuthApiStatus: PropTypes.string, - registrationFormData: PropTypes.shape({ - country: PropTypes.string, - email: PropTypes.string, - name: PropTypes.string, - password: PropTypes.string, - username: PropTypes.string, - marketingOptIn: PropTypes.bool, - errors: PropTypes.shape({ - email: PropTypes.string, - name: PropTypes.string, - username: PropTypes.string, - password: PropTypes.string, - country: PropTypes.string, - }), - emailFieldBorderClass: PropTypes.string, - emailErrorSuggestion: PropTypes.string, - emailWarningSuggestion: PropTypes.string, - }), thirdPartyAuthContext: PropTypes.shape({ currentProvider: PropTypes.string, platformName: PropTypes.string, @@ -1006,48 +648,63 @@ RegistrationPage.propTypes = { username: PropTypes.string, }), }), - fetchRealtimeValidations: PropTypes.func.isRequired, - validationDecisions: PropTypes.shape({ - country: PropTypes.string, - email: PropTypes.string, - name: PropTypes.string, - password: PropTypes.string, - username: PropTypes.string, - }), - clearUsernameSuggestions: PropTypes.func.isRequired, - statusCode: PropTypes.number, - usernameSuggestions: PropTypes.arrayOf(string), - institutionLogin: PropTypes.bool.isRequired, + usernameSuggestions: PropTypes.arrayOf(PropTypes.string), + userPipelineDataLoaded: PropTypes.bool, + validationApiRateLimited: PropTypes.bool, + // Actions + backupFormState: PropTypes.func.isRequired, + getRegistrationDataFromBackend: PropTypes.func.isRequired, handleInstitutionLogin: PropTypes.func.isRequired, + registerNewUser: PropTypes.func.isRequired, + resetUsernameSuggestions: PropTypes.func.isRequired, + setUserPipelineDetailsLoaded: PropTypes.func.isRequired, + validateFromBackend: PropTypes.func.isRequired, }; -const mapStateToProps = state => { - const registrationResult = registrationRequestSelector(state); - const thirdPartyAuthContext = thirdPartyAuthContextSelector(state); - return { - registrationErrorCode: registrationErrorSelector(state), - submitState: state.register.submitState, - thirdPartyAuthApiStatus: state.commonComponents.thirdPartyAuthApiStatus, - registrationResult, - thirdPartyAuthContext, - validationDecisions: validationsSelector(state), - statusCode: state.register.statusCode, - usernameSuggestions: usernameSuggestionsSelector(state), - registrationFormData: registrationFormDataSelector(state), - fieldDescriptions: fieldDescriptionSelector(state), - optionalFields: optionalFieldsSelector(state), - extendedProfile: extendedProfileSelector(state), - }; +RegistrationPage.defaultProps = { + backedUpFormData: { + configurableFormFields: {}, + formFields: { + name: '', email: '', username: '', password: '', marketingEmailsOptIn: true, + }, + errors: { + name: '', email: '', username: '', password: '', + }, + emailSuggestion: { + suggestion: '', type: '', + }, + }, + backendCountryCode: '', + backendValidations: null, + extendedProfile: [], + fieldDescriptions: {}, + optionalFields: {}, + registrationErrorCode: '', + registrationResult: null, + shouldBackupState: false, + submitState: DEFAULT_STATE, + thirdPartyAuthApiStatus: PENDING_STATE, + thirdPartyAuthContext: { + currentProvider: null, + finishAuthUrl: null, + countryCode: null, + providers: [], + secondaryProviders: [], + pipelineUserDetails: null, + }, + usernameSuggestions: [], + userPipelineDataLoaded: false, + validationApiRateLimited: false, }; export default connect( mapStateToProps, { - clearUsernameSuggestions, - getThirdPartyAuthContext, - fetchRealtimeValidations, + backupFormState: backupRegistrationFormBegin, + getRegistrationDataFromBackend: getThirdPartyAuthContext, + resetUsernameSuggestions: clearUsernameSuggestions, + validateFromBackend: fetchRealtimeValidations, registerNewUser, - resetRegistrationForm, - setRegistrationFormData, + setUserPipelineDetailsLoaded: setUserPipelineDataLoaded, }, )(injectIntl(RegistrationPage)); diff --git a/src/register/ThirdPartyAuth.jsx b/src/register/ThirdPartyAuth.jsx new file mode 100644 index 00000000..52814caa --- /dev/null +++ b/src/register/ThirdPartyAuth.jsx @@ -0,0 +1,73 @@ +import React from 'react'; + +import { getConfig } from '@edx/frontend-platform'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import PropTypes from 'prop-types'; +import Skeleton from 'react-loading-skeleton'; + +import { + RenderInstitutionButton, + SocialAuthProviders, +} from '../common-components'; +import { + PENDING_STATE, REGISTER_PAGE, +} from '../data/constants'; +import messages from './messages'; + +/** + * This component renders the Single sign-on (SSO) buttons for the providers passed. + * */ +const ThirdPartyAuth = (props) => { + const { + providers, secondaryProviders, currentProvider, handleInstitutionLogin, thirdPartyAuthApiStatus, intl, + } = props; + const isInstitutionAuthActive = !!secondaryProviders.length && !currentProvider; + const isSocialAuthActive = !!providers.length && !currentProvider; + const isEnterpriseLoginDisabled = getConfig().DISABLE_ENTERPRISE_LOGIN; + + return ( + <> + {((isEnterpriseLoginDisabled && isInstitutionAuthActive) || isSocialAuthActive) && ( +
+ {intl.formatMessage(messages['registration.other.options.heading'])} +
+ )} + + {thirdPartyAuthApiStatus === PENDING_STATE ? ( + + ) : ( + <> + {(isEnterpriseLoginDisabled && isInstitutionAuthActive) && ( + + )} + {isSocialAuthActive && ( +
+ +
+ )} + + )} + + ); +}; + +ThirdPartyAuth.defaultProps = { + currentProvider: null, + providers: [], + secondaryProviders: [], + thirdPartyAuthApiStatus: 'pending', +}; + +ThirdPartyAuth.propTypes = { + currentProvider: PropTypes.string, + handleInstitutionLogin: PropTypes.func.isRequired, + intl: intlShape.isRequired, + providers: PropTypes.arrayOf(PropTypes.any), + secondaryProviders: PropTypes.arrayOf(PropTypes.any), + thirdPartyAuthApiStatus: PropTypes.string, +}; + +export default injectIntl(ThirdPartyAuth); diff --git a/src/register/data/actions.js b/src/register/data/actions.js index 143c1543..84e06d9c 100644 --- a/src/register/data/actions.js +++ b/src/register/data/actions.js @@ -1,15 +1,39 @@ import { AsyncActionType } from '../../data/utils'; -export const REGISTER_NEW_USER = new AsyncActionType('REGISTRATION', 'REGISTER_NEW_USER'); +export const BACKUP_REGISTRATION_DATA = new AsyncActionType('REGISTRATION', 'BACKUP_REGISTRATION_DATA'); export const REGISTER_FORM_VALIDATIONS = new AsyncActionType('REGISTRATION', 'GET_FORM_VALIDATIONS'); -export const REGISTRATION_FORM = new AsyncActionType('REGISTRATION', 'REGISTRATION_FORM'); +export const REGISTER_NEW_USER = new AsyncActionType('REGISTRATION', 'REGISTER_NEW_USER'); export const REGISTER_CLEAR_USERNAME_SUGGESTIONS = 'REGISTRATION_CLEAR_USERNAME_SUGGESTIONS'; -export const REGISTER_PERSIST_FORM_DATA = 'REGISTER_PERSIST_FORM_DATA'; export const REGISTER_SET_COUNTRY_CODE = 'REGISTER_SET_COUNTRY_CODE'; +export const REGISTER_SET_USER_PIPELINE_DATA_LOADED = 'REGISTER_SET_USER_PIPELINE_DATA_LOADED'; -// Reset Form -export const resetRegistrationForm = () => ({ - type: REGISTRATION_FORM.RESET, +// Backup registration form +export const backupRegistrationForm = () => ({ + type: BACKUP_REGISTRATION_DATA.BASE, +}); + +export const backupRegistrationFormBegin = (data) => ({ + type: BACKUP_REGISTRATION_DATA.BEGIN, + payload: { ...data }, +}); + +// Validate fields from the backend +export const fetchRealtimeValidations = (formPayload) => ({ + type: REGISTER_FORM_VALIDATIONS.BASE, + payload: { formPayload }, +}); + +export const fetchRealtimeValidationsBegin = () => ({ + type: REGISTER_FORM_VALIDATIONS.BEGIN, +}); + +export const fetchRealtimeValidationsSuccess = (validations) => ({ + type: REGISTER_FORM_VALIDATIONS.SUCCESS, + payload: { validations }, +}); + +export const fetchRealtimeValidationsFailure = () => ({ + type: REGISTER_FORM_VALIDATIONS.FAILURE, }); // Register @@ -33,35 +57,16 @@ export const registerNewUserFailure = (error) => ({ payload: { ...error }, }); -// Realtime Field validations -export const fetchRealtimeValidations = (formPayload) => ({ - type: REGISTER_FORM_VALIDATIONS.BASE, - payload: { formPayload }, -}); - -export const fetchRealtimeValidationsBegin = () => ({ - type: REGISTER_FORM_VALIDATIONS.BEGIN, -}); - -export const fetchRealtimeValidationsSuccess = (validations) => ({ - type: REGISTER_FORM_VALIDATIONS.SUCCESS, - payload: { validations }, -}); - -export const fetchRealtimeValidationsFailure = () => ({ - type: REGISTER_FORM_VALIDATIONS.FAILURE, -}); - export const clearUsernameSuggestions = () => ({ type: REGISTER_CLEAR_USERNAME_SUGGESTIONS, }); -export const setRegistrationFormData = (formData, clearRegistrationError = false) => ({ - type: REGISTER_PERSIST_FORM_DATA, - payload: { formData, clearRegistrationError }, -}); - export const setCountryFromThirdPartyAuthContext = (countryCode) => ({ type: REGISTER_SET_COUNTRY_CODE, payload: { countryCode }, }); + +export const setUserPipelineDataLoaded = (value) => ({ + type: REGISTER_SET_USER_PIPELINE_DATA_LOADED, + payload: { value }, +}); diff --git a/src/register/data/reducers.js b/src/register/data/reducers.js index 9eafc31d..68cd6f46 100644 --- a/src/register/data/reducers.js +++ b/src/register/data/reducers.js @@ -3,49 +3,50 @@ import { PENDING_STATE, } from '../../data/constants'; import { + BACKUP_REGISTRATION_DATA, REGISTER_CLEAR_USERNAME_SUGGESTIONS, REGISTER_FORM_VALIDATIONS, REGISTER_NEW_USER, - REGISTER_PERSIST_FORM_DATA, REGISTER_SET_COUNTRY_CODE, - REGISTRATION_FORM, + REGISTER_SET_COUNTRY_CODE, REGISTER_SET_USER_PIPELINE_DATA_LOADED, } from './actions'; export const defaultState = { + backendCountryCode: '', registrationError: {}, registrationResult: {}, registrationFormData: { - country: '', - email: '', - name: '', - password: '', - username: '', - marketingOptIn: true, - errors: { - email: '', - name: '', - username: '', - password: '', - country: '', + configurableFormFields: {}, + formFields: { + name: '', email: '', username: '', password: '', marketingEmailsOptIn: true, + }, + emailSuggestion: { + suggestion: '', type: '', + }, + errors: { + name: '', email: '', username: '', password: '', }, - emailFieldBorderClass: '', - emailErrorSuggestion: null, - emailWarningSuggestion: null, }, validations: null, - statusCode: null, + submitState: DEFAULT_STATE, + userPipelineDataLoaded: false, usernameSuggestions: [], - extendedProfile: [], - fieldDescriptions: {}, - formRenderState: DEFAULT_STATE, + validationApiRateLimited: false, + shouldBackupState: false, }; const reducer = (state = defaultState, action) => { switch (action.type) { - case REGISTRATION_FORM.RESET: + case BACKUP_REGISTRATION_DATA.BASE: + return { + ...state, + shouldBackupState: true, + }; + case BACKUP_REGISTRATION_DATA.BEGIN: return { ...defaultState, - registrationFormData: state.registrationFormData, usernameSuggestions: state.usernameSuggestions, + registrationFormData: { ...action.payload }, + userPipelineDataLoaded: state.userPipelineDataLoaded, }; case REGISTER_NEW_USER.BEGIN: return { @@ -69,22 +70,18 @@ const reducer = (state = defaultState, action) => { usernameSuggestions: usernameSuggestions || state.usernameSuggestions, }; } - case REGISTER_FORM_VALIDATIONS.BEGIN: - return { - ...state, - }; case REGISTER_FORM_VALIDATIONS.SUCCESS: { - const { usernameSuggestions } = action.payload.validations; + const { usernameSuggestions, ...validationWithoutUsernameSuggestions } = action.payload.validations; return { ...state, - validations: action.payload.validations, + validations: validationWithoutUsernameSuggestions, usernameSuggestions: usernameSuggestions || state.usernameSuggestions, }; } case REGISTER_FORM_VALIDATIONS.FAILURE: return { ...state, - statusCode: 403, + validationApiRateLimited: true, validations: null, }; case REGISTER_CLEAR_USERNAME_SUGGESTIONS: @@ -92,33 +89,28 @@ const reducer = (state = defaultState, action) => { ...state, usernameSuggestions: [], }; - case REGISTER_PERSIST_FORM_DATA: { - const { formData, clearRegistrationError } = action.payload; - return { - ...state, - registrationError: clearRegistrationError ? {} : state.registrationError, - registrationFormData: { - ...state.registrationFormData, - ...formData, - }, - }; - } case REGISTER_SET_COUNTRY_CODE: { const { countryCode } = action.payload; - if (state.registrationFormData.country === '') { + if (!state.registrationFormData.configurableFormFields.country) { return { ...state, - registrationFormData: { - ...state.registrationFormData, - country: countryCode, - errors: { ...state.registrationFormData.errors, country: '' }, - }, + backendCountryCode: countryCode, }; } return state; } + case REGISTER_SET_USER_PIPELINE_DATA_LOADED: { + const { value } = action.payload; + return { + ...state, + userPipelineDataLoaded: value, + }; + } default: - return state; + return { + ...state, + shouldBackupState: false, + }; } }; diff --git a/src/register/data/selectors.js b/src/register/data/selectors.js index 2233b80e..39148185 100644 --- a/src/register/data/selectors.js +++ b/src/register/data/selectors.js @@ -4,11 +4,6 @@ export const storeName = 'register'; export const registerSelector = state => ({ ...state[storeName] }); -export const registrationRequestSelector = createSelector( - registerSelector, - register => register.registrationResult, -); - export const registrationErrorSelector = createSelector( registerSelector, register => register.registrationError.errorCode, @@ -36,13 +31,3 @@ export const validationsSelector = createSelector( return null; }, ); - -export const usernameSuggestionsSelector = createSelector( - registerSelector, - register => register.usernameSuggestions, -); - -export const registrationFormDataSelector = createSelector( - registerSelector, - register => register.registrationFormData, -); diff --git a/src/register/data/tests/reducers.test.js b/src/register/data/tests/reducers.test.js index c86390e5..57e97b5b 100644 --- a/src/register/data/tests/reducers.test.js +++ b/src/register/data/tests/reducers.test.js @@ -1,254 +1,106 @@ import { DEFAULT_STATE } from '../../../data/constants'; import { + BACKUP_REGISTRATION_DATA, REGISTER_CLEAR_USERNAME_SUGGESTIONS, REGISTER_FORM_VALIDATIONS, REGISTER_NEW_USER, - REGISTER_PERSIST_FORM_DATA, REGISTER_SET_COUNTRY_CODE, - REGISTRATION_FORM, } from '../actions'; import reducer from '../reducers'; -describe('register reducer', () => { - it('should return the initial state', () => { - expect(reducer(undefined, {})).toEqual( - { - registrationError: {}, - registrationResult: {}, - registrationFormData: { - country: '', - email: '', - name: '', - password: '', - username: '', - marketingOptIn: true, - errors: { - email: '', - name: '', - username: '', - password: '', - country: '', - }, - emailFieldBorderClass: '', - emailErrorSuggestion: null, - emailWarningSuggestion: null, - }, - validations: null, - statusCode: null, - usernameSuggestions: [], - extendedProfile: [], - fieldDescriptions: {}, - formRenderState: DEFAULT_STATE, +describe('Registration Reducer Tests', () => { + const defaultState = { + backendCountryCode: '', + registrationError: {}, + registrationResult: {}, + registrationFormData: { + configurableFormFields: {}, + formFields: { + name: '', email: '', username: '', password: '', marketingEmailsOptIn: true, }, - ); + emailSuggestion: { + suggestion: '', type: '', + }, + errors: { + name: '', email: '', username: '', password: '', + }, + }, + validations: null, + submitState: DEFAULT_STATE, + userPipelineDataLoaded: false, + usernameSuggestions: [], + validationApiRateLimited: false, + shouldBackupState: false, + }; + + it('should return the initial state', () => { + expect(reducer(undefined, {})).toEqual(defaultState); }); - it('should set username suggestions upon validation failed case', () => { - const state = { - usernameSuggestions: [], - registrationFormData: { - country: '', - email: '', + it('should set username suggestions returned by the backend validations', () => { + const validations = { + usernameSuggestions: ['test12'], + validationDecisions: { name: '', - password: '', - username: '', - marketingOptIn: true, - errors: { - email: '', - name: '', - username: '', - password: '', - country: '', - }, - emailFieldBorderClass: '', - emailErrorSuggestion: null, - emailWarningSuggestion: null, }, }; - const validations = { usernameSuggestions: ['test12'], validationDecisions: {} }; + const { usernameSuggestions, ...validationWithoutUsernameSuggestions } = validations; const action = { type: REGISTER_FORM_VALIDATIONS.SUCCESS, payload: { validations }, }; - expect( - reducer(state, action), - ).toEqual( + expect(reducer(defaultState, action)).toEqual( { - validations, - registrationFormData: { - country: '', - email: '', - name: '', - password: '', - username: '', - marketingOptIn: true, - errors: { - email: '', - name: '', - username: '', - password: '', - country: '', - }, - emailFieldBorderClass: '', - emailErrorSuggestion: null, - emailWarningSuggestion: null, - }, - usernameSuggestions: validations.usernameSuggestions, + ...defaultState, + usernameSuggestions, + validations: validationWithoutUsernameSuggestions, }, ); }); - it('should set username suggestions upon registration error case', () => { - const state = { - usernameSuggestions: [], - }; + it('should show username suggestions returned by registration error', () => { const payload = { usernameSuggestions: ['test12'] }; const action = { type: REGISTER_NEW_USER.FAILURE, payload, }; - expect( - reducer(state, action), - ).toEqual( + expect(reducer(defaultState, action)).toEqual( { + ...defaultState, registrationError: payload, - submitState: DEFAULT_STATE, usernameSuggestions: payload.usernameSuggestions, - validations: null, }, ); }); - it('should clear username suggestions from validations state', () => { + it('should clear username suggestions', () => { const state = { + ...defaultState, usernameSuggestions: ['test_1'], }; const action = { type: REGISTER_CLEAR_USERNAME_SUGGESTIONS, }; - expect( - reducer(state, action), - ).toEqual( - { - usernameSuggestions: [], - }, - ); + expect(reducer(state, action)).toEqual({ ...defaultState }); }); - it('should not reset username suggestions and form data in form reset', () => { + + it('should not reset username suggestions and fields data during form reset', () => { const state = { - registrationError: {}, - registrationResult: {}, - registrationFormData: { - country: 'PK', - email: 'test@email.com', - name: 'John Doe', - password: 'johndoe', - username: 'john', - marketingOptIn: true, - errors: { - email: '', - name: '', - username: '', - password: '', - country: '', - }, - emailErrorSuggestion: 'test@email.com', - emailWarningSuggestion: 'test@email.com', - }, - validations: null, - statusCode: null, - extendedProfile: [], - fieldDescriptions: {}, - formRenderState: DEFAULT_STATE, + ...defaultState, usernameSuggestions: ['test1', 'test2'], }; const action = { - type: REGISTRATION_FORM.RESET, + type: BACKUP_REGISTRATION_DATA.BEGIN, + payload: { ...state.registrationFormData }, }; - expect( - reducer(state, action), - ).toEqual( - state, - ); + expect(reducer(state, action)).toEqual(state); }); - it('should set registrationFormData', () => { - const state = { - registrationFormData: { - country: '', - email: '', - name: '', - password: '', - username: '', - marketingOptIn: true, - errors: { - email: '', - name: '', - username: '', - password: '', - country: '', - }, - emailErrorSuggestion: null, - emailWarningSuggestion: null, - }, - }; - const formData = { - country: 'PK', - email: 'test@email.com', - name: 'John Doe', - password: 'johndoe', - username: 'john', - emailErrorSuggestion: 'test@email.com', - emailWarningSuggestion: 'test@email.com', - }; - - const action = { - type: REGISTER_PERSIST_FORM_DATA, - payload: { formData }, - }; - - expect( - reducer(state, action), - ).toEqual( - { - registrationFormData: { - ...state.registrationFormData, - country: 'PK', - email: 'test@email.com', - name: 'John Doe', - password: 'johndoe', - username: 'john', - emailErrorSuggestion: 'test@email.com', - emailWarningSuggestion: 'test@email.com', - }, - }, - ); - }); - - it('should set country code from context', () => { - const state = { - registrationFormData: { - country: '', - email: '', - name: '', - password: '', - username: '', - marketingOptIn: true, - errors: { - email: '', - name: '', - username: '', - password: '', - country: '', - }, - emailErrorSuggestion: null, - emailWarningSuggestion: null, - }, - }; + it('should set country code', () => { const countryCode = 'PK'; const action = { @@ -256,14 +108,10 @@ describe('register reducer', () => { payload: { countryCode }, }; - expect( - reducer(state, action), - ).toEqual( + expect(reducer(defaultState, action)).toEqual( { - registrationFormData: { - ...state.registrationFormData, - country: 'PK', - }, + ...defaultState, + backendCountryCode: countryCode, }, ); }); diff --git a/src/register/data/utils.js b/src/register/data/utils.js new file mode 100644 index 00000000..c73e0dd9 --- /dev/null +++ b/src/register/data/utils.js @@ -0,0 +1,111 @@ +import { distance } from 'fastest-levenshtein'; + +import { + COMMON_EMAIL_PROVIDERS, + COUNTRY_CODE_KEY, + COUNTRY_DISPLAY_KEY, + DEFAULT_SERVICE_PROVIDER_DOMAINS, + DEFAULT_TOP_LEVEL_DOMAINS, +} from './constants'; + +function getLevenshteinSuggestion(word, knownWords, similarityThreshold = 4) { + if (!word) { + return null; + } + + let minEditDistance = 100; + let mostSimilar = word; + + for (let i = 0; i < knownWords.length; i++) { + const editDistance = distance(knownWords[i].toLowerCase(), word.toLowerCase()); + if (editDistance < minEditDistance) { + minEditDistance = editDistance; + mostSimilar = knownWords[i]; + } + } + + return minEditDistance <= similarityThreshold && word !== mostSimilar ? mostSimilar : null; +} + +export function getSuggestionForInvalidEmail(domain, username) { + if (!domain) { + return ''; + } + + const defaultDomains = ['yahoo', 'aol', 'hotmail', 'live', 'outlook', 'gmail']; + const suggestion = getLevenshteinSuggestion(domain, COMMON_EMAIL_PROVIDERS); + + if (suggestion) { + return `${username}@${suggestion}`; + } + + for (let i = 0; i < defaultDomains.length; i++) { + if (domain.includes(defaultDomains[i])) { + return `${username}@${defaultDomains[i]}.com`; + } + } + + return ''; +} + +export function validateEmailAddress(value, username, domainName) { + let suggestion = null; + const validation = { + hasError: false, + suggestion: '', + type: '', + }; + + const hasMultipleSubdomains = value.match(/\./g).length > 1; + const [serviceLevelDomain, topLevelDomain] = domainName.split('.'); + const tldSuggestion = !DEFAULT_TOP_LEVEL_DOMAINS.includes(topLevelDomain); + const serviceSuggestion = getLevenshteinSuggestion(serviceLevelDomain, DEFAULT_SERVICE_PROVIDER_DOMAINS, 2); + + if (DEFAULT_SERVICE_PROVIDER_DOMAINS.includes(serviceSuggestion || serviceLevelDomain)) { + suggestion = `${username}@${serviceSuggestion || serviceLevelDomain}.com`; + } + + if (!hasMultipleSubdomains && tldSuggestion) { + validation.suggestion = suggestion; + validation.type = 'error'; + } else if (serviceSuggestion) { + validation.suggestion = suggestion; + validation.type = 'warning'; + } else { + suggestion = getLevenshteinSuggestion(domainName, COMMON_EMAIL_PROVIDERS, 3); + if (suggestion) { + validation.suggestion = `${username}@${suggestion}`; + validation.type = 'warning'; + } + } + + if (!hasMultipleSubdomains && tldSuggestion) { + validation.hasError = true; + } + + return validation; +} + +export function validateCountryField(value, countryList, errorMessage) { + let countryCode = ''; + let displayValue = value; + let error = errorMessage; + + if (value) { + const normalizedValue = value.toLowerCase(); + // Handling a case here where user enters a valid country code that needs to be + // evaluated and set its value as a valid value. + const selectedCountry = countryList.find( + (country) => ( + country[COUNTRY_DISPLAY_KEY].toLowerCase() === normalizedValue + || country[COUNTRY_CODE_KEY].toLowerCase() === normalizedValue + ), + ); + if (selectedCountry) { + countryCode = selectedCountry[COUNTRY_CODE_KEY]; + displayValue = selectedCountry[COUNTRY_DISPLAY_KEY]; + error = ''; + } + } + return { error, countryCode, displayValue }; +} diff --git a/src/register/messages.jsx b/src/register/messages.jsx index 501d691e..03f48326 100644 --- a/src/register/messages.jsx +++ b/src/register/messages.jsx @@ -285,11 +285,6 @@ const messages = defineMessages({ defaultMessage: 'Suggested:', description: 'Suggested usernames label text.', }, - 'registration.using.tpa.form.heading': { - id: 'registration.using.tpa.form.heading', - defaultMessage: 'Finish creating your account', - description: 'Heading that appears above form when user is trying to create account using social auth', - }, 'did.you.mean.alert.text': { id: 'did.you.mean.alert.text', defaultMessage: 'Did you mean', diff --git a/src/register/registrationFields/CountryField.jsx b/src/register/registrationFields/CountryField.jsx new file mode 100644 index 00000000..ce8a7a29 --- /dev/null +++ b/src/register/registrationFields/CountryField.jsx @@ -0,0 +1,194 @@ +import React, { useEffect, useRef, useState } from 'react'; + +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { Icon, IconButton } from '@edx/paragon'; +import { ExpandLess, ExpandMore } from '@edx/paragon/icons'; +import PropTypes from 'prop-types'; + +import { FormGroup } from '../../common-components'; +import { COUNTRY_CODE_KEY, COUNTRY_DISPLAY_KEY } from '../data/constants'; +import messages from '../messages'; + +const CountryField = (props) => { + const { + intl, countryList, selectedCountry, + } = props; + + const dropdownRef = useRef(null); + const [errorMessage, setErrorMessage] = useState(props.errorMessage); + const [dropDownItems, setDropDownItems] = useState([]); + const [displayValue, setDisplayValue] = useState(''); + const [trailingIcon, setTrailingIcon] = useState(null); + + const onBlurHandler = (event, itemClicked = false) => { + const { name } = event.target; + const relatedName = event.relatedTarget ? event.relatedTarget.name : ''; + // For a better user experience, do not validate when focus out from 'country' field + // and focus on 'countryItem' or 'countryExpand' button. + if ((relatedName === 'countryItem' || relatedName === 'countryExpand') && name === 'country') { + return; + } + const countryValue = itemClicked ? event.target.value : displayValue; + if (props.onBlurHandler) { + props.onBlurHandler({ target: { name: 'country', value: countryValue } }); + } + setTrailingIcon(); + setDropDownItems([]); + }; + + const getDropdownItems = (countryToFind = null) => { + let updatedCountryList = countryList; + if (countryToFind) { + updatedCountryList = countryList.filter( + (option) => (option.name.toLowerCase().includes(countryToFind.toLowerCase())), + ); + } + + return updatedCountryList.map((country) => { + const countryName = country[COUNTRY_DISPLAY_KEY]; + return ( + + ); + }); + }; + + const onFocusHandler = (event) => { + const { name, value } = event.target; + setDropDownItems(getDropdownItems(name === 'country' ? value : displayValue)); + setTrailingIcon(); + setErrorMessage(''); + if (props.onFocusHandler) { props.onFocusHandler(event); } + }; + + const onChangeHandler = (event) => { + const filteredItems = getDropdownItems(event.target.value); + setDropDownItems(filteredItems); + setDisplayValue(event.target.value); + if (props.onChangeHandler) { props.onChangeHandler(event, { countryCode: '', displayValue: event.target.value }); } + }; + + const handleOnClickOutside = () => { + setTrailingIcon(); + setDropDownItems([]); + }; + + const handleExpandMore = () => { + setTrailingIcon(); + setDropDownItems(getDropdownItems()); + }; + + const handleExpandLess = () => { + setTrailingIcon(); + setDropDownItems([]); + }; + + const ExpandMoreButton = () => ( + {}} + onClick={handleExpandMore} + onFocus={() => {}} + /> + ); + + const ExpandLessButton = () => ( + {}} + onClick={handleExpandLess} + onFocus={() => {}} + /> + ); + + useEffect(() => { + const handleClickOutside = (event) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target)) { + handleOnClickOutside(); + } + }; + document.addEventListener('click', handleClickOutside, true); + return () => { + document.removeEventListener('click', handleClickOutside, true); + }; + }, []); + + useEffect(() => { + if (!trailingIcon) { + setTrailingIcon(); + } + }, [trailingIcon]); + + useEffect(() => { + if (selectedCountry.displayValue) { + setDisplayValue(selectedCountry.displayValue); + } + }, [selectedCountry]); + + useEffect(() => { + setErrorMessage(props.errorMessage); + }, [props.errorMessage]); + + return ( +
+ +
+ { dropDownItems?.length > 0 ? dropDownItems : null } +
+
+ ); +}; + +CountryField.propTypes = { + countryList: PropTypes.arrayOf(PropTypes.object).isRequired, + errorMessage: PropTypes.string, + intl: intlShape.isRequired, + onBlurHandler: PropTypes.func.isRequired, + onChangeHandler: PropTypes.func.isRequired, + onFocusHandler: PropTypes.func.isRequired, + selectedCountry: PropTypes.shape({ + displayValue: PropTypes.string, + countryCode: PropTypes.string, + }), +}; + +CountryField.defaultProps = { + errorMessage: null, + selectedCountry: { + value: '', + }, +}; + +export default injectIntl(CountryField); diff --git a/src/register/registrationFields/EmailField.jsx b/src/register/registrationFields/EmailField.jsx new file mode 100644 index 00000000..243d5234 --- /dev/null +++ b/src/register/registrationFields/EmailField.jsx @@ -0,0 +1,82 @@ +import React from 'react'; + +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { Alert, Icon } from '@edx/paragon'; +import { Close, Error } from '@edx/paragon/icons'; +import PropTypes from 'prop-types'; + +import { FormGroup } from '../../common-components'; +import messages from '../messages'; + +const EmailField = (props) => { + const { + intl, + emailSuggestion, + handleSuggestionClick, + handleOnClose, + } = props; + + const renderEmailFeedback = () => { + if (emailSuggestion.type === 'error') { + return ( + + + {intl.formatMessage(messages['did.you.mean.alert.text'])}{' '} + + {emailSuggestion.suggestion} + ? + + + ); + } + return ( + + {intl.formatMessage(messages['did.you.mean.alert.text'])}:{' '} + + {emailSuggestion.suggestion} + ? + + ); + }; + + return ( + + {emailSuggestion.suggestion ? renderEmailFeedback() : null} + + ); +}; + +EmailField.defaultProps = { + emailSuggestion: { + suggestion: '', + type: '', + }, + errorMessage: '', +}; + +EmailField.propTypes = { + errorMessage: PropTypes.string, + emailSuggestion: PropTypes.shape({ + suggestion: PropTypes.string, + type: PropTypes.string, + }), + intl: intlShape.isRequired, + value: PropTypes.string.isRequired, + handleOnClose: PropTypes.func.isRequired, + handleSuggestionClick: PropTypes.func.isRequired, +}; + +export default injectIntl(EmailField); diff --git a/src/register/HonorCode.jsx b/src/register/registrationFields/HonorCode.jsx similarity index 91% rename from src/register/HonorCode.jsx rename to src/register/registrationFields/HonorCode.jsx index 263d7eb3..d7809c09 100644 --- a/src/register/HonorCode.jsx +++ b/src/register/registrationFields/HonorCode.jsx @@ -1,17 +1,23 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { getConfig } from '@edx/frontend-platform'; import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { Form, Hyperlink } from '@edx/paragon'; import PropTypes from 'prop-types'; -import messages from './messages'; +import messages from '../messages'; const HonorCode = (props) => { const { intl, errorMessage, onChangeHandler, fieldType, value, } = props; + useEffect(() => { + if (fieldType === 'tos_and_honor_code' && !value) { + onChangeHandler({ target: { name: 'honor_code', value: true } }); + } + }, [fieldType, onChangeHandler, value]); + if (fieldType === 'tos_and_honor_code') { return (
diff --git a/src/register/TermsOfService.jsx b/src/register/registrationFields/TermsOfService.jsx similarity index 98% rename from src/register/TermsOfService.jsx rename to src/register/registrationFields/TermsOfService.jsx index 7696d9ec..04def3ab 100644 --- a/src/register/TermsOfService.jsx +++ b/src/register/registrationFields/TermsOfService.jsx @@ -5,7 +5,7 @@ import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/ import { Form, Hyperlink } from '@edx/paragon'; import PropTypes from 'prop-types'; -import messages from './messages'; +import messages from '../messages'; const TermsOfService = (props) => { const { diff --git a/src/register/UsernameField.jsx b/src/register/registrationFields/UsernameField.jsx similarity index 76% rename from src/register/UsernameField.jsx rename to src/register/registrationFields/UsernameField.jsx index e0e0b258..ddbf0d51 100644 --- a/src/register/UsernameField.jsx +++ b/src/register/registrationFields/UsernameField.jsx @@ -5,11 +5,13 @@ import { Button, Icon, IconButton } from '@edx/paragon'; import { Close } from '@edx/paragon/icons'; import PropTypes, { string } from 'prop-types'; -import { FormGroup } from '../common-components'; -import messages from './messages'; +import { FormGroup } from '../../common-components'; +import messages from '../messages'; const UsernameField = (props) => { - const { intl, usernameSuggestions, errorMessage } = props; + const { + intl, handleSuggestionClick, handleUsernameSuggestionClose, usernameSuggestions, errorMessage, + } = props; let className = ''; let suggestedUsernameDiv = <>; let iconButton = <>; @@ -25,7 +27,7 @@ const UsernameField = (props) => { className="username-suggestion data-hj-suppress" autoComplete={props.autoComplete} key={`suggestion-${index.toString()}`} - onClick={(e) => props.handleSuggestionClick(e, username)} + onClick={(e) => handleSuggestionClick(e, 'username', username)} > {username} @@ -36,11 +38,11 @@ const UsernameField = (props) => { ); if (usernameSuggestions.length > 0 && errorMessage && props.value === ' ') { className = 'suggested-username-with-error'; - iconButton = props.handleUsernameSuggestionClose()} variant="black" size="sm" className="suggested-username-close-button" />; + iconButton = handleUsernameSuggestionClose()} variant="black" size="sm" className="suggested-username-close-button" />; suggestedUsernameDiv = suggestedUsernames(); } else if (usernameSuggestions.length > 0 && props.value === ' ') { className = 'suggested-username'; - iconButton = props.handleUsernameSuggestionClose()} variant="black" size="sm" className="suggested-username-close-button" />; + iconButton = handleUsernameSuggestionClose()} variant="black" size="sm" className="suggested-username-close-button" />; suggestedUsernameDiv = suggestedUsernames(); } else if (usernameSuggestions.length > 0 && errorMessage) { suggestedUsernameDiv = suggestedUsernames(); @@ -54,16 +56,14 @@ const UsernameField = (props) => { UsernameField.defaultProps = { usernameSuggestions: [], - handleSuggestionClick: () => {}, - handleUsernameSuggestionClose: () => {}, errorMessage: '', autoComplete: null, }; UsernameField.propTypes = { usernameSuggestions: PropTypes.arrayOf(string), - handleSuggestionClick: PropTypes.func, - handleUsernameSuggestionClose: PropTypes.func, + handleSuggestionClick: PropTypes.func.isRequired, + handleUsernameSuggestionClose: PropTypes.func.isRequired, errorMessage: PropTypes.string, intl: intlShape.isRequired, name: PropTypes.string.isRequired, diff --git a/src/register/registrationFields/index.js b/src/register/registrationFields/index.js new file mode 100644 index 00000000..4f4c45ba --- /dev/null +++ b/src/register/registrationFields/index.js @@ -0,0 +1,5 @@ +export { default as EmailField } from './EmailField'; +export { default as UsernameField } from './UsernameField'; +export { default as CountryField } from './CountryField'; +export { default as HonorCode } from './HonorCode'; +export { default as TermsOfService } from './TermsOfService'; diff --git a/src/register/tests/HonorCode.test.jsx b/src/register/tests/HonorCode.test.jsx index 61c1aec5..a42bc001 100644 --- a/src/register/tests/HonorCode.test.jsx +++ b/src/register/tests/HonorCode.test.jsx @@ -4,7 +4,7 @@ import { getConfig, mergeConfig } from '@edx/frontend-platform'; import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n'; import { mount } from 'enzyme'; -import HonorCode from '../HonorCode'; +import HonorCode from '../registrationFields/HonorCode'; const IntlHonorCode = injectIntl(HonorCode); diff --git a/src/register/tests/RegistrationPage.test.jsx b/src/register/tests/RegistrationPage.test.jsx index b01cd5bc..92ebb9fe 100644 --- a/src/register/tests/RegistrationPage.test.jsx +++ b/src/register/tests/RegistrationPage.test.jsx @@ -4,21 +4,17 @@ import { Provider } from 'react-redux'; import CookiePolicyBanner from '@edx/frontend-component-cookie-policy-banner'; import { getConfig, mergeConfig } from '@edx/frontend-platform'; import * as analytics from '@edx/frontend-platform/analytics'; -import { - configure, getLocale, injectIntl, IntlProvider, -} from '@edx/frontend-platform/i18n'; +import { configure, injectIntl, IntlProvider } from '@edx/frontend-platform/i18n'; import { mount } from 'enzyme'; -import { MemoryRouter } from 'react-router-dom'; import renderer from 'react-test-renderer'; import configureStore from 'redux-mock-store'; -import { COMPLETE_STATE, PENDING_STATE, WELCOME_PAGE } from '../../data/constants'; +import { COMPLETE_STATE, PENDING_STATE } from '../../data/constants'; import { + backupRegistrationFormBegin, clearUsernameSuggestions, fetchRealtimeValidations, registerNewUser, - resetRegistrationForm, - setRegistrationFormData, } from '../data/actions'; import { FIELDS, FORBIDDEN_REQUEST, INTERNAL_SERVER_ERROR, TPA_SESSION_EXPIRED, @@ -27,10 +23,6 @@ import RegistrationFailureMessage from '../RegistrationFailure'; import RegistrationPage from '../RegistrationPage'; jest.mock('@edx/frontend-platform/analytics'); -jest.mock('@edx/frontend-platform/i18n', () => ({ - ...jest.requireActual('@edx/frontend-platform/i18n'), - getLocale: jest.fn(), -})); analytics.sendTrackEvent = jest.fn(); analytics.sendPageEvent = jest.fn(); @@ -41,15 +33,27 @@ const mockStore = configureStore(); describe('RegistrationPage', () => { mergeConfig({ - PRIVACY_POLICY: 'http://privacy-policy.com', - TOS_AND_HONOR_CODE: 'http://tos-and-honot-code.com', + PRIVACY_POLICY: 'https://privacy-policy.com', + TOS_AND_HONOR_CODE: 'https://tos-and-honot-code.com', USER_SURVEY_COOKIE_NAME: process.env.USER_SURVEY_COOKIE_NAME, REGISTER_CONVERSION_COOKIE_NAME: process.env.REGISTER_CONVERSION_COOKIE_NAME, + SHOW_CONFIGURABLE_EDX_FIELDS: true, }); let props = {}; let store = {}; - let registrationFormData = {}; + const registrationFormData = { + configurableFormFields: {}, + formFields: { + name: '', email: '', username: '', password: '', marketingEmailsOptIn: true, + }, + emailSuggestion: { + suggestion: '', type: '', + }, + errors: { + name: '', email: '', username: '', password: '', + }, + }; const reduxWrapper = children => ( @@ -58,7 +62,6 @@ describe('RegistrationPage', () => { ); const thirdPartyAuthContext = { - platformName: 'openedX', currentProvider: null, finishAuthUrl: null, providers: [], @@ -88,24 +91,6 @@ describe('RegistrationPage', () => { }, messages: { 'es-419': {}, de: {}, 'en-us': {} }, }); - registrationFormData = { - country: '', - email: '', - name: '', - password: '', - username: '', - marketingOptIn: true, - errors: { - email: '', - name: '', - username: '', - password: '', - country: '', - }, - emailFieldBorderClass: '', - emailErrorSuggestion: null, - emailWarningSuggestion: null, - }; props = { registrationResult: jest.fn(), handleInstitutionLogin: jest.fn(), @@ -122,14 +107,16 @@ describe('RegistrationPage', () => { registerPage.find('input#name').simulate('change', { target: { value: payload.name, name: 'name' } }); registerPage.find('input#username').simulate('change', { target: { value: payload.username, name: 'username' } }); registerPage.find('input#email').simulate('change', { target: { value: payload.email, name: 'email' } }); + registerPage.find('input#country').simulate('change', { target: { value: payload.country, name: 'country' } }); + registerPage.find('input#country').simulate('blur', { target: { value: payload.country, name: 'country' } }); if (!isThirdPartyAuth) { registerPage.find('input#password').simulate('change', { target: { value: payload.password, name: 'password' } }); } }; - describe('TestRegistrationPage', () => { + describe('Test Registration Page', () => { const emptyFieldValidation = { name: 'Enter your full name', username: 'Username must be between 2 and 30 characters', @@ -153,7 +140,6 @@ describe('RegistrationPage', () => { // ******** test registration form submission ******** it('should submit form for valid input', () => { - getLocale.mockImplementation(() => ('en-us')); jest.spyOn(global.Date, 'now').mockImplementation(() => 0); delete window.location; @@ -167,19 +153,14 @@ describe('RegistrationPage', () => { country: 'Pakistan', honor_code: true, totalRegistrationTime: 0, - is_authn_mfe: true, + marketing_emails_opt_in: true, next: '/course/demo-course-url', - }; - const nextProps = { - registrationFormData: { - country: 'PK', - }, + extended_profile: [], }; store.dispatch = jest.fn(store.dispatch); const registerPage = mount(reduxWrapper()); populateRequiredFields(registerPage, payload); - registerPage.find('RegistrationPage').instance().shouldComponentUpdate(nextProps); registerPage.find('button.btn-brand').simulate('click'); expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK' })); }); @@ -195,12 +176,8 @@ describe('RegistrationPage', () => { honor_code: true, social_auth_provider: ssoProvider.name, totalRegistrationTime: 0, - is_authn_mfe: true, - }; - const nextProps = { - registrationFormData: { - country: 'PK', - }, + marketing_emails_opt_in: true, + extended_profile: [], }; store = mockStore({ @@ -217,7 +194,6 @@ describe('RegistrationPage', () => { const registerPage = mount(reduxWrapper()); populateRequiredFields(registerPage, formPayload, true); - registerPage.find('RegistrationPage').instance().shouldComponentUpdate(nextProps); registerPage.find('button.btn-brand').simulate('click'); expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...formPayload, country: 'PK' })); }); @@ -243,54 +219,61 @@ describe('RegistrationPage', () => { expect(registrationPage.find('div[feedback-for="country"]').text()).toEqual(emptyFieldValidation.country); const alertBanner = 'We couldn\'t create your account.Please check your responses and try again.'; - registrationPage.find('RegistrationPage').setState({ failureCount: 1 }); - expect(registrationPage.find('#validation-errors').first().text()).toEqual(alertBanner); - expect(registrationPage.find('RegistrationPage').state('failureCount')).toEqual(1); }); it('should update errors for frontend validations', () => { const registrationPage = mount(reduxWrapper()); + registrationPage.find('input#name').simulate('blur', { target: { value: 'http://test.com', name: 'name' } }); + expect( + registrationPage.find('div[feedback-for="name"]').text(), + ).toEqual('Enter a valid name'); registrationPage.find('input#password').simulate('blur', { target: { value: 'pas', name: 'password' } }); - expect(registrationPage.find('RegistrationPage').state('errors')).toEqual({ - email: '', name: 'Enter a valid name', username: '', password: 'Password criteria has not been met', country: '', - }); + expect( + registrationPage.find('div[feedback-for="password"]').text(), + ).toContain('Password criteria has not been met'); registrationPage.find('input#password').simulate('blur', { target: { value: 'invalid-email', name: 'email' } }); - expect(registrationPage.find('RegistrationPage').state('errors')).toEqual({ - email: 'Enter a valid email address', name: 'Enter a valid name', username: '', password: 'Password criteria has not been met', country: '', - }); + expect( + registrationPage.find('div[feedback-for="email"]').text(), + ).toEqual('Enter a valid email address'); }); it('should validate fields on blur event', () => { const registrationPage = mount(reduxWrapper()); + registrationPage.find('input#username').simulate('blur', { target: { value: '', name: 'username' } }); + expect(registrationPage.find('div[feedback-for="username"]').text()).toEqual(emptyFieldValidation.username); + registrationPage.find('input#name').simulate('blur', { target: { value: '', name: 'name' } }); + expect(registrationPage.find('div[feedback-for="name"]').text()).toEqual(emptyFieldValidation.name); + registrationPage.find('input#email').simulate('blur', { target: { value: '', name: 'email' } }); + expect(registrationPage.find('div[feedback-for="email"]').text()).toEqual(emptyFieldValidation.email); + registrationPage.find('input#password').simulate('blur', { target: { value: '', name: 'password' } }); + expect(registrationPage.find('div[feedback-for="password"]').text()).toContain(emptyFieldValidation.password); + registrationPage.find('input#country').simulate('blur', { target: { value: '', name: 'country' } }); - expect(registrationPage.find('RegistrationPage').state('errors')).toEqual(emptyFieldValidation); + expect(registrationPage.find('div[feedback-for="country"]').text()).toEqual(emptyFieldValidation.country); }); it('should call validation api on blur event, if frontend validations have passed', () => { store.dispatch = jest.fn(store.dispatch); const registrationPage = mount(reduxWrapper()); - // enter a valid username so that frontend validations are passed + // Enter a valid username so that frontend validations are passed registrationPage.find('input#username').simulate('change', { target: { value: 'test', name: 'username' } }); registrationPage.find('input#username').simulate('blur'); const formPayload = { form_field_key: 'username', - is_authn_mfe: true, email: '', name: '', username: 'test', password: '', - country: '', - honor_code: true, }; expect(store.dispatch).toHaveBeenCalledWith(fetchRealtimeValidations(formPayload)); }); @@ -307,34 +290,12 @@ describe('RegistrationPage', () => { }, }); const registrationPage = mount(reduxWrapper()).find('RegistrationPage'); - expect(registrationPage.prop('validationDecisions')).toEqual({ + expect(registrationPage.prop('backendValidations')).toEqual({ email: `This email is already associated with an existing or previous ${ getConfig().SITE_NAME } account`, username: 'It looks like this username is already taken', }); }); - it('should change validations in shouldComponentUpdate', () => { - const formData = { - errors: { - ...registrationFormData.errors, - username: 'It looks like this username is already taken', - }, - }; - store.dispatch = jest.fn(store.dispatch); - const nextProps = { - thirdPartyAuthContext, - registrationErrorCode: 'duplicate-username', - validationDecisions: { - username: 'It looks like this username is already taken', - }, - }; - - const registrationPage = mount(reduxWrapper()).find('RegistrationPage'); - expect(registrationPage.instance().shouldComponentUpdate(nextProps)).toBe(false); - expect(store.dispatch).toHaveBeenCalledWith(setRegistrationFormData(formData, true)); - expect(registrationPage.state('errorCode')).toEqual('duplicate-username'); - }); - it('should remove space from the start of username', () => { const registrationPage = mount(reduxWrapper()); registrationPage.find('input#username').simulate('change', { target: { value: ' test-user', name: 'username' } }); @@ -345,27 +306,28 @@ describe('RegistrationPage', () => { // ******** test field focus in functionality ******** it('should clear field related error messages on input field Focus', () => { - const errors = { - email: '', - name: '', - username: '', - password: '', - country: '', - }; const registrationPage = mount(reduxWrapper()); registrationPage.find('button.btn-brand').simulate('click'); expect(registrationPage.find('div[feedback-for="name"]').text()).toEqual(emptyFieldValidation.name); registrationPage.find('input#name').simulate('focus'); + expect(registrationPage.find('div[feedback-for="name"]').exists()).toBeFalsy(); + expect(registrationPage.find('div[feedback-for="username"]').text()).toEqual(emptyFieldValidation.username); registrationPage.find('input#username').simulate('focus'); + expect(registrationPage.find('div[feedback-for="username"]').exists()).toBeFalsy(); + expect(registrationPage.find('div[feedback-for="email"]').text()).toEqual(emptyFieldValidation.email); registrationPage.find('input#email').simulate('focus'); + expect(registrationPage.find('div[feedback-for="email"]').exists()).toBeFalsy(); + expect(registrationPage.find('div[feedback-for="password"]').text()).toContain(emptyFieldValidation.password); registrationPage.find('input#password').simulate('focus'); + expect(registrationPage.find('div[feedback-for="password"]').exists()).toBeFalsy(); + expect(registrationPage.find('div[feedback-for="country"]').text()).toEqual(emptyFieldValidation.country); registrationPage.find('input#country').simulate('focus'); - expect(registrationPage.find('RegistrationPage').state('errors')).toEqual(errors); + expect(registrationPage.find('div[feedback-for="country"]').exists()).toBeFalsy(); }); it('should clear username suggestions when username field is focused in', () => { @@ -459,6 +421,19 @@ describe('RegistrationPage', () => { expect(button.find('.sr-only').text()).toEqual('pending'); }); + it('should display opt-in/opt-out checkbox', () => { + mergeConfig({ + MARKETING_EMAILS_OPT_IN: 'true', + }); + + const registerPage = mount(reduxWrapper()); + expect(registerPage.find('div.opt-checkbox').length).toEqual(1); + + mergeConfig({ + MARKETING_EMAILS_OPT_IN: '', + }); + }); + it('should show single sign on provider button', () => { store = mockStore({ ...initialState, @@ -550,7 +525,6 @@ describe('RegistrationPage', () => { }); const registerPage = mount(reduxWrapper()); - registerPage.find('RegistrationPage').instance().shouldComponentUpdate(props); expect(registerPage.find('button.username-suggestion').length).toEqual(3); }); @@ -559,7 +533,7 @@ describe('RegistrationPage', () => { ...initialState, register: { ...initialState.register, - usernameSuggestions: ['testname', 't.name', 'test_0'], + usernameSuggestions: ['test_1', 'test_12', 'test_123'], registrationFormData: { ...registrationFormData, username: ' ', @@ -578,7 +552,7 @@ describe('RegistrationPage', () => { ...initialState, register: { ...initialState.register, - usernameSuggestions: ['testname', 't.name', 'test_0'], + usernameSuggestions: ['test_1', 'test_12', 'test_123'], registrationFormData: { ...registrationFormData, username: ' ', @@ -588,28 +562,27 @@ describe('RegistrationPage', () => { store.dispatch = jest.fn(store.dispatch); const registerPage = mount(reduxWrapper()); - registerPage.find('RegistrationPage').instance().shouldComponentUpdate(props); registerPage.find('input#name').simulate('change', { target: { value: 'test name', name: 'name' } }); registerPage.find('button.suggested-username-close-button').at(0).simulate('click'); expect(store.dispatch).toHaveBeenCalledWith(clearUsernameSuggestions()); }); it('should redirect to url returned in registration result after successful account creation', () => { - const dasboardUrl = 'http://test.com/testing-dashboard/'; + const dashboardURL = 'https://test.com/testing-dashboard/'; store = mockStore({ ...initialState, register: { ...initialState.register, registrationResult: { success: true, - redirectUrl: dasboardUrl, + redirectUrl: dashboardURL, }, }, }); delete window.location; window.location = { href: getConfig().BASE_URL }; renderer.create(reduxWrapper()); - expect(window.location.href).toBe(dasboardUrl); + expect(window.location.href).toBe(dashboardURL); }); it('should redirect to social auth provider url on SSO button click', () => { @@ -663,6 +636,27 @@ describe('RegistrationPage', () => { expect(window.location.href).toBe(getConfig().LMS_BASE_URL + authCompleteUrl); }); + it('should redirect to dashboard if features flags are configured but no optional fields are configured', () => { + mergeConfig({ + ENABLE_PROGRESSIVE_PROFILING: true, + }); + const dashboardUrl = 'https://test.com/testing-dashboard/'; + store = mockStore({ + ...initialState, + register: { + ...initialState.register, + registrationResult: { + success: true, + redirectUrl: dashboardUrl, + }, + }, + }); + delete window.location; + window.location = { href: getConfig().BASE_URL }; + renderer.create(reduxWrapper()); + expect(window.location.href).toBe(dashboardUrl); + }); + // ******** test hinted third party auth ******** it('should render tpa button for tpa_hint id matching one of the primary providers', () => { @@ -733,10 +727,18 @@ describe('RegistrationPage', () => { // ******** miscellaneous tests ******** - it('tests componentDidMount calls the reset form action', () => { + it('should backup the registration form state when shouldBackupState is true', () => { + store = mockStore({ + ...initialState, + register: { + ...initialState.register, + shouldBackupState: true, + }, + }); + store.dispatch = jest.fn(store.dispatch); mount(reduxWrapper()); - expect(store.dispatch).toHaveBeenCalledWith(resetRegistrationForm()); + expect(store.dispatch).toHaveBeenCalledWith(backupRegistrationFormBegin({ ...registrationFormData })); }); it('should render cookie banner', () => { @@ -749,319 +751,102 @@ describe('RegistrationPage', () => { expect(analytics.sendPageEvent).toHaveBeenCalledWith('login_and_registration', 'register'); }); - it('should send track event for save_for_later param', () => { - delete window.location; - window.location = { href: getConfig().BASE_URL.concat('/register'), search: '?save_for_later=true' }; - renderer.create(reduxWrapper()); - expect(analytics.sendTrackEvent).toHaveBeenCalledWith('edx.bi.user.saveforlater.course.enroll.clicked', - { category: 'save-for-later' }); - }); - - // ******** shouldComponentUpdate tests ******** - it('should populate form with pipeline user details', () => { store = mockStore({ ...initialState, + register: { + ...initialState.register, + backedUpFormData: { ...registrationFormData }, + }, commonComponents: { ...initialState.commonComponents, thirdPartyAuthContext: { ...initialState.commonComponents.thirdPartyAuthContext, pipelineUserDetails: { email: 'test@example.com', + username: 'test', }, - countryCode: 'US', }, }, }); - const nextProps = { - thirdPartyAuthContext: { - pipelineUserDetails: {}, - }, - registrationFormData: { - ...registrationFormData, - country: 'US', - email: 'test@example.com', - }, - validationDecisions: null, - }; const registrationPage = mount(reduxWrapper()).find('RegistrationPage'); - registrationPage.instance().shouldComponentUpdate(nextProps); - expect(registrationPage.state('country')).toEqual('US'); - expect(registrationPage.state('email')).toEqual('test@example.com'); + expect(registrationPage.find('input#email').props().value).toEqual('test@example.com'); + expect(registrationPage.find('input#username').props().value).toEqual('test'); }); it('should update state from country code present in redux store', () => { - const nextProps = { - registrationErrorCode: null, - thirdPartyAuthContext: { - ...thirdPartyAuthContext, - countryCode: 'US', + store = mockStore({ + ...initialState, + register: { + ...initialState.register, + backendCountryCode: 'PK', }, - registrationFormData: { - ...registrationFormData, - country: 'US', - }, - validationDecisions: null, - }; + }); const registrationPage = mount(reduxWrapper()); - const shouldUpdate = registrationPage.find('RegistrationPage').instance().shouldComponentUpdate(nextProps); - expect(registrationPage.find('RegistrationPage').state('country')).toEqual('US'); - expect(shouldUpdate).toBe(false); + expect(registrationPage.find('input#country').props().value).toEqual('Pakistan'); }); - it('should update error code state with error returned by registration api', () => { - const nextProps = { - registrationErrorCode: INTERNAL_SERVER_ERROR, - thirdPartyAuthContext, - validationDecisions: {}, - }; + it('should display error message based on the error code returned by API', () => { + store = mockStore({ + ...initialState, + register: { + ...initialState.register, + registrationError: { + errorCode: INTERNAL_SERVER_ERROR, + }, + }, + }); - const registrationPage = mount(reduxWrapper()); - registrationPage.find('RegistrationPage').instance().shouldComponentUpdate(nextProps); - - expect(registrationPage.find('RegistrationPage').state('errorCode')).toEqual(INTERNAL_SERVER_ERROR); + const registrationPage = mount(reduxWrapper()).find('RegistrationPage'); + expect(registrationPage.find('div#validation-errors').first().text()).toContain( + 'An error has occurred. Try refreshing the page, or check your internet connection.', + ); }); it('should update form fields state if updated in redux store', () => { - const nextProps = { - thirdPartyAuthContext, - registrationFormData: { - name: 'John Doe', - username: 'john_doe', - email: 'john.doe@example.com', - password: 'password1', - emailErrorSuggestion: 'test@gmail.com', - }, - }; - - const registrationPage = mount(reduxWrapper()); - registrationPage.find('RegistrationPage').instance().shouldComponentUpdate(nextProps); - - expect(registrationPage.find('RegistrationPage').state('name')).toEqual('John Doe'); - expect(registrationPage.find('RegistrationPage').state('username')).toEqual('john_doe'); - expect(registrationPage.find('RegistrationPage').state('email')).toEqual('john.doe@example.com'); - expect(registrationPage.find('RegistrationPage').state('emailErrorSuggestion')).toEqual('test@gmail.com'); - expect(registrationPage.find('RegistrationPage').state('password')).toEqual('password1'); - }); - - it('should display opt-in/opt-out checkbox', () => { - mergeConfig({ - MARKETING_EMAILS_OPT_IN: 'true', - }); - - const registerPage = mount(reduxWrapper()); - expect(registerPage.find('div.opt-checkbox').length).toEqual(1); - - mergeConfig({ - MARKETING_EMAILS_OPT_IN: '', - }); - }); - - // ******** persist state tests ******** - - it('should clear form field errors in redux store on onFocus', () => { - store.dispatch = jest.fn(store.dispatch); - const registrationPage = mount(reduxWrapper()); - registrationPage.find('input#name').simulate('focus'); - expect(store.dispatch).toHaveBeenCalledWith(setRegistrationFormData({ errors: registrationFormData.errors })); - }); - - it('should set username in redux store if usernameSuggestion is clicked', () => { - const formData = { - username: 'testname', - errors: { - ...registrationFormData.errors, - }, - }; store = mockStore({ ...initialState, register: { ...initialState.register, - usernameSuggestions: ['testname', 't.name', 'test_0'], registrationFormData: { ...registrationFormData, - username: ' ', + formFields: { + name: 'John Doe', + username: 'john_doe', + email: 'john.doe@yopmail.com', + password: 'password1', + }, + emailSuggestion: { + suggestion: 'john.doe@hotmail.com', type: 'warning', + }, }, }, }); - store.dispatch = jest.fn(store.dispatch); - const registerPage = mount(reduxWrapper()); - registerPage.find('input#name').simulate('change', { target: { value: 'test name', name: 'name' } }); - registerPage.find('button.username-suggestion').at(0).simulate('click'); - expect(store.dispatch).toHaveBeenCalledWith(setRegistrationFormData(formData)); - }); + const registrationPage = mount(reduxWrapper()).find('RegistrationPage'); - it('should set email in redux store if emailSuggestion is clicked', () => { - const formData = { - email: 'test@gmail.com', - emailErrorSuggestion: null, - emailWarningSuggestion: null, - emailFieldBorderClass: '', - errors: { - ...registrationFormData.errors, - }, - }; - store = mockStore({ - ...initialState, - register: { - ...initialState.register, - }, - }); - store.dispatch = jest.fn(store.dispatch); - const registrationPage = mount(reduxWrapper()); - registrationPage.find('input#email').simulate('blur', { target: { value: 'test@gmail.con', name: 'email' } }); - registrationPage.find('RegistrationPage').setState({ emailErrorSuggestion: 'test@gmail.com' }); - registrationPage.find('.alert-link').simulate('click'); - expect(store.dispatch).toHaveBeenCalledWith(setRegistrationFormData(formData)); - }); - - it('should clear username in redux store if usernameSuggestion close button is clicked', () => { - store = mockStore({ - ...initialState, - register: { - ...initialState.register, - usernameSuggestions: ['testname', 't.name', 'test_0'], - registrationFormData: { - ...registrationFormData, - username: ' ', - }, - }, - }); - store.dispatch = jest.fn(store.dispatch); - const registerPage = mount(reduxWrapper()); - registerPage.find('input#name').simulate('change', { target: { value: 'test name', name: 'name' } }); - registerPage.find('button.suggested-username-close-button').simulate('click'); - expect(store.dispatch).toHaveBeenCalledWith(setRegistrationFormData({ username: '' })); - }); - - it('should clear emailErrorSuggestion in redux store if close button is clicked', () => { - store = mockStore({ - ...initialState, - register: { - ...initialState.register, - }, - }); - store.dispatch = jest.fn(store.dispatch); - const registrationPage = mount(reduxWrapper()); - registrationPage.find('input#email').simulate('blur', { target: { value: 'test@gmail.con', name: 'email' } }); - registrationPage.find('RegistrationPage').setState({ emailErrorSuggestion: 'test@gmail.com' }); - registrationPage.find('.alert-close').at(0).simulate('click'); - expect(store.dispatch).toHaveBeenCalledWith(setRegistrationFormData({ emailErrorSuggestion: null })); - }); - - it('should set error in redux store if form field is invalid', () => { - const formData = { - name: '', - errors: { - ...registrationFormData.errors, - name: emptyFieldValidation.name, - }, - }; - store.dispatch = jest.fn(store.dispatch); - const registerPage = mount(reduxWrapper()); - registerPage.find('input#name').simulate('blur', { target: { value: '', name: 'name' } }); - expect(store.dispatch).toHaveBeenCalledWith(setRegistrationFormData(formData)); - }); - - it('should set country code in redux store on country field blur', () => { - const formData = { - country: 'PK', - errors: { - country: '', - email: '', - name: '', - password: '', - username: '', - }, - }; - store.dispatch = jest.fn(store.dispatch); - const registerPage = mount(reduxWrapper()); - registerPage.find('RegistrationPage').setState({ country: 'PK' }); - registerPage.find('input#country').simulate('blur'); - expect(store.dispatch).toHaveBeenCalledWith(setRegistrationFormData(formData)); - }); - - it('should set country value with field error in redux store on country field blur', () => { - const formData = { - country: 'test', - errors: { - country: 'Select your country or region of residence', - email: '', - name: '', - password: '', - username: '', - }, - }; - store.dispatch = jest.fn(store.dispatch); - const registerPage = mount(reduxWrapper()); - registerPage.find('RegistrationPage').setState({ country: 'test' }); - registerPage.find('input#country').simulate('blur'); - expect(store.dispatch).toHaveBeenCalledWith(setRegistrationFormData(formData)); - }); - - it('should set country in component state on country change', () => { - registrationFormData = { - ...registrationFormData, - country: 'PK', - }; - const nextProps = { - registrationFormData: { - country: 'PK', - }, - }; - - store.dispatch = jest.fn(store.dispatch); - const registerPage = mount(reduxWrapper()); - registerPage.find('input#country').simulate('change', { target: { value: 'Pakistan', name: 'country' } }); - registerPage.find('RegistrationPage').instance().shouldComponentUpdate(nextProps); - expect(registerPage.find('RegistrationPage').state('country')).toEqual('PK'); - }); - - it('should set country in component state on country change with translations', () => { - getLocale.mockImplementation(() => ('ar-ae')); - registrationFormData = { - errors: { ...registrationFormData.errors }, - country: 'AF', - }; - store.dispatch = jest.fn(store.dispatch); - - const registerPage = mount(reduxWrapper()); - registerPage.find('input#country').simulate('focus'); - registerPage.find('button.dropdown-item').at(0).simulate('click', { target: { value: 'أفغانستان ', name: 'countryItem' } }); - expect(store.dispatch).toHaveBeenCalledWith(setRegistrationFormData(registrationFormData)); - }); - - it('should set country in component state on country change with chrome translations', () => { - getLocale.mockImplementation(() => ('en-us')); - registrationFormData = { - errors: { ...registrationFormData.errors }, - country: 'AF', - }; - store.dispatch = jest.fn(store.dispatch); - - const registerPage = mount(reduxWrapper()); - registerPage.find('input#country').simulate('focus'); - registerPage.find('button.dropdown-item').at(0).simulate('click', { target: { value: undefined, name: undefined, parentElement: { parentElement: { value: 'Afghanistan' } } } }); - expect(store.dispatch).toHaveBeenCalledWith(setRegistrationFormData(registrationFormData)); + expect(registrationPage.find('input#name').props().value).toEqual('John Doe'); + expect(registrationPage.find('input#username').props().value).toEqual('john_doe'); + expect(registrationPage.find('input#email').props().value).toEqual('john.doe@yopmail.com'); + expect(registrationPage.find('input#password').props().value).toEqual('password1'); + expect(registrationPage.find('.email-warning-alert-link').first().text()).toEqual('john.doe@hotmail.com'); }); }); - describe('TestDynamicFields', () => { + describe('Test Configurable Fields', () => { + mergeConfig({ + ENABLE_DYNAMIC_REGISTRATION_FIELDS: true, + }); + it('should render fields returned by backend', () => { - mergeConfig({ - ENABLE_DYNAMIC_REGISTRATION_FIELDS: true, - }); store = mockStore({ ...initialState, commonComponents: { ...initialState.commonComponents, fieldDescriptions: { - country: { name: 'country', error_message: true }, profession: { name: 'profession', type: 'text', label: 'Profession' }, - honor_code: { name: FIELDS.HONOR_CODE, error_message: 'You must agree to Honor Code of our site' }, terms_of_service: { name: FIELDS.TERMS_OF_SERVICE, error_message: 'You must agree to the Terms and Service agreement of our site', @@ -1070,9 +855,7 @@ describe('RegistrationPage', () => { }, }); const registerPage = mount(reduxWrapper()); - expect(registerPage.find('#country').exists()).toBeTruthy(); expect(registerPage.find('#profession').exists()).toBeTruthy(); - expect(registerPage.find('#honor-code').exists()).toBeTruthy(); expect(registerPage.find('#tos').exists()).toBeTruthy(); }); @@ -1083,9 +866,7 @@ describe('RegistrationPage', () => { commonComponents: { ...initialState.commonComponents, fieldDescriptions: { - country: { name: 'country', error_message: true }, profession: { name: 'profession', type: 'text', label: 'Profession' }, - honor_code: { name: 'honor_code', type: 'tos_and_honor_code' }, }, extendedProfile: ['profession'], }, @@ -1097,9 +878,9 @@ describe('RegistrationPage', () => { email: 'john.doe@example.com', password: 'password1', country: 'Pakistan', - totalRegistrationTime: 0, - is_authn_mfe: true, honor_code: true, + totalRegistrationTime: 0, + marketing_emails_opt_in: true, extended_profile: [{ field_name: 'profession', field_value: 'Engineer' }], }; @@ -1107,7 +888,6 @@ describe('RegistrationPage', () => { const registerPage = mount(reduxWrapper()); populateRequiredFields(registerPage, payload); - registerPage.find('RegistrationPage').setState({ values: { country: 'PK' } }); registerPage.find('input#profession').simulate('change', { target: { value: 'Engineer', name: 'profession' } }); registerPage.find('button.btn-brand').simulate('click'); expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK' })); @@ -1151,11 +931,7 @@ describe('RegistrationPage', () => { expect(registrationPage.find('#confirm_email-error').last().text()).toEqual('Enter your confirm email'); }); - it('should show error if email and confirm email fields not match', () => { - mergeConfig({ - ENABLE_DYNAMIC_REGISTRATION_FIELDS: true, - }); - + it('should show error if email and confirm email fields do not match', () => { store = mockStore({ ...initialState, commonComponents: { @@ -1168,68 +944,10 @@ describe('RegistrationPage', () => { }, }); const registrationPage = mount(reduxWrapper()); - registrationPage.find('RegistrationPage').setState({ email: 'test1@gmail.com' }); - registrationPage.find('input#confirm_email').simulate('blur', { target: { value: 'test@gmail.com', name: 'confirm_email' } }); + registrationPage.find('input#email').simulate('change', { target: { value: 'test1@gmail.com', name: 'email' } }); + registrationPage.find('input#confirm_email').simulate('blur', { target: { value: 'test2@gmail.com', name: 'confirm_email' } }); - expect(registrationPage.find('#confirm_email-error').last().text()).toEqual('The email addresses do not match.'); - }); - - it('should redirect to dashboard if features flags are configured but no optional fields are configured', () => { - mergeConfig({ - ENABLE_PROGRESSIVE_PROFILING: true, - ENABLE_DYNAMIC_REGISTRATION_FIELDS: true, - }); - const dasboardUrl = 'http://test.com/testing-dashboard/'; - store = mockStore({ - ...initialState, - register: { - ...initialState.register, - registrationResult: { - success: true, - redirectUrl: dasboardUrl, - }, - commonComponents: { - optionalFields: {}, - }, - }, - }); - delete window.location; - window.location = { href: getConfig().BASE_URL }; - renderer.create(reduxWrapper()); - expect(window.location.href).toBe(dasboardUrl); - }); - - it('should redirect to welcome page when optional fields are configured with feature flags', () => { - mergeConfig({ - ENABLE_PROGRESSIVE_PROFILING: true, - ENABLE_DYNAMIC_REGISTRATION_FIELDS: true, - }); - store = mockStore({ - ...initialState, - commonComponents: { - optionalFields: { - country: { name: 'country', error_message: false }, - }, - }, - register: { - ...initialState.register, - registrationResult: { - success: true, - }, - - }, - }); - - delete window.location; - window.location = { href: getConfig().BASE_URL + WELCOME_PAGE }; - renderer.create(reduxWrapper( - - - - - , - )); - expect(window.location.href).toBe(getConfig().BASE_URL + WELCOME_PAGE); + expect(registrationPage.find('div#confirm_email-error').text()).toEqual('The email addresses do not match.'); }); }); }); diff --git a/src/register/tests/TermsOfService.test.jsx b/src/register/tests/TermsOfService.test.jsx index bfb24b04..51939997 100644 --- a/src/register/tests/TermsOfService.test.jsx +++ b/src/register/tests/TermsOfService.test.jsx @@ -4,7 +4,7 @@ import { getConfig, mergeConfig } from '@edx/frontend-platform'; import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n'; import { mount } from 'enzyme'; -import TermsOfService from '../TermsOfService'; +import TermsOfService from '../registrationFields/TermsOfService'; const IntlTermsOfService = injectIntl(TermsOfService); diff --git a/src/register/utils.js b/src/register/utils.js deleted file mode 100644 index 98562bb5..00000000 --- a/src/register/utils.js +++ /dev/null @@ -1,43 +0,0 @@ -import { distance } from 'fastest-levenshtein'; - -import { COMMON_EMAIL_PROVIDERS } from './data/constants'; - -export function getLevenshteinSuggestion(word, knownWords, similarityThreshold = 4) { - if (!word) { - return null; - } - - let minEditDistance = 100; - let mostSimilar = word; - - for (let i = 0; i < knownWords.length; i++) { - const editDistance = distance(knownWords[i].toLowerCase(), word.toLowerCase()); - if (editDistance < minEditDistance) { - minEditDistance = editDistance; - mostSimilar = knownWords[i]; - } - } - - return minEditDistance <= similarityThreshold && word !== mostSimilar ? mostSimilar : null; -} - -export function getSuggestionForInvalidEmail(domain, username) { - if (!domain) { - return null; - } - - const defaultDomains = ['yahoo', 'aol', 'hotmail', 'live', 'outlook', 'gmail']; - const suggestion = getLevenshteinSuggestion(domain, COMMON_EMAIL_PROVIDERS); - - if (suggestion) { - return `${username}@${suggestion}`; - } - - for (let i = 0; i < defaultDomains.length; i++) { - if (domain.includes(defaultDomains[i])) { - return `${username}@${defaultDomains[i]}.com`; - } - } - - return null; -} diff --git a/src/reset-password/ResetPasswordPage.jsx b/src/reset-password/ResetPasswordPage.jsx index 18872d96..1a2e3b0f 100644 --- a/src/reset-password/ResetPasswordPage.jsx +++ b/src/reset-password/ResetPasswordPage.jsx @@ -94,12 +94,12 @@ const ResetPasswordPage = (props) => { // Do not validate when focus out from 'newPassword' and focus on 'passwordValidation' icon // for better user experience. if (event.relatedTarget - && event.relatedTarget.name === 'passwordValidation' + && event.relatedTarget.name === 'password' && name === 'newPassword' ) { return; } - if (name === 'passwordValidation') { + if (name === 'password') { name = 'newPassword'; value = newPassword; } diff --git a/src/sass/_registration.scss b/src/sass/_registration.scss new file mode 100644 index 00000000..5b4f93a0 --- /dev/null +++ b/src/sass/_registration.scss @@ -0,0 +1,3 @@ +.register-stateful-button-width { + min-width: 14.4rem; +} diff --git a/src/sass/_style.scss b/src/sass/_style.scss index 5597bfa7..ce7ac9f9 100644 --- a/src/sass/_style.scss +++ b/src/sass/_style.scss @@ -1,5 +1,6 @@ // Load component based styles @import "_base_component.scss"; +@import "_registration.scss"; // // ---------------------------- // #COLORS @@ -46,10 +47,6 @@ $elevation-level-2-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.15); width: 12rem; } -.register-stateful-button-width { - min-width: 14.4rem; -} - .login-button-width { min-width: 6rem; }