From 80e940f44e20a0c3d441b8d181d7a47b54dbf10b Mon Sep 17 00:00:00 2001 From: Blue Date: Mon, 21 Aug 2023 12:08:06 +0500 Subject: [PATCH 1/3] fix: embedded registration form (#1026) Description: Adding embedded registration form VAN-1574 --- src/MainApp.jsx | 4 +- .../RedirectLogistration.jsx | 15 +- src/register/RegistrationPage.jsx | 71 +- src/register/RegistrationPage.test.jsx | 98 --- .../components/EmbeddableRegistrationPage.jsx | 476 ++++++++++++++ .../ConfigurableRegistrationForm.test.jsx | 4 +- .../tests/EmbeddableRegistrationPage.test.jsx | 616 ++++++++++++++++++ src/register/index.js | 1 + 8 files changed, 1116 insertions(+), 169 deletions(-) create mode 100644 src/register/components/EmbeddableRegistrationPage.jsx rename src/register/components/{ => tests}/ConfigurableRegistrationForm.test.jsx (97%) create mode 100644 src/register/components/tests/EmbeddableRegistrationPage.test.jsx diff --git a/src/MainApp.jsx b/src/MainApp.jsx index 26c2cf594a..05aaae638b 100755 --- a/src/MainApp.jsx +++ b/src/MainApp.jsx @@ -24,7 +24,7 @@ import { ForgotPasswordPage } from './forgot-password'; import Logistration from './logistration/Logistration'; import { ProgressiveProfiling } from './progressive-profiling'; import { RecommendationsPage } from './recommendations'; -import { RegistrationPage } from './register'; +import { EmbeddableRegistrationPage } from './register'; import { ResetPasswordPage } from './reset-password'; import './index.scss'; @@ -41,7 +41,7 @@ const MainApp = () => ( } /> } + element={} /> { redirectToRecommendationsPage, educationLevel, userId, - registrationEmbedded, - host, } = props; let finalRedirectUrl = ''; @@ -39,13 +37,6 @@ const RedirectLogistration = (props) => { // TODO: Do we still need this cookie? setCookie('van-504-returning-user', true); - if (registrationEmbedded) { - window.parent.postMessage({ - action: REDIRECT, - redirectUrl: getConfig().POST_REGISTRATION_REDIRECT_URL, - }, host); - return null; - } const registrationResult = { redirectUrl: finalRedirectUrl, success }; return ( { const { formatMessage } = useIntl(); const dispatch = useDispatch(); - const registrationEmbedded = isHostAvailableInQueryParams(); const platformName = getConfig().SITE_NAME; const flags = { showConfigurableEdxFields: getConfig().SHOW_CONFIGURABLE_EDX_FIELDS, @@ -99,13 +97,6 @@ const RegistrationPage = (props) => { const [errors, setErrors] = useState({ ...backedUpFormData.errors }); const [errorCode, setErrorCode] = useState({ type: '', count: 0 }); const [formStartTime, setFormStartTime] = useState(null); - // temporary error state for embedded experience because we don't want to show errors on blur - const [temporaryErrors, setTemporaryErrors] = useState({ ...backedUpFormData.errors }); - - const { cta, host } = queryParams; - const buttonLabel = cta - ? formatMessage(messages['create.account.cta.button'], { label: cta }) - : formatMessage(messages['create.account.for.free.button']); /** * Set the userPipelineDetails data in formFields for only first time @@ -156,13 +147,9 @@ const RegistrationPage = (props) => { useEffect(() => { if (backendValidations) { - if (registrationEmbedded) { - setTemporaryErrors(prevErrors => ({ ...prevErrors, ...backendValidations })); - } else { - setErrors(prevErrors => ({ ...prevErrors, ...backendValidations })); - } + setErrors(prevErrors => ({ ...prevErrors, ...backendValidations })); } - }, [backendValidations, registrationEmbedded]); + }, [backendValidations]); useEffect(() => { if (registrationErrorCode) { @@ -206,23 +193,10 @@ const RegistrationPage = (props) => { }; const handleErrorChange = (fieldName, error) => { - if (registrationEmbedded) { - setTemporaryErrors(prevErrors => ({ - ...prevErrors, - [fieldName]: error, - })); - if (error === '' && errors[fieldName] !== '') { - setErrors(prevErrors => ({ - ...prevErrors, - [fieldName]: error, - })); - } - } else { - setErrors(prevErrors => ({ - ...prevErrors, - [fieldName]: error, - })); - } + setErrors(prevErrors => ({ + ...prevErrors, + [fieldName]: error, + })); }; const registerUser = () => { @@ -237,7 +211,7 @@ const RegistrationPage = (props) => { // Validating form data before submitting const { isValid, fieldErrors } = isFormValid( payload, - registrationEmbedded ? temporaryErrors : errors, + errors, configurableFormFields, fieldDescriptions, formatMessage, @@ -288,13 +262,11 @@ const RegistrationPage = (props) => { {formatMessage(messages['register.page.title'], { siteName: getConfig().SITE_NAME })} { ) : ( -
+
{ email={formFields.email} fieldErrors={errors} formFields={configurableFormFields} - setFieldErrors={registrationEmbedded ? setTemporaryErrors : setErrors} + setFieldErrors={setErrors} setFormFields={setConfigurableFormFields} autoSubmitRegisterForm={autoSubmitRegForm} fieldDescriptions={fieldDescriptions} @@ -378,21 +345,19 @@ const RegistrationPage = (props) => { className="register-button mt-4 mb-4" state={submitState} labels={{ - default: buttonLabel, + default: formatMessage(messages['create.account.for.free.button']), pending: '', }} onClick={handleSubmit} onMouseDown={(e) => e.preventDefault()} /> - {!registrationEmbedded && ( - - )} +
)} diff --git a/src/register/RegistrationPage.test.jsx b/src/register/RegistrationPage.test.jsx index 5310f1623c..c7dc132694 100644 --- a/src/register/RegistrationPage.test.jsx +++ b/src/register/RegistrationPage.test.jsx @@ -503,14 +503,6 @@ describe('RegistrationPage', () => { expect(registrationPage.find('.institutions__heading').text()).toEqual('Register with institution/campus credentials'); }); - it('should show button label based on cta query params value', () => { - const buttonLabel = 'Register'; - delete window.location; - window.location = { href: getConfig().BASE_URL, search: `?cta=${buttonLabel}` }; - const registrationPage = mount(reduxWrapper()); - expect(registrationPage.find('button[type="submit"] span').first().text()).toEqual(buttonLabel); - }); - it('should not display password field when current provider is present', () => { store = mockStore({ ...initialState, @@ -892,96 +884,6 @@ describe('RegistrationPage', () => { expect(registrationPage.find('.email-suggestion-alert-warning').first().text()).toEqual('john.doe@hotmail.com'); }); - // ********* Embedded experience tests *********/ - - it('should call the postMessage API when embedded variant is rendered', () => { - getLocale.mockImplementation(() => ('en-us')); - mergeConfig({ - ENABLE_PROGRESSIVE_PROFILING_ON_AUTHN: true, - }); - - window.parent.postMessage = jest.fn(); - - delete window.location; - window.location = { href: getConfig().BASE_URL.concat(AUTHN_PROGRESSIVE_PROFILING), search: '?host=http://localhost/host-website' }; - - store = mockStore({ - ...initialState, - register: { - ...initialState.register, - registrationResult: { - success: true, - }, - }, - commonComponents: { - ...initialState.commonComponents, - optionalFields: { - extended_profile: {}, - fields: { - level_of_education: { name: 'level_of_education', error_message: false }, - }, - }, - }, - }); - const progressiveProfilingPage = mount(reduxWrapper( - , - )); - progressiveProfilingPage.update(); - expect(window.parent.postMessage).toHaveBeenCalledTimes(2); - }); - - it('should not display validations error on blur event when embedded variant is rendered', () => { - delete window.location; - window.location = { href: getConfig().BASE_URL.concat(REGISTER_PAGE), search: '?host=http://localhost/host-website' }; - const registrationPage = mount(reduxWrapper()); - - registrationPage.find('input#username').simulate('blur', { target: { value: '', name: 'username' } }); - expect(registrationPage.find('div[feedback-for="username"]').exists()).toBeFalsy(); - - registrationPage.find('input[name="country"]').simulate('blur', { target: { value: '', name: 'country' } }); - expect(registrationPage.find('div[feedback-for="country"]').exists()).toBeFalsy(); - }); - - it('should set errors in temporary state when validations are returned by registration api', () => { - delete window.location; - window.location = { href: getConfig().BASE_URL.concat(REGISTER_PAGE), search: '?host=http://localhost/host-website' }; - - const usernameError = 'It looks like this username is already taken'; - const emailError = 'This email is already associated with an existing or previous account'; - store = mockStore({ - ...initialState, - register: { - ...initialState.register, - registrationError: { - username: [{ userMessage: usernameError }], - email: [{ userMessage: emailError }], - }, - }, - }); - const registrationPage = mount(routerWrapper(reduxWrapper( - ), - )).find('RegistrationPage'); - - expect(registrationPage.find('div[feedback-for="username"]').exists()).toBeFalsy(); - expect(registrationPage.find('div[feedback-for="email"]').exists()).toBeFalsy(); - }); - - it('should clear error on focus for embedded experience also', () => { - delete window.location; - window.location = { - href: getConfig().BASE_URL.concat(REGISTER_PAGE), - search: '?host=http://localhost/host-website', - }; - - const registrationPage = mount(routerWrapper(reduxWrapper())); - registrationPage.find('button.btn-brand').simulate('click'); - - 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(); - }); - it('should show spinner instead of form while registering if autoSubmitRegForm is true', () => { jest.spyOn(global.Date, 'now').mockImplementation(() => 0); getLocale.mockImplementation(() => ('en-us')); diff --git a/src/register/components/EmbeddableRegistrationPage.jsx b/src/register/components/EmbeddableRegistrationPage.jsx new file mode 100644 index 0000000000..e4388c5983 --- /dev/null +++ b/src/register/components/EmbeddableRegistrationPage.jsx @@ -0,0 +1,476 @@ +import React, { + useEffect, useMemo, useState, +} from 'react'; +import { connect } from 'react-redux'; + +import { getConfig, snakeCaseObject } from '@edx/frontend-platform'; +import { sendPageEvent } from '@edx/frontend-platform/analytics'; +import { + getCountryList, getLocale, useIntl, +} from '@edx/frontend-platform/i18n'; +import { Form, StatefulButton } from '@edx/paragon'; +import PropTypes from 'prop-types'; +import { Helmet } from 'react-helmet'; + +import ConfigurableRegistrationForm from './ConfigurableRegistrationForm'; +import { + clearRegistertionBackendError, + clearUsernameSuggestions, + fetchRealtimeValidations, + registerNewUser, +} from '../data/actions'; +import { + COUNTRY_CODE_KEY, + COUNTRY_DISPLAY_KEY, + FORM_SUBMISSION_ERROR, +} from '../data/constants'; +import { registrationErrorSelector, validationsSelector } from './data/selectors'; +import messages from '../messages'; +import RegistrationFailure from './RegistrationFailure'; +import { EmailField, UsernameField } from '../RegistrationFields'; +import { + FormGroup, PasswordField, +} from '../../common-components'; +import { getThirdPartyAuthContext } from '../../common-components/data/actions'; +import { + fieldDescriptionSelector, +} from '../../common-components/data/selectors'; +import { + DEFAULT_STATE, REDIRECT, +} from '../../data/constants'; +import { + getAllPossibleQueryParams, setCookie, +} from '../../data/utils'; + +const EmbeddableRegistrationPage = (props) => { + const { + backendCountryCode, + backendValidations, + fieldDescriptions, + registrationError, + registrationErrorCode, + registrationResult, + submitState, + usernameSuggestions, + validationApiRateLimited, + // Actions + getRegistrationDataFromBackend, + validateFromBackend, + clearBackendError, + } = props; + + const { formatMessage } = useIntl(); + const countryList = useMemo(() => getCountryList(getLocale()), []); + const queryParams = useMemo(() => getAllPossibleQueryParams(), []); + const { cta, host } = queryParams; + const flags = { + showConfigurableEdxFields: getConfig().SHOW_CONFIGURABLE_EDX_FIELDS, + showConfigurableRegistrationFields: getConfig().ENABLE_DYNAMIC_REGISTRATION_FIELDS, + showMarketingEmailOptInCheckbox: getConfig().MARKETING_EMAILS_OPT_IN, + }; + + const [formFields, setFormFields] = useState({ + email: '', + name: '', + password: '', + username: '', + }); + const [configurableFormFields, setConfigurableFormFields] = useState({ + marketingEmailsOptIn: true, + }); + const [errors, setErrors] = useState({ + email: '', + name: '', + password: '', + username: '', + }); + const [emailSuggestion, setEmailSuggestion] = useState({ suggestion: '', type: '' }); + const [errorCode, setErrorCode] = useState({ type: '', count: 0 }); + const [formStartTime, setFormStartTime] = useState(null); + const [, setFocusedField] = useState(null); + + const buttonLabel = cta ? formatMessage(messages['create.account.cta.button'], { label: cta }) : formatMessage(messages['create.account.for.free.button']); + + useEffect(() => { + if (!formStartTime) { + sendPageEvent('login_and_registration', 'register'); + const payload = { ...queryParams, is_register_page: true }; + getRegistrationDataFromBackend(payload); + setFormStartTime(Date.now()); + } + }, [formStartTime, getRegistrationDataFromBackend, queryParams]); + + useEffect(() => { + if (backendValidations) { + setErrors(prevErrors => ({ ...prevErrors, ...backendValidations })); + } + }, [backendValidations]); + + useEffect(() => { + if (registrationErrorCode) { + setErrorCode(prevState => ({ type: registrationErrorCode, count: prevState.count + 1 })); + } + }, [registrationErrorCode]); + + useEffect(() => { + if (backendCountryCode && backendCountryCode !== configurableFormFields?.country?.countryCode) { + let countryCode = ''; + let countryDisplayValue = ''; + + const selectedCountry = countryList.find( + (country) => (country[COUNTRY_CODE_KEY].toLowerCase() === backendCountryCode.toLowerCase()), + ); + if (selectedCountry) { + countryCode = selectedCountry[COUNTRY_CODE_KEY]; + countryDisplayValue = selectedCountry[COUNTRY_DISPLAY_KEY]; + } + setConfigurableFormFields(prevState => ( + { + ...prevState, + country: { + countryCode, displayValue: countryDisplayValue, + }, + } + )); + } + }, [backendCountryCode, countryList]); // eslint-disable-line react-hooks/exhaustive-deps + + /** + * 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(prevState => ({ ...prevState, username: ' ' })); + } + }, [usernameSuggestions, formFields]); + + useEffect(() => { + if (registrationResult.success) { + // Optimizely registration conversion event + window.optimizely = window.optimizely || []; + window.optimizely.push({ + type: 'event', + eventName: 'authn-registration-conversion', + }); + + // We probably don't need this cookie because this fires the same event as + // above for optimizely using GTM. + setCookie(getConfig().REGISTER_CONVERSION_COOKIE_NAME, true); + // This is used by the "User Retention Rate Event" on GTM + setCookie('authn-returning-user'); + + // Fire GTM event used for integration with impact.com + window.dataLayer = window.dataLayer || []; + window.dataLayer.push({ + event: 'ImpactRegistrationEvent', + }); + + window.parent.postMessage({ + action: REDIRECT, + redirectUrl: encodeURIComponent(getConfig().POST_REGISTRATION_REDIRECT_URL), + }, host); + } + }, [registrationResult, host]); + + const validateInput = (fieldName, value, payload, shouldValidateFromBackend) => { + switch (fieldName) { + case 'name': + if (value && !payload.username.trim() && shouldValidateFromBackend) { + validateFromBackend(payload); + } + break; + default: + break; + } + }; + + const isFormValid = (payload) => { + const fieldErrors = { ...errors }; + let isValid = true; + Object.keys(payload).forEach(key => { + if (!payload[key]) { + fieldErrors[key] = formatMessage(messages[`empty.${key}.field.error`]); + } + if (fieldErrors[key]) { + isValid = false; + } + }); + + if (flags.showConfigurableEdxFields) { + if (!configurableFormFields.country.displayValue) { + fieldErrors.country = 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] = formatMessage(messages['empty.country.field.error']); + } else if (!configurableFormFields[key]) { + fieldErrors[key] = fieldDescriptions[key].error_message; + } + if (fieldErrors[key]) { + isValid = false; + } + }); + } + setErrors({ ...fieldErrors }); + return isValid; + }; + + const handleSuggestionClick = (event, fieldName, suggestion = '') => { + event.preventDefault(); + setErrors(prevErrors => ({ ...prevErrors, [fieldName]: '' })); + switch (fieldName) { + case 'username': + setFormFields(prevState => ({ ...prevState, username: suggestion })); + props.resetUsernameSuggestions(); + break; + default: + break; + } + }; + + const handleEmailSuggestionClosed = () => setEmailSuggestion({ suggestion: '', type: '' }); + + const handleUsernameSuggestionClosed = () => props.resetUsernameSuggestions(); + + const handleOnChange = (event) => { + const { name } = event.target; + let value = event.target.type === 'checkbox' ? event.target.checked : event.target.value; + if (registrationError[name]) { + clearBackendError(name); + setErrors(prevErrors => ({ ...prevErrors, [name]: '' })); + } + if (name === 'username') { + if (value.length > 30) { + return; + } + if (value.startsWith(' ')) { + value = value.trim(); + } + } + + setFormFields(prevState => ({ ...prevState, [name]: value })); + }; + + const handleOnBlur = (event) => { + const { name, value } = event.target; + + if (name === 'name') { + validateInput( + name, + value, + { name: formFields.name, username: formFields.username, form_field_key: name }, + !validationApiRateLimited, + ); + } + }; + + const handleOnFocus = (event) => { + const { name, value } = event.target; + setErrors(prevErrors => ({ ...prevErrors, [name]: '' })); + clearBackendError(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(prevState => ({ ...prevState, [name]: '' })); + } + } + }; + + const handleSubmit = (e) => { + e.preventDefault(); + const totalRegistrationTime = (Date.now() - formStartTime) / 1000; + let payload = { ...formFields }; + + if (!isFormValid(payload)) { + setErrorCode(prevState => ({ type: FORM_SUBMISSION_ERROR, count: prevState.count + 1 })); + return; + } + + Object.keys(configurableFormFields).forEach((fieldName) => { + if (fieldName === 'country') { + payload[fieldName] = configurableFormFields[fieldName].countryCode; + } else { + payload[fieldName] = configurableFormFields[fieldName]; + } + }); + + // Don't send the marketing email opt-in value if the flag is turned off + if (!flags.showMarketingEmailOptInCheckbox) { + delete payload.marketingEmailsOptIn; + } + + payload = snakeCaseObject(payload); + payload.totalRegistrationTime = totalRegistrationTime; + + // add query params to the payload + payload = { ...payload, ...queryParams }; + props.registerNewUser(payload); + }; + + return ( + <> + + {formatMessage(messages['register.page.title'], { siteName: getConfig().SITE_NAME })} + +
+ +
+ + + + + + e.preventDefault()} + /> + +
+ + + ); +}; + +const mapStateToProps = state => { + const registerPageState = state.register; + return { + backendCountryCode: registerPageState.backendCountryCode, + backendValidations: validationsSelector(state), + fieldDescriptions: fieldDescriptionSelector(state), + registrationError: registerPageState.registrationError, + registrationErrorCode: registrationErrorSelector(state), + registrationResult: registerPageState.registrationResult, + submitState: registerPageState.submitState, + validationApiRateLimited: registerPageState.validationApiRateLimited, + usernameSuggestions: registerPageState.usernameSuggestions, + }; +}; + +EmbeddableRegistrationPage.propTypes = { + backendCountryCode: PropTypes.string, + backendValidations: PropTypes.shape({ + name: PropTypes.string, + email: PropTypes.string, + username: PropTypes.string, + password: PropTypes.string, + }), + fieldDescriptions: PropTypes.shape({}), + registrationError: PropTypes.shape({}), + registrationErrorCode: PropTypes.string, + registrationResult: PropTypes.shape({ + redirectUrl: PropTypes.string, + success: PropTypes.bool, + }), + submitState: PropTypes.string, + usernameSuggestions: PropTypes.arrayOf(PropTypes.string), + validationApiRateLimited: PropTypes.bool, + // Actions + clearBackendError: PropTypes.func.isRequired, + getRegistrationDataFromBackend: PropTypes.func.isRequired, + registerNewUser: PropTypes.func.isRequired, + resetUsernameSuggestions: PropTypes.func.isRequired, + validateFromBackend: PropTypes.func.isRequired, +}; + +EmbeddableRegistrationPage.defaultProps = { + backendCountryCode: '', + backendValidations: null, + fieldDescriptions: {}, + registrationError: {}, + registrationErrorCode: '', + registrationResult: null, + submitState: DEFAULT_STATE, + usernameSuggestions: [], + validationApiRateLimited: false, +}; + +export default connect( + mapStateToProps, + { + clearBackendError: clearRegistertionBackendError, + getRegistrationDataFromBackend: getThirdPartyAuthContext, + resetUsernameSuggestions: clearUsernameSuggestions, + validateFromBackend: fetchRealtimeValidations, + registerNewUser, + }, +)(EmbeddableRegistrationPage); diff --git a/src/register/components/ConfigurableRegistrationForm.test.jsx b/src/register/components/tests/ConfigurableRegistrationForm.test.jsx similarity index 97% rename from src/register/components/ConfigurableRegistrationForm.test.jsx rename to src/register/components/tests/ConfigurableRegistrationForm.test.jsx index 6f42eb75ff..c22e609108 100644 --- a/src/register/components/ConfigurableRegistrationForm.test.jsx +++ b/src/register/components/tests/ConfigurableRegistrationForm.test.jsx @@ -9,8 +9,8 @@ import { mount } from 'enzyme'; import { BrowserRouter as Router } from 'react-router-dom'; import configureStore from 'redux-mock-store'; -import ConfigurableRegistrationForm from './ConfigurableRegistrationForm'; -import { FIELDS } from '../data/constants'; +import ConfigurableRegistrationForm from '../ConfigurableRegistrationForm'; +import { FIELDS } from '../../data/constants'; jest.mock('@edx/frontend-platform/analytics', () => ({ sendPageEvent: jest.fn(), diff --git a/src/register/components/tests/EmbeddableRegistrationPage.test.jsx b/src/register/components/tests/EmbeddableRegistrationPage.test.jsx new file mode 100644 index 0000000000..1ec3662568 --- /dev/null +++ b/src/register/components/tests/EmbeddableRegistrationPage.test.jsx @@ -0,0 +1,616 @@ +import React from 'react'; +import { Provider } from 'react-redux'; + +import { getConfig, mergeConfig } from '@edx/frontend-platform'; +import { sendPageEvent } from '@edx/frontend-platform/analytics'; +import { + configure, getLocale, injectIntl, IntlProvider, +} from '@edx/frontend-platform/i18n'; +import { mount } from 'enzyme'; +import { act } from 'react-dom/test-utils'; +import renderer from 'react-test-renderer'; +import configureStore from 'redux-mock-store'; + +import { + PENDING_STATE, +} from '../../../data/constants'; +import { + clearUsernameSuggestions, + registerNewUser, +} from '../../data/actions'; +import { + FIELDS, +} from '../../data/constants'; +import EmbeddableRegistrationPage from '../EmbeddableRegistrationPage'; + +jest.mock('@edx/frontend-platform/analytics', () => ({ + sendPageEvent: jest.fn(), + sendTrackEvent: jest.fn(), +})); +jest.mock('@edx/frontend-platform/i18n', () => ({ + ...jest.requireActual('@edx/frontend-platform/i18n'), + getLocale: jest.fn(), +})); + +const IntlEmbedableRegistrationForm = injectIntl(EmbeddableRegistrationPage); +const mockStore = configureStore(); + +describe('RegistrationPage', () => { + mergeConfig({ + PRIVACY_POLICY: 'https://privacy-policy.com', + TOS_AND_HONOR_CODE: 'https://tos-and-honot-code.com', + REGISTER_CONVERSION_COOKIE_NAME: process.env.REGISTER_CONVERSION_COOKIE_NAME, + }); + + let props = {}; + let store = {}; + const registrationFormData = { + configurableFormFields: { + marketingEmailsOptIn: true, + }, + formFields: { + name: '', email: '', username: '', password: '', + }, + emailSuggestion: { + suggestion: '', type: '', + }, + errors: { + name: '', email: '', username: '', password: '', + }, + }; + + const reduxWrapper = children => ( + + {children} + + ); + + const initialState = { + register: { + registrationResult: { success: false, redirectUrl: '' }, + registrationError: {}, + registrationFormData, + }, + }; + + beforeEach(() => { + store = mockStore(initialState); + window.parent.postMessage = jest.fn(); + configure({ + loggingService: { logError: jest.fn() }, + config: { + ENVIRONMENT: 'production', + LANGUAGE_PREFERENCE_COOKIE_NAME: 'yum', + }, + messages: { 'es-419': {}, de: {}, 'en-us': {} }, + }); + props = { + registrationResult: jest.fn(), + handleInstitutionLogin: jest.fn(), + institutionLogin: false, + }; + window.location = { search: '' }; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + const populateRequiredFields = (registrationPage, payload, isThirdPartyAuth = false) => { + registrationPage.find('input#name').simulate('change', { target: { value: payload.name, name: 'name' } }); + registrationPage.find('input#username').simulate('change', { target: { value: payload.username, name: 'username' } }); + registrationPage.find('input#email').simulate('change', { target: { value: payload.email, name: 'email' } }); + + registrationPage.find('input[name="country"]').simulate('change', { target: { value: payload.country, name: 'country' } }); + registrationPage.find('input[name="country"]').simulate('blur', { target: { value: payload.country, name: 'country' } }); + + if (!isThirdPartyAuth) { + registrationPage.find('input#password').simulate('change', { target: { value: payload.password, name: 'password' } }); + } + }; + + describe('Test Registration Page', () => { + mergeConfig({ + SHOW_CONFIGURABLE_EDX_FIELDS: true, + }); + + const emptyFieldValidation = { + name: 'Enter your full name', + username: 'Username must be between 2 and 30 characters', + email: 'Enter your email', + password: 'Password criteria has not been met', + country: 'Select your country or region of residence', + }; + + // ******** 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; + window.location = { href: getConfig().BASE_URL, search: '?next=/course/demo-course-url' }; + + const payload = { + name: 'John Doe', + username: 'john_doe', + email: 'john.doe@gmail.com', + password: 'password1', + country: 'Pakistan', + honor_code: true, + totalRegistrationTime: 0, + next: '/course/demo-course-url', + }; + + store.dispatch = jest.fn(store.dispatch); + const registrationPage = mount(reduxWrapper()); + populateRequiredFields(registrationPage, payload); + registrationPage.find('button.btn-brand').simulate('click'); + expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK' })); + }); + + it('should submit form with marketing email opt in value', () => { + mergeConfig({ + MARKETING_EMAILS_OPT_IN: 'true', + }); + + jest.spyOn(global.Date, 'now').mockImplementation(() => 0); + + const payload = { + name: 'John Doe', + username: 'john_doe', + email: 'john.doe@gmail.com', + password: 'password1', + country: 'Pakistan', + honor_code: true, + totalRegistrationTime: 0, + marketing_emails_opt_in: true, + }; + + store.dispatch = jest.fn(store.dispatch); + const registrationPage = mount(reduxWrapper()); + populateRequiredFields(registrationPage, payload); + registrationPage.find('button.btn-brand').simulate('click'); + expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK' })); + + mergeConfig({ + MARKETING_EMAILS_OPT_IN: '', + }); + }); + + it('should not dispatch registerNewUser on empty form Submission', () => { + store.dispatch = jest.fn(store.dispatch); + + const registrationPage = mount(reduxWrapper()); + registrationPage.find('button.btn-brand').simulate('click'); + expect(store.dispatch).not.toHaveBeenCalledWith(registerNewUser({})); + }); + + // // ******** test registration form validations ******** + + it('should show error messages for required fields on empty form submission', () => { + const registrationPage = mount(reduxWrapper()); + registrationPage.find('button.btn-brand').simulate('click'); + + expect(registrationPage.find('div[feedback-for="name"]').text()).toEqual(emptyFieldValidation.name); + expect(registrationPage.find('div[feedback-for="username"]').text()).toEqual(emptyFieldValidation.username); + expect(registrationPage.find('div[feedback-for="email"]').text()).toEqual(emptyFieldValidation.email); + expect(registrationPage.find('div[feedback-for="password"]').text()).toContain(emptyFieldValidation.password); + 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.'; + expect(registrationPage.find('#validation-errors').first().text()).toEqual(alertBanner); + }); + + it('should run validations for focused field on form submission', () => { + const registrationPage = mount(reduxWrapper()); + registrationPage.find('input[name="country"]').simulate('focus'); + registrationPage.find('button.btn-brand').simulate('click'); + + expect(registrationPage.find('div[feedback-for="country"]').text()).toEqual(emptyFieldValidation.country); + }); + + it('should update props with validations returned by registration api', () => { + store = mockStore({ + ...initialState, + register: { + ...initialState.register, + registrationError: { + username: [{ userMessage: 'It looks like this username is already taken' }], + email: [{ userMessage: `This email is already associated with an existing or previous ${ getConfig().SITE_NAME } account` }], + }, + }, + }); + const registrationPage = mount(reduxWrapper()).find('EmbeddableRegistrationPage'); + 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 remove space from the start of username', () => { + const registrationPage = mount(reduxWrapper()); + registrationPage.find('input#username').simulate('change', { target: { value: ' test-user', name: 'username' } }); + + expect(registrationPage.find('input#username').prop('value')).toEqual('test-user'); + }); + it('should remove extra character if username is more than 30 character long', () => { + const registrationPage = mount(reduxWrapper()); + registrationPage.find('input#username').simulate('change', { target: { value: 'why_this_is_not_valid_username_', name: 'username' } }); + + expect(registrationPage.find('input#username').prop('value')).toEqual(''); + }); + + // // ******** test field focus in functionality ******** + + it('should clear field related error messages on input field Focus', () => { + 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[name="country"]').simulate('focus'); + expect(registrationPage.find('div[feedback-for="country"]').exists()).toBeFalsy(); + }); + + it('should clear username suggestions when username field is focused in', () => { + store.dispatch = jest.fn(store.dispatch); + + const registrationPage = mount(reduxWrapper()); + registrationPage.find('input#username').simulate('focus'); + + expect(store.dispatch).toHaveBeenCalledWith(clearUsernameSuggestions()); + }); + + it('should call backend api for username suggestions when input the name field', () => { + store.dispatch = jest.fn(store.dispatch); + const registrationPage = mount(reduxWrapper()); + + registrationPage.find('input#name').simulate('focus'); + registrationPage.find('input#name').simulate('change', { target: { value: 'ahtesham', name: 'name' } }); + registrationPage.find('input#name').simulate('blur'); + + expect(store.dispatch).toHaveBeenCalledTimes(4); + }); + + // // ******** test form buttons and fields ******** + + it('should match default button state', () => { + const registrationPage = mount(reduxWrapper()); + expect(registrationPage.find('button[type="submit"] span').first().text()) + .toEqual('Create an account for free'); + }); + + it('should match pending button state', () => { + store = mockStore({ + ...initialState, + register: { + ...initialState.register, + submitState: PENDING_STATE, + }, + }); + + const registrationPage = mount(reduxWrapper()); + const button = registrationPage.find('button[type="submit"] span').first(); + + expect(button.find('.sr-only').text()).toEqual('pending'); + }); + + it('should display opt-in/opt-out checkbox', () => { + mergeConfig({ + MARKETING_EMAILS_OPT_IN: 'true', + }); + + const registrationPage = mount(reduxWrapper()); + expect(registrationPage.find('div.form-field--checkbox').length).toEqual(1); + + mergeConfig({ + MARKETING_EMAILS_OPT_IN: '', + }); + }); + + it('should show button label based on cta query params value', () => { + const buttonLabel = 'Register'; + delete window.location; + window.location = { href: getConfig().BASE_URL, search: `?cta=${buttonLabel}` }; + const registrationPage = mount(reduxWrapper()); + expect(registrationPage.find('button[type="submit"] span').first().text()).toEqual(buttonLabel); + }); + + it('should check registration conversion cookie', () => { + store = mockStore({ + ...initialState, + register: { + ...initialState.register, + registrationResult: { + success: true, + }, + }, + }); + + renderer.create(reduxWrapper()); + expect(document.cookie).toMatch(`${getConfig().REGISTER_CONVERSION_COOKIE_NAME}=true`); + }); + + it('should show username suggestions in case of conflict with an existing username', () => { + store = mockStore({ + ...initialState, + register: { + ...initialState.register, + usernameSuggestions: ['test_1', 'test_12', 'test_123'], + registrationFormData: { + ...registrationFormData, + errors: { + ...registrationFormData.errors, + username: 'It looks like this username is already taken', + }, + }, + }, + }); + + const registrationPage = mount(reduxWrapper()); + expect(registrationPage.find('button.username-suggestions--chip').length).toEqual(3); + }); + + it('should show username suggestions when full name is populated', () => { + store = mockStore({ + ...initialState, + register: { + ...initialState.register, + usernameSuggestions: ['test_1', 'test_12', 'test_123'], + registrationFormData: { + ...registrationFormData, + username: ' ', + }, + }, + }); + + const registrationPage = mount(reduxWrapper()); + registrationPage.find('input#name').simulate('change', { target: { value: 'test name', name: 'name' } }); + + expect(registrationPage.find('button.username-suggestions--chip').length).toEqual(3); + }); + + it('should remove empty space from username field when it is focused', async () => { + store = mockStore({ + ...initialState, + register: { + ...initialState.register, + usernameSuggestions: ['test_1', 'test_12', 'test_123'], + registrationFormData: { + ...registrationFormData, + username: ' ', + }, + }, + }); + + const registrationPage = mount(reduxWrapper()); + registrationPage.find('input#name').simulate('change', { target: { value: 'test name', name: 'name' } }); + registrationPage.find('input#username').simulate('focus'); + await act(async () => { await expect(registrationPage.find('input#username').text()).toEqual(''); }); + }); + + it('should click on username suggestions when full name is populated', () => { + store = mockStore({ + ...initialState, + register: { + ...initialState.register, + usernameSuggestions: ['test_1', 'test_12', 'test_123'], + registrationFormData: { + ...registrationFormData, + username: ' ', + }, + }, + }); + + const registrationPage = mount(reduxWrapper()); + registrationPage.find('input#name').simulate('change', { target: { value: 'test name', name: 'name' } }); + registrationPage.find('.username-suggestions--chip').first().simulate('click'); + expect(registrationPage.find('input#username').props().value).toEqual('test_1'); + }); + + it('should clear username suggestions when close icon is clicked', () => { + store = mockStore({ + ...initialState, + register: { + ...initialState.register, + usernameSuggestions: ['test_1', 'test_12', 'test_123'], + registrationFormData: { + ...registrationFormData, + username: ' ', + }, + }, + }); + store.dispatch = jest.fn(store.dispatch); + + const registrationPage = mount(reduxWrapper()); + registrationPage.find('input#name').simulate('change', { target: { value: 'test name', name: 'name' } }); + registrationPage.find('button.username-suggestions__close__button').at(0).simulate('click'); + expect(store.dispatch).toHaveBeenCalledWith(clearUsernameSuggestions()); + }); + + // // ******** miscellaneous tests ******** + + it('should send page event when register page is rendered', () => { + mount(reduxWrapper()); + expect(sendPageEvent).toHaveBeenCalledWith('login_and_registration', 'register'); + }); + + it('should update state from country code present in redux store', () => { + store = mockStore({ + ...initialState, + register: { + ...initialState.register, + backendCountryCode: 'PK', + }, + }); + + const registrationPage = mount(reduxWrapper()); + expect(registrationPage.find('input[name="country"]').props().value).toEqual('Pakistan'); + }); + + it('should set country in component state when form is translated used i18n', () => { + getLocale.mockImplementation(() => ('ar-ae')); + + const registrationPage = mount(reduxWrapper()); + registrationPage.find('input[name="country"]').simulate('click'); + registrationPage.find('button.dropdown-item').at(0).simulate('click', { target: { value: 'أفغانستان ', name: 'countryItem' } }); + expect(registrationPage.find('div[feedback-for="country"]').exists()).toBeFalsy(); + }); + + it('should clear the registation validation error on change event on field focused', () => { + store = mockStore({ + ...initialState, + register: { + ...initialState.register, + registrationError: { + errorCode: 'duplicate-email', + email: [{ userMessage: `This email is already associated with an existing or previous ${ getConfig().SITE_NAME } account` }], + }, + }, + }); + + store.dispatch = jest.fn(store.dispatch); + const clearBackendError = jest.fn(); + const registrationPage = mount(reduxWrapper()); + registrationPage.find('input#email').simulate('change', { target: { value: 'a@gmail.com', name: 'email' } }); + expect(registrationPage.find('div[feedback-for="email"]').exists()).toBeFalsy(); + }); + }); + + describe('Test Configurable Fields', () => { + mergeConfig({ + ENABLE_DYNAMIC_REGISTRATION_FIELDS: true, + SHOW_CONFIGURABLE_EDX_FIELDS: true, + }); + + it('should render fields returned by backend', () => { + store = mockStore({ + ...initialState, + commonComponents: { + ...initialState.commonComponents, + fieldDescriptions: { + profession: { name: 'profession', type: 'text', label: 'Profession' }, + terms_of_service: { + name: FIELDS.TERMS_OF_SERVICE, + error_message: 'You must agree to the Terms and Service agreement of our site', + }, + }, + }, + }); + const registrationPage = mount(reduxWrapper()); + expect(registrationPage.find('#profession').exists()).toBeTruthy(); + expect(registrationPage.find('#tos').exists()).toBeTruthy(); + }); + + it('should submit form with fields returned by backend in payload', () => { + getLocale.mockImplementation(() => ('en-us')); + jest.spyOn(global.Date, 'now').mockImplementation(() => 0); + store = mockStore({ + ...initialState, + commonComponents: { + ...initialState.commonComponents, + fieldDescriptions: { + profession: { name: 'profession', type: 'text', label: 'Profession' }, + }, + extendedProfile: ['profession'], + }, + }); + + const payload = { + name: 'John Doe', + username: 'john_doe', + email: 'john.doe@example.com', + password: 'password1', + country: 'Pakistan', + honor_code: true, + profession: 'Engineer', + totalRegistrationTime: 0, + }; + + store.dispatch = jest.fn(store.dispatch); + const registrationPage = mount(reduxWrapper()); + + populateRequiredFields(registrationPage, payload); + registrationPage.find('input#profession').simulate('change', { target: { value: 'Engineer', name: 'profession' } }); + registrationPage.find('button.btn-brand').simulate('click'); + expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK' })); + }); + + it('should show error messages for required fields on empty form submission', () => { + const professionError = 'Enter your profession'; + const countryError = 'Select your country or region of residence'; + const confirmEmailError = 'Enter your email'; + + store = mockStore({ + ...initialState, + commonComponents: { + ...initialState.commonComponents, + fieldDescriptions: { + profession: { + name: 'profession', type: 'text', label: 'Profession', error_message: professionError, + }, + confirm_email: { + name: 'confirm_email', type: 'text', label: 'Confirm Email', error_message: confirmEmailError, + }, + country: { name: 'country' }, + }, + }, + }); + + const registrationPage = mount(reduxWrapper()); + registrationPage.find('button.btn-brand').simulate('click'); + + expect(registrationPage.find('#profession-error').last().text()).toEqual(professionError); + expect(registrationPage.find('div[feedback-for="country"]').text()).toEqual(countryError); + expect(registrationPage.find('#confirm_email-error').last().text()).toEqual(confirmEmailError); + }); + + it('should run validations for configurable focused field on form submission', () => { + const professionError = 'Enter your profession'; + store = mockStore({ + ...initialState, + commonComponents: { + ...initialState.commonComponents, + fieldDescriptions: { + profession: { + name: 'profession', type: 'text', label: 'Profession', error_message: professionError, + }, + }, + }, + }); + + const registrationPage = mount(reduxWrapper()); + registrationPage.find('input#profession').simulate('focus', { target: { value: '', name: 'profession' } }); + registrationPage.find('button.btn-brand').simulate('click'); + + expect(registrationPage.find('#profession-error').last().text()).toEqual(professionError); + }); + + it('should not run country field validation when onBlur is fired by drop-down arrow icon click', () => { + getLocale.mockImplementation(() => ('en-us')); + + const registrationPage = mount(reduxWrapper()); + registrationPage.find('input[name="country"]').simulate('blur', { + target: { value: '', name: 'country' }, + relatedTarget: { type: 'button', className: 'btn-icon pgn__form-autosuggest__icon-button' }, + }); + expect(registrationPage.find('div[feedback-for="country"]').exists()).toBeFalsy(); + }); + }); +}); diff --git a/src/register/index.js b/src/register/index.js index eb48077a18..8b37e68972 100644 --- a/src/register/index.js +++ b/src/register/index.js @@ -1,4 +1,5 @@ export { default as RegistrationPage } from './RegistrationPage'; +export { default as EmbeddableRegistrationPage } from './components/EmbeddableRegistrationPage'; export { default as reducer } from './data/reducers'; export { default as saga } from './data/sagas'; export { storeName } from './data/reducers'; From 68be7c65d5ab5cb0c4c15cad7c992deb72861180 Mon Sep 17 00:00:00 2001 From: Blue Date: Thu, 24 Aug 2023 15:24:00 +0500 Subject: [PATCH 2/3] fix: apply frontend validations (#1041) Description: Applied frontend validations on all the fields VAN-1614 --- .../components/EmbeddableRegistrationPage.jsx | 419 +++++------------- .../tests/EmbeddableRegistrationPage.test.jsx | 91 ++++ 2 files changed, 193 insertions(+), 317 deletions(-) diff --git a/src/register/components/EmbeddableRegistrationPage.jsx b/src/register/components/EmbeddableRegistrationPage.jsx index e4388c5983..762189525b 100644 --- a/src/register/components/EmbeddableRegistrationPage.jsx +++ b/src/register/components/EmbeddableRegistrationPage.jsx @@ -1,108 +1,84 @@ import React, { useEffect, useMemo, useState, } from 'react'; -import { connect } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; -import { getConfig, snakeCaseObject } from '@edx/frontend-platform'; -import { sendPageEvent } from '@edx/frontend-platform/analytics'; -import { - getCountryList, getLocale, useIntl, -} from '@edx/frontend-platform/i18n'; +import { getConfig } from '@edx/frontend-platform'; +import { sendPageEvent, sendTrackEvent } from '@edx/frontend-platform/analytics'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { Form, StatefulButton } from '@edx/paragon'; -import PropTypes from 'prop-types'; import { Helmet } from 'react-helmet'; import ConfigurableRegistrationForm from './ConfigurableRegistrationForm'; -import { - clearRegistertionBackendError, - clearUsernameSuggestions, - fetchRealtimeValidations, - registerNewUser, -} from '../data/actions'; -import { - COUNTRY_CODE_KEY, - COUNTRY_DISPLAY_KEY, - FORM_SUBMISSION_ERROR, -} from '../data/constants'; -import { registrationErrorSelector, validationsSelector } from './data/selectors'; -import messages from '../messages'; import RegistrationFailure from './RegistrationFailure'; -import { EmailField, UsernameField } from '../RegistrationFields'; -import { - FormGroup, PasswordField, -} from '../../common-components'; -import { getThirdPartyAuthContext } from '../../common-components/data/actions'; -import { - fieldDescriptionSelector, -} from '../../common-components/data/selectors'; -import { - DEFAULT_STATE, REDIRECT, -} from '../../data/constants'; -import { - getAllPossibleQueryParams, setCookie, -} from '../../data/utils'; - -const EmbeddableRegistrationPage = (props) => { - const { - backendCountryCode, - backendValidations, - fieldDescriptions, - registrationError, - registrationErrorCode, - registrationResult, - submitState, - usernameSuggestions, - validationApiRateLimited, - // Actions - getRegistrationDataFromBackend, - validateFromBackend, - clearBackendError, - } = props; +import { PasswordField } from '../../common-components'; +import { getThirdPartyAuthContext as getRegistrationDataFromBackend } from '../../common-components/data/actions'; +import { REDIRECT } from '../../data/constants'; +import { getAllPossibleQueryParams, setCookie } from '../../data/utils'; +import { clearRegistrationBackendError, registerNewUser } from '../data/actions'; +import { FORM_SUBMISSION_ERROR } from '../data/constants'; +import { getBackendValidations, isFormValid, prepareRegistrationPayload } from '../data/utils'; +import messages from '../messages'; +import { EmailField, NameField, UsernameField } from '../RegistrationFields'; +/** + * Main Registration Page component + */ +const EmbeddableRegistrationPage = () => { const { formatMessage } = useIntl(); - const countryList = useMemo(() => getCountryList(getLocale()), []); - const queryParams = useMemo(() => getAllPossibleQueryParams(), []); - const { cta, host } = queryParams; + const dispatch = useDispatch(); + const flags = { showConfigurableEdxFields: getConfig().SHOW_CONFIGURABLE_EDX_FIELDS, showConfigurableRegistrationFields: getConfig().ENABLE_DYNAMIC_REGISTRATION_FIELDS, showMarketingEmailOptInCheckbox: getConfig().MARKETING_EMAILS_OPT_IN, }; - const [formFields, setFormFields] = useState({ - email: '', - name: '', - password: '', - username: '', - }); - const [configurableFormFields, setConfigurableFormFields] = useState({ - marketingEmailsOptIn: true, - }); - const [errors, setErrors] = useState({ - email: '', - name: '', - password: '', - username: '', - }); - const [emailSuggestion, setEmailSuggestion] = useState({ suggestion: '', type: '' }); + const { + registrationFormData: backedUpFormData, + registrationError, + registrationError: { + errorCode: registrationErrorCode, + } = {}, + registrationResult, + submitState, + validations, + } = useSelector(state => state.register); + + const { fieldDescriptions } = useSelector(state => state.commonComponents); + + const backendValidations = useMemo( + () => getBackendValidations(registrationError, validations), [registrationError, validations], + ); + const queryParams = useMemo(() => getAllPossibleQueryParams(), []); + + const [formFields, setFormFields] = useState({ ...backedUpFormData.formFields }); + const [configurableFormFields, setConfigurableFormFields] = useState( + { ...backedUpFormData.configurableFormFields }, + ); + const [errors, setErrors] = useState({ ...backedUpFormData.errors }); const [errorCode, setErrorCode] = useState({ type: '', count: 0 }); const [formStartTime, setFormStartTime] = useState(null); - const [, setFocusedField] = useState(null); + // temporary error state for embedded experience because we don't want to show errors on blur + const [temporaryErrors, setTemporaryErrors] = useState({ ...backedUpFormData.errors }); - const buttonLabel = cta ? formatMessage(messages['create.account.cta.button'], { label: cta }) : formatMessage(messages['create.account.for.free.button']); + const { cta, host } = queryParams; + const buttonLabel = cta + ? formatMessage(messages['create.account.cta.button'], { label: cta }) + : formatMessage(messages['create.account.for.free.button']); useEffect(() => { if (!formStartTime) { sendPageEvent('login_and_registration', 'register'); const payload = { ...queryParams, is_register_page: true }; - getRegistrationDataFromBackend(payload); + dispatch(getRegistrationDataFromBackend(payload)); setFormStartTime(Date.now()); } - }, [formStartTime, getRegistrationDataFromBackend, queryParams]); + }, [dispatch, formStartTime, queryParams]); useEffect(() => { if (backendValidations) { - setErrors(prevErrors => ({ ...prevErrors, ...backendValidations })); + setTemporaryErrors(prevErrors => ({ ...prevErrors, ...backendValidations })); } }, [backendValidations]); @@ -112,41 +88,10 @@ const EmbeddableRegistrationPage = (props) => { } }, [registrationErrorCode]); - useEffect(() => { - if (backendCountryCode && backendCountryCode !== configurableFormFields?.country?.countryCode) { - let countryCode = ''; - let countryDisplayValue = ''; - - const selectedCountry = countryList.find( - (country) => (country[COUNTRY_CODE_KEY].toLowerCase() === backendCountryCode.toLowerCase()), - ); - if (selectedCountry) { - countryCode = selectedCountry[COUNTRY_CODE_KEY]; - countryDisplayValue = selectedCountry[COUNTRY_DISPLAY_KEY]; - } - setConfigurableFormFields(prevState => ( - { - ...prevState, - country: { - countryCode, displayValue: countryDisplayValue, - }, - } - )); - } - }, [backendCountryCode, countryList]); // eslint-disable-line react-hooks/exhaustive-deps - - /** - * 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(prevState => ({ ...prevState, username: ' ' })); - } - }, [usernameSuggestions, formFields]); - useEffect(() => { if (registrationResult.success) { + sendTrackEvent('edx.bi.user.account.registered.client', {}); + // Optimizely registration conversion event window.optimizely = window.optimizely || []; window.optimizely.push({ @@ -171,154 +116,66 @@ const EmbeddableRegistrationPage = (props) => { redirectUrl: encodeURIComponent(getConfig().POST_REGISTRATION_REDIRECT_URL), }, host); } - }, [registrationResult, host]); - - const validateInput = (fieldName, value, payload, shouldValidateFromBackend) => { - switch (fieldName) { - case 'name': - if (value && !payload.username.trim() && shouldValidateFromBackend) { - validateFromBackend(payload); - } - break; - default: - break; - } - }; - - const isFormValid = (payload) => { - const fieldErrors = { ...errors }; - let isValid = true; - Object.keys(payload).forEach(key => { - if (!payload[key]) { - fieldErrors[key] = formatMessage(messages[`empty.${key}.field.error`]); - } - if (fieldErrors[key]) { - isValid = false; - } - }); - - if (flags.showConfigurableEdxFields) { - if (!configurableFormFields.country.displayValue) { - fieldErrors.country = 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] = formatMessage(messages['empty.country.field.error']); - } else if (!configurableFormFields[key]) { - fieldErrors[key] = fieldDescriptions[key].error_message; - } - if (fieldErrors[key]) { - isValid = false; - } - }); - } - setErrors({ ...fieldErrors }); - return isValid; - }; - - const handleSuggestionClick = (event, fieldName, suggestion = '') => { - event.preventDefault(); - setErrors(prevErrors => ({ ...prevErrors, [fieldName]: '' })); - switch (fieldName) { - case 'username': - setFormFields(prevState => ({ ...prevState, username: suggestion })); - props.resetUsernameSuggestions(); - break; - default: - break; - } - }; - - const handleEmailSuggestionClosed = () => setEmailSuggestion({ suggestion: '', type: '' }); - - const handleUsernameSuggestionClosed = () => props.resetUsernameSuggestions(); + }, [host, registrationResult]); const handleOnChange = (event) => { const { name } = event.target; - let value = event.target.type === 'checkbox' ? event.target.checked : event.target.value; + const value = event.target.type === 'checkbox' ? event.target.checked : event.target.value; if (registrationError[name]) { - clearBackendError(name); + dispatch(clearRegistrationBackendError(name)); setErrors(prevErrors => ({ ...prevErrors, [name]: '' })); } - if (name === 'username') { - if (value.length > 30) { - return; - } - if (value.startsWith(' ')) { - value = value.trim(); - } - } - setFormFields(prevState => ({ ...prevState, [name]: value })); }; - const handleOnBlur = (event) => { - const { name, value } = event.target; - - if (name === 'name') { - validateInput( - name, - value, - { name: formFields.name, username: formFields.username, form_field_key: name }, - !validationApiRateLimited, - ); - } - }; - - const handleOnFocus = (event) => { - const { name, value } = event.target; - setErrors(prevErrors => ({ ...prevErrors, [name]: '' })); - clearBackendError(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(prevState => ({ ...prevState, [name]: '' })); - } + const handleErrorChange = (fieldName, error) => { + setTemporaryErrors(prevErrors => ({ + ...prevErrors, + [fieldName]: error, + })); + if (error === '' && errors[fieldName] !== '') { + setErrors(prevErrors => ({ + ...prevErrors, + [fieldName]: error, + })); } }; - const handleSubmit = (e) => { - e.preventDefault(); + const registerUser = () => { const totalRegistrationTime = (Date.now() - formStartTime) / 1000; let payload = { ...formFields }; - if (!isFormValid(payload)) { + // Validating form data before submitting + const { isValid, fieldErrors } = isFormValid( + payload, + temporaryErrors, + configurableFormFields, + fieldDescriptions, + formatMessage, + ); + setErrors({ ...fieldErrors }); + + // returning if not valid + if (!isValid) { setErrorCode(prevState => ({ type: FORM_SUBMISSION_ERROR, count: prevState.count + 1 })); return; } - Object.keys(configurableFormFields).forEach((fieldName) => { - if (fieldName === 'country') { - payload[fieldName] = configurableFormFields[fieldName].countryCode; - } else { - payload[fieldName] = configurableFormFields[fieldName]; - } - }); + // Preparing payload for submission + payload = prepareRegistrationPayload( + payload, + configurableFormFields, + flags.showMarketingEmailOptInCheckbox, + totalRegistrationTime, + queryParams); - // Don't send the marketing email opt-in value if the flag is turned off - if (!flags.showMarketingEmailOptInCheckbox) { - delete payload.marketingEmailsOptIn; - } - - payload = snakeCaseObject(payload); - payload.totalRegistrationTime = totalRegistrationTime; + // making register call + dispatch(registerNewUser(payload)); + }; - // add query params to the payload - payload = { ...payload, ...queryParams }; - props.registerNewUser(payload); + const handleSubmit = (e) => { + e.preventDefault(); + registerUser(); }; return ( @@ -334,12 +191,12 @@ const EmbeddableRegistrationPage = (props) => { failureCount={errorCode.count} />
- { { name="username" spellCheck="false" value={formFields.username} - handleBlur={handleOnBlur} handleChange={handleOnChange} - handleFocus={handleOnFocus} - handleSuggestionClick={handleSuggestionClick} - handleUsernameSuggestionClose={handleUsernameSuggestionClosed} - usernameSuggestions={usernameSuggestions} + handleErrorChange={handleErrorChange} errorMessage={errors.username} helpText={[formatMessage(messages['help.text.username.1']), formatMessage(messages['help.text.username.2'])]} floatingLabel={formatMessage(messages['registration.username.label'])} @@ -374,20 +225,17 @@ const EmbeddableRegistrationPage = (props) => { name="password" value={formFields.password} handleChange={handleOnChange} - handleBlur={handleOnBlur} - handleFocus={handleOnFocus} + handleErrorChange={handleErrorChange} errorMessage={errors.password} floatingLabel={formatMessage(messages['registration.password.label'])} /> { />
- ); }; -const mapStateToProps = state => { - const registerPageState = state.register; - return { - backendCountryCode: registerPageState.backendCountryCode, - backendValidations: validationsSelector(state), - fieldDescriptions: fieldDescriptionSelector(state), - registrationError: registerPageState.registrationError, - registrationErrorCode: registrationErrorSelector(state), - registrationResult: registerPageState.registrationResult, - submitState: registerPageState.submitState, - validationApiRateLimited: registerPageState.validationApiRateLimited, - usernameSuggestions: registerPageState.usernameSuggestions, - }; -}; - -EmbeddableRegistrationPage.propTypes = { - backendCountryCode: PropTypes.string, - backendValidations: PropTypes.shape({ - name: PropTypes.string, - email: PropTypes.string, - username: PropTypes.string, - password: PropTypes.string, - }), - fieldDescriptions: PropTypes.shape({}), - registrationError: PropTypes.shape({}), - registrationErrorCode: PropTypes.string, - registrationResult: PropTypes.shape({ - redirectUrl: PropTypes.string, - success: PropTypes.bool, - }), - submitState: PropTypes.string, - usernameSuggestions: PropTypes.arrayOf(PropTypes.string), - validationApiRateLimited: PropTypes.bool, - // Actions - clearBackendError: PropTypes.func.isRequired, - getRegistrationDataFromBackend: PropTypes.func.isRequired, - registerNewUser: PropTypes.func.isRequired, - resetUsernameSuggestions: PropTypes.func.isRequired, - validateFromBackend: PropTypes.func.isRequired, -}; - -EmbeddableRegistrationPage.defaultProps = { - backendCountryCode: '', - backendValidations: null, - fieldDescriptions: {}, - registrationError: {}, - registrationErrorCode: '', - registrationResult: null, - submitState: DEFAULT_STATE, - usernameSuggestions: [], - validationApiRateLimited: false, -}; - -export default connect( - mapStateToProps, - { - clearBackendError: clearRegistertionBackendError, - getRegistrationDataFromBackend: getThirdPartyAuthContext, - resetUsernameSuggestions: clearUsernameSuggestions, - validateFromBackend: fetchRealtimeValidations, - registerNewUser, - }, -)(EmbeddableRegistrationPage); +export default EmbeddableRegistrationPage; diff --git a/src/register/components/tests/EmbeddableRegistrationPage.test.jsx b/src/register/components/tests/EmbeddableRegistrationPage.test.jsx index 1ec3662568..0487530958 100644 --- a/src/register/components/tests/EmbeddableRegistrationPage.test.jsx +++ b/src/register/components/tests/EmbeddableRegistrationPage.test.jsx @@ -234,6 +234,97 @@ describe('RegistrationPage', () => { expect(registrationPage.find('input#username').prop('value')).toEqual('test-user'); }); + it('should run username and email frontend validations', () => { + const payload = { + name: 'John Doe', + username: 'test@2u.com', + email: 'test@yopmail.test', + password: 'password1', + country: 'Pakistan', + honor_code: true, + totalRegistrationTime: 0, + marketing_emails_opt_in: true, + }; + + store.dispatch = jest.fn(store.dispatch); + const registrationPage = mount(reduxWrapper()); + populateRequiredFields(registrationPage, payload); + registrationPage.find('input[name="email"]').simulate('focus'); + registrationPage.find('input[name="email"]').simulate('blur', { target: { value: 'test@yopmail.test', name: 'email' } }); + expect(registrationPage.find('.email-suggestion__text').exists()).toBeTruthy(); + + registrationPage.find('input[name="email"]').simulate('focus'); + registrationPage.find('input[name="email"]').simulate('blur', { target: { value: 'asasasasas', name: 'email' } }); + + registrationPage.find('button.btn-brand').simulate('click'); + expect(registrationPage.find('div[feedback-for="email"]').exists()).toBeTruthy(); + expect(registrationPage.find('div[feedback-for="username"]').exists()).toBeTruthy(); + }); + it('should run email frontend validations when random string is input', () => { + const payload = { + name: 'John Doe', + username: 'testh@2u.com', + email: 'as', + password: 'password1', + country: 'Pakistan', + honor_code: true, + totalRegistrationTime: 0, + marketing_emails_opt_in: true, + }; + + const registrationPage = mount(reduxWrapper()); + populateRequiredFields(registrationPage, payload); + + registrationPage.find('button.btn-brand').simulate('click'); + expect(registrationPage.find('div[feedback-for="email"]').exists()).toBeTruthy(); + }); + it('should run frontend validations for name field', () => { + const payload = { + name: 'https://localhost.com', + username: 'test@2u.com', + email: 'as', + password: 'password1', + country: 'Pakistan', + honor_code: true, + totalRegistrationTime: 0, + marketing_emails_opt_in: true, + }; + + const registrationPage = mount(reduxWrapper()); + populateRequiredFields(registrationPage, payload); + + registrationPage.find('button.btn-brand').simulate('click'); + expect(registrationPage.find('div[feedback-for="name"]').exists()).toBeTruthy(); + }); + + it('should run frontend validations for password field', () => { + const payload = { + name: 'https://localhost.com', + username: 'test@2u.com', + email: 'as', + password: 'as', + country: 'Pakistan', + honor_code: true, + totalRegistrationTime: 0, + marketing_emails_opt_in: true, + }; + + const registrationPage = mount(reduxWrapper()); + populateRequiredFields(registrationPage, payload); + + registrationPage.find('button.btn-brand').simulate('click'); + expect(registrationPage.find('div[feedback-for="password"]').exists()).toBeTruthy(); + }); + + it('should click on email suggestion in case suggestion is avialable', () => { + const registrationPage = mount(reduxWrapper()); + registrationPage.find('input[name="email"]').simulate('focus'); + registrationPage.find('input[name="email"]').simulate('blur', { target: { value: 'test@gmail.co', name: 'email' } }); + + registrationPage.find('a.email-suggestion-alert-warning').simulate('click'); + expect(registrationPage.find('input#email').prop('value')).toEqual('test@gmail.com'); + }); + it('should remove extra character if username is more than 30 character long', () => { const registrationPage = mount(reduxWrapper()); registrationPage.find('input#username').simulate('change', { target: { value: 'why_this_is_not_valid_username_', name: 'username' } }); From 0e7e748bc4396bf702309c5bc84fe9af2df856bc Mon Sep 17 00:00:00 2001 From: Syed Sajjad Hussain Shah Date: Thu, 7 Sep 2023 11:48:23 +0500 Subject: [PATCH 3/3] feat: final changes --- .../ConfigurableRegistrationForm.test.jsx | 2 +- .../tests/EmbeddableRegistrationPage.test.jsx | 525 ++++++++---------- 2 files changed, 246 insertions(+), 281 deletions(-) diff --git a/src/register/components/tests/ConfigurableRegistrationForm.test.jsx b/src/register/components/tests/ConfigurableRegistrationForm.test.jsx index c22e609108..64955118dd 100644 --- a/src/register/components/tests/ConfigurableRegistrationForm.test.jsx +++ b/src/register/components/tests/ConfigurableRegistrationForm.test.jsx @@ -9,8 +9,8 @@ import { mount } from 'enzyme'; import { BrowserRouter as Router } from 'react-router-dom'; import configureStore from 'redux-mock-store'; -import ConfigurableRegistrationForm from '../ConfigurableRegistrationForm'; import { FIELDS } from '../../data/constants'; +import ConfigurableRegistrationForm from '../ConfigurableRegistrationForm'; jest.mock('@edx/frontend-platform/analytics', () => ({ sendPageEvent: jest.fn(), diff --git a/src/register/components/tests/EmbeddableRegistrationPage.test.jsx b/src/register/components/tests/EmbeddableRegistrationPage.test.jsx index 0487530958..1f5fc4c432 100644 --- a/src/register/components/tests/EmbeddableRegistrationPage.test.jsx +++ b/src/register/components/tests/EmbeddableRegistrationPage.test.jsx @@ -2,26 +2,31 @@ import React from 'react'; import { Provider } from 'react-redux'; import { getConfig, mergeConfig } from '@edx/frontend-platform'; -import { sendPageEvent } from '@edx/frontend-platform/analytics'; +import { sendPageEvent, sendTrackEvent } from '@edx/frontend-platform/analytics'; import { configure, getLocale, injectIntl, IntlProvider, } from '@edx/frontend-platform/i18n'; import { mount } from 'enzyme'; -import { act } from 'react-dom/test-utils'; +import { BrowserRouter as Router } from 'react-router-dom'; import renderer from 'react-test-renderer'; import configureStore from 'redux-mock-store'; import { - PENDING_STATE, + AUTHN_PROGRESSIVE_PROFILING, PENDING_STATE, REGISTER_PAGE, } from '../../../data/constants'; import { - clearUsernameSuggestions, + clearRegistrationBackendError, registerNewUser, } from '../../data/actions'; import { FIELDS, + FORBIDDEN_REQUEST, + INTERNAL_SERVER_ERROR, + TPA_AUTHENTICATION_FAILURE, + TPA_SESSION_EXPIRED, } from '../../data/constants'; -import EmbeddableRegistrationPage from '../EmbeddableRegistrationPage'; +import { EmbeddableRegistrationPage } from '../../index'; +import RegistrationFailureMessage from '../RegistrationFailure'; jest.mock('@edx/frontend-platform/analytics', () => ({ sendPageEvent: jest.fn(), @@ -32,10 +37,27 @@ jest.mock('@edx/frontend-platform/i18n', () => ({ getLocale: jest.fn(), })); -const IntlEmbedableRegistrationForm = injectIntl(EmbeddableRegistrationPage); +const IntlEmbeddableRegistrationPage = injectIntl(EmbeddableRegistrationPage); +const IntlRegistrationFailure = injectIntl(RegistrationFailureMessage); const mockStore = configureStore(); -describe('RegistrationPage', () => { +jest.mock('react-router-dom', () => { + const mockNavigation = jest.fn(); + + // eslint-disable-next-line react/prop-types + const Navigate = ({ to }) => { + mockNavigation(to); + return
; + }; + + return { + ...jest.requireActual('react-router-dom'), + Navigate, + mockNavigate: mockNavigation, + }; +}); + +describe('EmbeddableRegistrationPage', () => { mergeConfig({ PRIVACY_POLICY: 'https://privacy-policy.com', TOS_AND_HONOR_CODE: 'https://tos-and-honot-code.com', @@ -65,17 +87,41 @@ describe('RegistrationPage', () => { ); + const routerWrapper = children => ( + + {children} + + ); + + const thirdPartyAuthContext = { + currentProvider: null, + finishAuthUrl: null, + providers: [], + secondaryProviders: [], + pipelineUserDetails: null, + countryCode: null, + }; + const initialState = { register: { registrationResult: { success: false, redirectUrl: '' }, registrationError: {}, registrationFormData, + usernameSuggestions: [], + }, + commonComponents: { + thirdPartyAuthApiStatus: null, + thirdPartyAuthContext, + fieldDescriptions: {}, + optionalFields: { + fields: {}, + extended_profile: [], + }, }, }; beforeEach(() => { store = mockStore(initialState); - window.parent.postMessage = jest.fn(); configure({ loggingService: { logError: jest.fn() }, config: { @@ -84,12 +130,13 @@ describe('RegistrationPage', () => { }, messages: { 'es-419': {}, de: {}, 'en-us': {} }, }); - props = { - registrationResult: jest.fn(), - handleInstitutionLogin: jest.fn(), - institutionLogin: false, - }; + + props = {}; + window.location = { search: '' }; + window.parent.postMessage = jest.fn(); + + getLocale.mockImplementation(() => ('en-us')); }); afterEach(() => { @@ -109,7 +156,7 @@ describe('RegistrationPage', () => { } }; - describe('Test Registration Page', () => { + describe('Test Embeddable Registration Page', () => { mergeConfig({ SHOW_CONFIGURABLE_EDX_FIELDS: true, }); @@ -143,7 +190,7 @@ describe('RegistrationPage', () => { }; store.dispatch = jest.fn(store.dispatch); - const registrationPage = mount(reduxWrapper()); + const registrationPage = mount(routerWrapper(reduxWrapper())); populateRequiredFields(registrationPage, payload); registrationPage.find('button.btn-brand').simulate('click'); expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK' })); @@ -168,7 +215,7 @@ describe('RegistrationPage', () => { }; store.dispatch = jest.fn(store.dispatch); - const registrationPage = mount(reduxWrapper()); + const registrationPage = mount(routerWrapper(reduxWrapper())); populateRequiredFields(registrationPage, payload); registrationPage.find('button.btn-brand').simulate('click'); expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK' })); @@ -181,15 +228,15 @@ describe('RegistrationPage', () => { it('should not dispatch registerNewUser on empty form Submission', () => { store.dispatch = jest.fn(store.dispatch); - const registrationPage = mount(reduxWrapper()); + const registrationPage = mount(routerWrapper(reduxWrapper())); registrationPage.find('button.btn-brand').simulate('click'); expect(store.dispatch).not.toHaveBeenCalledWith(registerNewUser({})); }); - // // ******** test registration form validations ******** + // ******** test registration form validations ******** it('should show error messages for required fields on empty form submission', () => { - const registrationPage = mount(reduxWrapper()); + const registrationPage = mount(routerWrapper(reduxWrapper())); registrationPage.find('button.btn-brand').simulate('click'); expect(registrationPage.find('div[feedback-for="name"]').text()).toEqual(emptyFieldValidation.name); @@ -202,189 +249,99 @@ describe('RegistrationPage', () => { expect(registrationPage.find('#validation-errors').first().text()).toEqual(alertBanner); }); - it('should run validations for focused field on form submission', () => { - const registrationPage = mount(reduxWrapper()); - registrationPage.find('input[name="country"]').simulate('focus'); + it('should clear error on focus', () => { + const registrationPage = mount(routerWrapper(reduxWrapper())); registrationPage.find('button.btn-brand').simulate('click'); - expect(registrationPage.find('div[feedback-for="country"]').text()).toEqual(emptyFieldValidation.country); + 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(); }); - it('should update props with validations returned by registration api', () => { + it('should clear registration backend error on change', () => { + getLocale.mockImplementation(() => ('en-us')); + const emailError = 'This email is already associated with an existing or previous account'; store = mockStore({ ...initialState, register: { ...initialState.register, registrationError: { - username: [{ userMessage: 'It looks like this username is already taken' }], - email: [{ userMessage: `This email is already associated with an existing or previous ${ getConfig().SITE_NAME } account` }], + email: [{ userMessage: emailError }], }, }, }); - const registrationPage = mount(reduxWrapper()).find('EmbeddableRegistrationPage'); - 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 remove space from the start of username', () => { - const registrationPage = mount(reduxWrapper()); - registrationPage.find('input#username').simulate('change', { target: { value: ' test-user', name: 'username' } }); - - expect(registrationPage.find('input#username').prop('value')).toEqual('test-user'); - }); - it('should run username and email frontend validations', () => { - const payload = { - name: 'John Doe', - username: 'test@2u.com', - email: 'test@yopmail.test', - password: 'password1', - country: 'Pakistan', - honor_code: true, - totalRegistrationTime: 0, - marketing_emails_opt_in: true, - }; - store.dispatch = jest.fn(store.dispatch); - const registrationPage = mount(reduxWrapper()); - populateRequiredFields(registrationPage, payload); - registrationPage.find('input[name="email"]').simulate('focus'); - registrationPage.find('input[name="email"]').simulate('blur', { target: { value: 'test@yopmail.test', name: 'email' } }); - expect(registrationPage.find('.email-suggestion__text').exists()).toBeTruthy(); - registrationPage.find('input[name="email"]').simulate('focus'); - registrationPage.find('input[name="email"]').simulate('blur', { target: { value: 'asasasasas', name: 'email' } }); + const registrationPage = mount(routerWrapper(reduxWrapper( + , + ))).find('EmbeddableRegistrationPage'); - registrationPage.find('button.btn-brand').simulate('click'); - expect(registrationPage.find('div[feedback-for="email"]').exists()).toBeTruthy(); - expect(registrationPage.find('div[feedback-for="username"]').exists()).toBeTruthy(); + registrationPage.find('input#email').simulate('change', { target: { value: 'test1@gmail.com', name: 'email' } }); + expect(store.dispatch).toHaveBeenCalledWith(clearRegistrationBackendError('email')); }); - it('should run email frontend validations when random string is input', () => { - const payload = { - name: 'John Doe', - username: 'testh@2u.com', - email: 'as', - password: 'password1', - country: 'Pakistan', - honor_code: true, - totalRegistrationTime: 0, - marketing_emails_opt_in: true, - }; - const registrationPage = mount(reduxWrapper()); - populateRequiredFields(registrationPage, payload); + // ******** test alert messages ******** - registrationPage.find('button.btn-brand').simulate('click'); - expect(registrationPage.find('div[feedback-for="email"]').exists()).toBeTruthy(); - }); - it('should run frontend validations for name field', () => { - const payload = { - name: 'https://localhost.com', - username: 'test@2u.com', - email: 'as', - password: 'password1', - country: 'Pakistan', - honor_code: true, - totalRegistrationTime: 0, - marketing_emails_opt_in: true, + it('should match internal server error message', () => { + const expectedMessage = 'We couldn\'t create your account.An error has occurred. Try refreshing the page, or check your internet connection.'; + props = { + errorCode: INTERNAL_SERVER_ERROR, + failureCount: 0, }; - const registrationPage = mount(reduxWrapper()); - populateRequiredFields(registrationPage, payload); - - registrationPage.find('button.btn-brand').simulate('click'); - expect(registrationPage.find('div[feedback-for="name"]').exists()).toBeTruthy(); + const registrationPage = mount(reduxWrapper()); + expect(registrationPage.find('div.alert-heading').length).toEqual(1); + expect(registrationPage.find('div.alert').first().text()).toEqual(expectedMessage); }); - it('should run frontend validations for password field', () => { - const payload = { - name: 'https://localhost.com', - username: 'test@2u.com', - email: 'as', - password: 'as', - country: 'Pakistan', - honor_code: true, - totalRegistrationTime: 0, - marketing_emails_opt_in: true, + it('should match registration api rate limit error message', () => { + const expectedMessage = 'We couldn\'t create your account.Too many failed registration attempts. Try again later.'; + props = { + errorCode: FORBIDDEN_REQUEST, + failureCount: 0, }; - const registrationPage = mount(reduxWrapper()); - populateRequiredFields(registrationPage, payload); - - registrationPage.find('button.btn-brand').simulate('click'); - expect(registrationPage.find('div[feedback-for="password"]').exists()).toBeTruthy(); - }); - - it('should click on email suggestion in case suggestion is avialable', () => { - const registrationPage = mount(reduxWrapper()); - registrationPage.find('input[name="email"]').simulate('focus'); - registrationPage.find('input[name="email"]').simulate('blur', { target: { value: 'test@gmail.co', name: 'email' } }); - - registrationPage.find('a.email-suggestion-alert-warning').simulate('click'); - expect(registrationPage.find('input#email').prop('value')).toEqual('test@gmail.com'); - }); - - it('should remove extra character if username is more than 30 character long', () => { - const registrationPage = mount(reduxWrapper()); - registrationPage.find('input#username').simulate('change', { target: { value: 'why_this_is_not_valid_username_', name: 'username' } }); - - expect(registrationPage.find('input#username').prop('value')).toEqual(''); + const registrationPage = mount(reduxWrapper()); + expect(registrationPage.find('div.alert-heading').length).toEqual(1); + expect(registrationPage.find('div.alert').first().text()).toEqual(expectedMessage); }); - // // ******** test field focus in functionality ******** - - it('should clear field related error messages on input field Focus', () => { - 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[name="country"]').simulate('focus'); - expect(registrationPage.find('div[feedback-for="country"]').exists()).toBeFalsy(); - }); - - it('should clear username suggestions when username field is focused in', () => { - store.dispatch = jest.fn(store.dispatch); - - const registrationPage = mount(reduxWrapper()); - registrationPage.find('input#username').simulate('focus'); + it('should match tpa session expired error message', () => { + const expectedMessage = 'We couldn\'t create your account.Registration using Google has timed out.'; + props = { + context: { + provider: 'Google', + }, + errorCode: TPA_SESSION_EXPIRED, + failureCount: 0, + }; - expect(store.dispatch).toHaveBeenCalledWith(clearUsernameSuggestions()); + const registrationPage = mount(reduxWrapper()); + expect(registrationPage.find('div.alert-heading').length).toEqual(1); + expect(registrationPage.find('div.alert').first().text()).toEqual(expectedMessage); }); - it('should call backend api for username suggestions when input the name field', () => { - store.dispatch = jest.fn(store.dispatch); - const registrationPage = mount(reduxWrapper()); - - registrationPage.find('input#name').simulate('focus'); - registrationPage.find('input#name').simulate('change', { target: { value: 'ahtesham', name: 'name' } }); - registrationPage.find('input#name').simulate('blur'); + it('should match tpa authentication failed error message', () => { + const expectedMessageSubstring = 'We are sorry, you are not authorized to access'; + props = { + context: { + provider: 'Google', + }, + errorCode: TPA_AUTHENTICATION_FAILURE, + failureCount: 0, + }; - expect(store.dispatch).toHaveBeenCalledTimes(4); + const registrationPage = mount(reduxWrapper()); + expect(registrationPage.find('div.alert-heading').length).toEqual(1); + expect(registrationPage.find('div.alert').first().text()).toContain(expectedMessageSubstring); }); - // // ******** test form buttons and fields ******** + // ******** test form buttons and fields ******** it('should match default button state', () => { - const registrationPage = mount(reduxWrapper()); - expect(registrationPage.find('button[type="submit"] span').first().text()) - .toEqual('Create an account for free'); + const registrationPage = mount(routerWrapper(reduxWrapper())); + expect(registrationPage.find('button[type="submit"] span').first().text()).toEqual('Create an account for free'); }); it('should match pending button state', () => { @@ -396,7 +353,7 @@ describe('RegistrationPage', () => { }, }); - const registrationPage = mount(reduxWrapper()); + const registrationPage = mount(routerWrapper(reduxWrapper())); const button = registrationPage.find('button[type="submit"] span').first(); expect(button.find('.sr-only').text()).toEqual('pending'); @@ -407,7 +364,7 @@ describe('RegistrationPage', () => { MARKETING_EMAILS_OPT_IN: 'true', }); - const registrationPage = mount(reduxWrapper()); + const registrationPage = mount(routerWrapper(reduxWrapper())); expect(registrationPage.find('div.form-field--checkbox').length).toEqual(1); mergeConfig({ @@ -419,7 +376,7 @@ describe('RegistrationPage', () => { const buttonLabel = 'Register'; delete window.location; window.location = { href: getConfig().BASE_URL, search: `?cta=${buttonLabel}` }; - const registrationPage = mount(reduxWrapper()); + const registrationPage = mount(reduxWrapper()); expect(registrationPage.find('button[type="submit"] span').first().text()).toEqual(buttonLabel); }); @@ -434,154 +391,141 @@ describe('RegistrationPage', () => { }, }); - renderer.create(reduxWrapper()); + renderer.create(routerWrapper(reduxWrapper())); expect(document.cookie).toMatch(`${getConfig().REGISTER_CONVERSION_COOKIE_NAME}=true`); }); - it('should show username suggestions in case of conflict with an existing username', () => { - store = mockStore({ - ...initialState, - register: { - ...initialState.register, - usernameSuggestions: ['test_1', 'test_12', 'test_123'], - registrationFormData: { - ...registrationFormData, - errors: { - ...registrationFormData.errors, - username: 'It looks like this username is already taken', - }, - }, - }, - }); + // ******** miscellaneous tests ******** - const registrationPage = mount(reduxWrapper()); - expect(registrationPage.find('button.username-suggestions--chip').length).toEqual(3); + it('should send page event when register page is rendered', () => { + mount(routerWrapper(reduxWrapper())); + expect(sendPageEvent).toHaveBeenCalledWith('login_and_registration', 'register'); }); - it('should show username suggestions when full name is populated', () => { + it('should send track event when user has successfully registered', () => { store = mockStore({ ...initialState, register: { ...initialState.register, - usernameSuggestions: ['test_1', 'test_12', 'test_123'], - registrationFormData: { - ...registrationFormData, - username: ' ', + registrationResult: { + success: true, + redirectUrl: 'https://test.com/testing-dashboard/', }, }, }); - const registrationPage = mount(reduxWrapper()); - registrationPage.find('input#name').simulate('change', { target: { value: 'test name', name: 'name' } }); - - expect(registrationPage.find('button.username-suggestions--chip').length).toEqual(3); + delete window.location; + window.location = { href: getConfig().BASE_URL }; + renderer.create(routerWrapper(reduxWrapper())); + expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.user.account.registered.client', {}); }); - it('should remove empty space from username field when it is focused', async () => { + it('should display error message based on the error code returned by API', () => { store = mockStore({ ...initialState, register: { ...initialState.register, - usernameSuggestions: ['test_1', 'test_12', 'test_123'], - registrationFormData: { - ...registrationFormData, - username: ' ', + registrationError: { + errorCode: INTERNAL_SERVER_ERROR, }, }, }); - const registrationPage = mount(reduxWrapper()); - registrationPage.find('input#name').simulate('change', { target: { value: 'test name', name: 'name' } }); - registrationPage.find('input#username').simulate('focus'); - await act(async () => { await expect(registrationPage.find('input#username').text()).toEqual(''); }); + const registrationPage = mount(routerWrapper(reduxWrapper())).find('EmbeddableRegistrationPage'); + expect(registrationPage.find('div#validation-errors').first().text()).toContain( + 'An error has occurred. Try refreshing the page, or check your internet connection.', + ); }); - it('should click on username suggestions when full name is populated', () => { - store = mockStore({ - ...initialState, - register: { - ...initialState.register, - usernameSuggestions: ['test_1', 'test_12', 'test_123'], - registrationFormData: { - ...registrationFormData, - username: ' ', - }, - }, + it('should call the postMessage API on registration success for embedded experience', () => { + mergeConfig({ + ENABLE_PROGRESSIVE_PROFILING_ON_AUTHN: true, }); - const registrationPage = mount(reduxWrapper()); - registrationPage.find('input#name').simulate('change', { target: { value: 'test name', name: 'name' } }); - registrationPage.find('.username-suggestions--chip').first().simulate('click'); - expect(registrationPage.find('input#username').props().value).toEqual('test_1'); - }); + window.parent.postMessage = jest.fn(); + + delete window.location; + window.location = { + href: getConfig().BASE_URL.concat(AUTHN_PROGRESSIVE_PROFILING), + search: '?host=http://localhost/host-website', + }; - it('should clear username suggestions when close icon is clicked', () => { store = mockStore({ ...initialState, register: { ...initialState.register, - usernameSuggestions: ['test_1', 'test_12', 'test_123'], - registrationFormData: { - ...registrationFormData, - username: ' ', + registrationResult: { + success: true, }, }, - }); - store.dispatch = jest.fn(store.dispatch); - - const registrationPage = mount(reduxWrapper()); - registrationPage.find('input#name').simulate('change', { target: { value: 'test name', name: 'name' } }); - registrationPage.find('button.username-suggestions__close__button').at(0).simulate('click'); - expect(store.dispatch).toHaveBeenCalledWith(clearUsernameSuggestions()); - }); - - // // ******** miscellaneous tests ******** - - it('should send page event when register page is rendered', () => { - mount(reduxWrapper()); - expect(sendPageEvent).toHaveBeenCalledWith('login_and_registration', 'register'); - }); - - it('should update state from country code present in redux store', () => { - store = mockStore({ - ...initialState, - register: { - ...initialState.register, - backendCountryCode: 'PK', + commonComponents: { + ...initialState.commonComponents, + optionalFields: { + extended_profile: [], + fields: { + level_of_education: { name: 'level_of_education', error_message: false }, + }, + }, }, }); - - const registrationPage = mount(reduxWrapper()); - expect(registrationPage.find('input[name="country"]').props().value).toEqual('Pakistan'); + const progressiveProfilingPage = mount(reduxWrapper( + , + )); + progressiveProfilingPage.update(); + expect(window.parent.postMessage).toHaveBeenCalledTimes(1); }); - it('should set country in component state when form is translated used i18n', () => { - getLocale.mockImplementation(() => ('ar-ae')); + it('should not display validations error on blur event when embedded variant is rendered', () => { + delete window.location; + window.location = { href: getConfig().BASE_URL.concat(REGISTER_PAGE), search: '?host=http://localhost/host-website' }; + const registrationPage = mount(reduxWrapper()); - const registrationPage = mount(reduxWrapper()); - registrationPage.find('input[name="country"]').simulate('click'); - registrationPage.find('button.dropdown-item').at(0).simulate('click', { target: { value: 'أفغانستان ', name: 'countryItem' } }); + registrationPage.find('input#username').simulate('blur', { target: { value: '', name: 'username' } }); + expect(registrationPage.find('div[feedback-for="username"]').exists()).toBeFalsy(); + + registrationPage.find('input[name="country"]').simulate('blur', { target: { value: '', name: 'country' } }); expect(registrationPage.find('div[feedback-for="country"]').exists()).toBeFalsy(); }); - it('should clear the registation validation error on change event on field focused', () => { + it('should set errors in temporary state when validations are returned by registration api', () => { + delete window.location; + window.location = { href: getConfig().BASE_URL.concat(REGISTER_PAGE), search: '?host=http://localhost/host-website' }; + + const usernameError = 'It looks like this username is already taken'; + const emailError = 'This email is already associated with an existing or previous account'; store = mockStore({ ...initialState, register: { ...initialState.register, registrationError: { - errorCode: 'duplicate-email', - email: [{ userMessage: `This email is already associated with an existing or previous ${ getConfig().SITE_NAME } account` }], + username: [{ userMessage: usernameError }], + email: [{ userMessage: emailError }], }, }, }); + const registrationPage = mount(routerWrapper(reduxWrapper( + ), + )).find('EmbeddableRegistrationPage'); - store.dispatch = jest.fn(store.dispatch); - const clearBackendError = jest.fn(); - const registrationPage = mount(reduxWrapper()); - registrationPage.find('input#email').simulate('change', { target: { value: 'a@gmail.com', name: 'email' } }); + expect(registrationPage.find('div[feedback-for="username"]').exists()).toBeFalsy(); expect(registrationPage.find('div[feedback-for="email"]').exists()).toBeFalsy(); }); + + it('should clear error on focus for embedded experience also', () => { + delete window.location; + window.location = { + href: getConfig().BASE_URL.concat(REGISTER_PAGE), + search: '?host=http://localhost/host-website', + }; + + const registrationPage = mount(routerWrapper(reduxWrapper())); + registrationPage.find('button.btn-brand').simulate('click'); + + 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(); + }); }); describe('Test Configurable Fields', () => { @@ -604,7 +548,7 @@ describe('RegistrationPage', () => { }, }, }); - const registrationPage = mount(reduxWrapper()); + const registrationPage = mount(routerWrapper(reduxWrapper())); expect(registrationPage.find('#profession').exists()).toBeTruthy(); expect(registrationPage.find('#tos').exists()).toBeTruthy(); }); @@ -635,7 +579,7 @@ describe('RegistrationPage', () => { }; store.dispatch = jest.fn(store.dispatch); - const registrationPage = mount(reduxWrapper()); + const registrationPage = mount(routerWrapper(reduxWrapper())); populateRequiredFields(registrationPage, payload); registrationPage.find('input#profession').simulate('change', { target: { value: 'Engineer', name: 'profession' } }); @@ -664,7 +608,7 @@ describe('RegistrationPage', () => { }, }); - const registrationPage = mount(reduxWrapper()); + const registrationPage = mount(routerWrapper(reduxWrapper())); registrationPage.find('button.btn-brand').simulate('click'); expect(registrationPage.find('#profession-error').last().text()).toEqual(professionError); @@ -672,6 +616,38 @@ describe('RegistrationPage', () => { expect(registrationPage.find('#confirm_email-error').last().text()).toEqual(confirmEmailError); }); + it('should show error if email and confirm email fields do not match', () => { + store = mockStore({ + ...initialState, + register: { + ...initialState.register, + registrationFormData: { + ...initialState.register.registrationFormData, + configurableFormFields: { + ...initialState.register.registrationFormData.configurableFormFields, + confirm_email: 'test2@yopmail.com', + }, + formFields: { + ...initialState.register.registrationFormData.formFields, + email: 'test1@yopmail.com', + }, + }, + }, + commonComponents: { + ...initialState.commonComponents, + fieldDescriptions: { + confirm_email: { + name: 'confirm_email', type: 'text', label: 'Confirm Email', + }, + }, + }, + }); + const registrationPage = mount(routerWrapper(reduxWrapper())); + registrationPage.find('input#confirm_email').simulate('blur'); + registrationPage.find('button.btn-brand').simulate('click'); + expect(registrationPage.find('div#confirm_email-error').text()).toEqual('The email addresses do not match.'); + }); + it('should run validations for configurable focused field on form submission', () => { const professionError = 'Enter your profession'; store = mockStore({ @@ -686,22 +662,11 @@ describe('RegistrationPage', () => { }, }); - const registrationPage = mount(reduxWrapper()); + const registrationPage = mount(routerWrapper(reduxWrapper())); registrationPage.find('input#profession').simulate('focus', { target: { value: '', name: 'profession' } }); registrationPage.find('button.btn-brand').simulate('click'); expect(registrationPage.find('#profession-error').last().text()).toEqual(professionError); }); - - it('should not run country field validation when onBlur is fired by drop-down arrow icon click', () => { - getLocale.mockImplementation(() => ('en-us')); - - const registrationPage = mount(reduxWrapper()); - registrationPage.find('input[name="country"]').simulate('blur', { - target: { value: '', name: 'country' }, - relatedTarget: { type: 'button', className: 'btn-icon pgn__form-autosuggest__icon-button' }, - }); - expect(registrationPage.find('div[feedback-for="country"]').exists()).toBeFalsy(); - }); }); });