diff --git a/src/common-components/messages.jsx b/src/common-components/messages.jsx index 08e88b8f0f..6f0e69cae9 100644 --- a/src/common-components/messages.jsx +++ b/src/common-components/messages.jsx @@ -132,6 +132,12 @@ const messages = defineMessages({ defaultMessage: 'Company or school credentials', description: 'Company or school login link text.', }, + // multi step registration experiment messages + 'tab.back.btn.text': { + id: 'tab.back.btn.text', + defaultMessage: 'Back', + description: 'Tab back button text', + }, }); export default messages; diff --git a/src/config/index.js b/src/config/index.js index fa3613aa88..5d221faa88 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -34,6 +34,8 @@ const configuration = { ZENDESK_LOGO_URL: process.env.ZENDESK_LOGO_URL, ALGOLIA_APP_ID: process.env.ALGOLIA_APP_ID || '', ALGOLIA_SEARCH_API_KEY: process.env.ALGOLIA_SEARCH_API_KEY || '', + // Multi Step Registration Experiment + MULTI_STEP_REGISTRATION_EXPERIMENT_ID: process.env.MULTI_STEP_REGISTRATION_EXPERIMENT_ID || '', }; export default configuration; diff --git a/src/logistration/Logistration.jsx b/src/logistration/Logistration.jsx index 9451aa2745..5199e1b380 100644 --- a/src/logistration/Logistration.jsx +++ b/src/logistration/Logistration.jsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { connect } from 'react-redux'; +import { connect, useDispatch, useSelector } from 'react-redux'; import { getConfig } from '@edx/frontend-platform'; import { sendPageEvent, sendTrackEvent } from '@edx/frontend-platform/analytics'; @@ -7,10 +7,11 @@ import { getAuthService } from '@edx/frontend-platform/auth'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Icon, + IconButton, Tab, Tabs, } from '@openedx/paragon'; -import { ChevronLeft } from '@openedx/paragon/icons'; +import { ArrowBackIos, ChevronLeft } from '@openedx/paragon/icons'; import PropTypes from 'prop-types'; import { Navigate, useNavigate } from 'react-router-dom'; @@ -27,7 +28,11 @@ import { import { LoginPage } from '../login'; import { backupLoginForm } from '../login/data/actions'; import { RegistrationPage } from '../register'; -import { backupRegistrationForm } from '../register/data/actions'; +import { backupRegistrationForm, setMultiStepRegistrationExpData } from '../register/data/actions'; +import { + FIRST_STEP, + getMultiStepRegistrationPreviousStep, +} from '../register/data/optimizelyExperiment/helper'; const Logistration = (props) => { const { selectedPage, tpaProviders } = props; @@ -42,6 +47,10 @@ const Logistration = (props) => { const disablePublicAccountCreation = getConfig().ALLOW_PUBLIC_ACCOUNT_CREATION === false; const hideRegistrationLink = getConfig().SHOW_REGISTRATION_LINKS === false; + const dispatch = useDispatch(); + const multiStepRegExpVariation = useSelector(state => state.register.multiStepRegExpVariation); + const multiStepRegistrationPageStep = useSelector(state => state.register.multiStepRegistrationPageStep); + useEffect(() => { const authService = getAuthService(); if (authService) { @@ -91,6 +100,39 @@ const Logistration = (props) => { ); + /** + * Temporary function created to resolve the complexity in tabs conditioning for multi-step + * registration experiment + */ + const getTabs = () => { + if (multiStepRegistrationPageStep !== FIRST_STEP) { + const prevStep = getMultiStepRegistrationPreviousStep(multiStepRegistrationPageStep); + return ( +
+ { + dispatch(setMultiStepRegistrationExpData(multiStepRegExpVariation, prevStep)); + }} + variant="primary" + size="inline" + className="mr-1" + /> + {formatMessage(messages['tab.back.btn.text'])} +
+ ); + } + return ( + handleOnSelect(tabKey, selectedPage)}> + + + + ); + }; + const isValidTpaHint = () => { const { provider } = getTpaProvider(tpaHint, providers, secondaryProviders); return !!provider; @@ -123,12 +165,7 @@ const Logistration = (props) => { ) - : (!isValidTpaHint() && !hideRegistrationLink && ( - handleOnSelect(tabKey, selectedPage)}> - - - - ))} + : (!isValidTpaHint() && !hideRegistrationLink && getTabs())} { key && ( )} diff --git a/src/logistration/Logistration.test.jsx b/src/logistration/Logistration.test.jsx index 87bf3e705e..828b40347f 100644 --- a/src/logistration/Logistration.test.jsx +++ b/src/logistration/Logistration.test.jsx @@ -15,12 +15,16 @@ import { } from '../data/constants'; import { backupLoginForm } from '../login/data/actions'; import { backupRegistrationForm } from '../register/data/actions'; +import { FIRST_STEP } from '../register/data/optimizelyExperiment/helper'; +import useMultiStepRegistrationExperimentVariation + from '../register/data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation'; jest.mock('@edx/frontend-platform/analytics', () => ({ sendPageEvent: jest.fn(), sendTrackEvent: jest.fn(), })); jest.mock('@edx/frontend-platform/auth'); +jest.mock('../register/data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation', () => jest.fn()); const mockStore = configureStore(); const IntlLogistration = injectIntl(Logistration); @@ -63,6 +67,8 @@ describe('Logistration', () => { registrationError: {}, usernameSuggestions: [], validationApiRateLimited: false, + multiStepRegExpVariation: '', + multiStepRegistrationPageStep: FIRST_STEP, }, commonComponents: { thirdPartyAuthContext: { @@ -83,6 +89,7 @@ describe('Logistration', () => { username: 'test-user', })), })); + useMultiStepRegistrationExperimentVariation.mockReturnValue('default-register-page'); configure({ loggingService: { logError: jest.fn() }, diff --git a/src/register/RegistrationPage.jsx b/src/register/RegistrationPage.jsx index 80ac1f6b1f..c7c4354e5e 100644 --- a/src/register/RegistrationPage.jsx +++ b/src/register/RegistrationPage.jsx @@ -17,14 +17,28 @@ import RegistrationFailure from './components/RegistrationFailure'; import { backupRegistrationFormBegin, clearRegistrationBackendError, + fetchRealtimeValidations, registerNewUser, setEmailSuggestionInStore, + setMultiStepRegistrationExpData, setUserPipelineDataLoaded, } from './data/actions'; import { FORM_SUBMISSION_ERROR, TPA_AUTHENTICATION_FAILURE, } from './data/constants'; +import { + FIRST_STEP, + getMultiStepRegistrationNextStep, + getRegisterButtonLabelInExperiment, + getRegisterButtonSubmitStateInExperiment, + MULTI_STEP_REGISTRATION_EXP_VARIATION, + NOT_INITIALIZED, + SECOND_STEP, + shouldDisplayFieldInExperiment, THIRD_STEP, +} from './data/optimizelyExperiment/helper'; +import useMultiStepRegistrationExperimentVariation + from './data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation'; import getBackendValidations from './data/selectors'; import { isFormValid, prepareRegistrationPayload, @@ -73,6 +87,13 @@ const RegistrationPage = (props) => { const shouldBackupState = useSelector(state => state.register.shouldBackupState); const userPipelineDataLoaded = useSelector(state => state.register.userPipelineDataLoaded); const submitState = useSelector(state => state.register.submitState); + const backendValidations = useSelector(getBackendValidations); + const multiStepRegExpVariation = useSelector(state => state.register.multiStepRegExpVariation); + const multiStepRegistrationPageStep = useSelector(state => state.register.multiStepRegistrationPageStep); + const isValidatingMultiStepRegistrationPage = useSelector( + state => state.register.isValidatingMultiStepRegistrationPage, + ); + const validationsSubmitState = useSelector(state => state.register.validationsSubmitState); const fieldDescriptions = useSelector(state => state.commonComponents.fieldDescriptions); const optionalFields = useSelector(state => state.commonComponents.optionalFields); @@ -85,7 +106,6 @@ const RegistrationPage = (props) => { const secondaryProviders = useSelector(state => state.commonComponents.thirdPartyAuthContext.secondaryProviders); const pipelineUserDetails = useSelector(state => state.commonComponents.thirdPartyAuthContext.pipelineUserDetails); - const backendValidations = useSelector(getBackendValidations); const queryParams = useMemo(() => getAllPossibleQueryParams(), []); const tpaHint = useMemo(() => getTpaHint(), []); @@ -102,6 +122,26 @@ const RegistrationPage = (props) => { ? formatMessage(messages['create.account.cta.button'], { label: cta }) : formatMessage(messages['create.account.for.free.button']); + /** + * Multi-Step Registration Page Experiment + */ + const multiStepRegistrationExpVariation = useMultiStepRegistrationExperimentVariation( + multiStepRegExpVariation, registrationEmbedded, tpaHint, currentProvider, thirdPartyAuthApiStatus, + ); + + useEffect(() => { + if (isValidatingMultiStepRegistrationPage && backendValidations + && Object.values(backendValidations).every(value => value === '') + ) { + setErrorCode({ type: '', count: 0 }); + const nextStep = getMultiStepRegistrationNextStep(multiStepRegistrationPageStep); + dispatch(setMultiStepRegistrationExpData(multiStepRegistrationExpVariation, nextStep)); + } + }, [ // eslint-disable-line react-hooks/exhaustive-deps + isValidatingMultiStepRegistrationPage, + backendValidations, + ]); + /** * Set the userPipelineDetails data in formFields for only first time */ @@ -148,8 +188,11 @@ const RegistrationPage = (props) => { formFields: { ...formFields }, errors: { ...errors }, })); + dispatch(setMultiStepRegistrationExpData( + multiStepRegistrationExpVariation, multiStepRegistrationPageStep, false, + )); } - }, [shouldBackupState, configurableFormFields, formFields, errors, dispatch, backedUpFormData]); + }, [shouldBackupState]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { if (backendValidations) { @@ -169,13 +212,21 @@ const RegistrationPage = (props) => { useEffect(() => { if (registrationResult.success) { + let registeredEventProps = {}; + + if (multiStepRegistrationExpVariation !== NOT_INITIALIZED) { + registeredEventProps = { + variation: multiStepRegistrationExpVariation, + }; + } + // This event is used by GTM - sendTrackEvent('edx.bi.user.account.registered.client', {}); + sendTrackEvent('edx.bi.user.account.registered.client', registeredEventProps); // This is used by the "User Retention Rate Event" on GTM setCookie(getConfig().USER_RETENTION_COOKIE_NAME, true); } - }, [registrationResult]); + }, [registrationResult]); // eslint-disable-line react-hooks/exhaustive-deps const handleOnChange = (event) => { const { name } = event.target; @@ -247,7 +298,37 @@ const RegistrationPage = (props) => { const handleSubmit = (e) => { e.preventDefault(); - registerUser(); + + if (multiStepRegistrationExpVariation === MULTI_STEP_REGISTRATION_EXP_VARIATION + && multiStepRegistrationPageStep !== THIRD_STEP) { + let formFieldsPayload = {}; + + if (multiStepRegistrationPageStep === FIRST_STEP) { + // We only want to validate email in the first step of registration + // Doing manual validations to avoid the case where user clicks CTA without focusing out of field. + formFieldsPayload = { email: formFields.email }; + } else if (multiStepRegistrationPageStep === SECOND_STEP) { + // We only want to validate name and password field in the second step of registration + // Doing manual validations to avoid the case where user clicks CTA without focusing out of field. + formFieldsPayload = { name: formFields.name, password: formFields.password }; + } + + const { isValid, fieldErrors } = isFormValid( + formFieldsPayload, errors, {}, {}, formatMessage, + ); + setErrors(prevErrors => ({ + ...prevErrors, + ...fieldErrors, + })); + // returning if not valid + if (!isValid) { + setErrorCode(prevState => ({ type: FORM_SUBMISSION_ERROR, count: prevState.count + 1 })); + } else { + dispatch(fetchRealtimeValidations(formFieldsPayload, true)); + } + } else { + registerUser(); + } }; useEffect(() => { @@ -282,104 +363,143 @@ const RegistrationPage = (props) => { getConfig().ENABLE_PROGRESSIVE_PROFILING_ON_AUTHN && !!Object.keys(optionalFields.fields).length } /> - {autoSubmitRegForm && !errorCode.type ? ( -
- -
- ) : ( -
- - -
- - - - {!currentProvider && ( - + {(autoSubmitRegForm && !errorCode.type) + || (!multiStepRegistrationExpVariation && !(registrationEmbedded || !!tpaHint || !!currentProvider)) + ? ( +
+ +
+ ) : ( +
+ - e.preventDefault()} + - {!registrationEmbedded && ( - + {(multiStepRegistrationPageStep === SECOND_STEP) && ( +

+ {formatMessage(messages['multistep.registration.username.second.step.guideline.content'])} +

+ )} + {(multiStepRegistrationPageStep === THIRD_STEP) && ( +

+ {formatMessage(messages['multistep.registration.username.third.step.guideline.content'])} +

+ )} + {shouldDisplayFieldInExperiment( + 'name', multiStepRegistrationExpVariation, multiStepRegistrationPageStep, + ) && ( + + )} + {shouldDisplayFieldInExperiment( + 'email', multiStepRegistrationExpVariation, multiStepRegistrationPageStep, + ) && ( + + )} + {shouldDisplayFieldInExperiment( + 'username', multiStepRegistrationExpVariation, multiStepRegistrationPageStep, + ) && ( + + )} + {!currentProvider && shouldDisplayFieldInExperiment( + 'password', multiStepRegistrationExpVariation, multiStepRegistrationPageStep, + ) && ( + + )} + - )} - -
- )} - + e.preventDefault()} + /> + {(!registrationEmbedded && shouldDisplayFieldInExperiment( + 'ThirdPartyAuth', multiStepRegistrationExpVariation, multiStepRegistrationPageStep, + )) + && ( + + )} + +
+ )} ); }; diff --git a/src/register/RegistrationPage.test.jsx b/src/register/RegistrationPage.test.jsx index 2634713f63..b10f79ff53 100644 --- a/src/register/RegistrationPage.test.jsx +++ b/src/register/RegistrationPage.test.jsx @@ -17,6 +17,8 @@ import { setUserPipelineDataLoaded, } from './data/actions'; import { INTERNAL_SERVER_ERROR } from './data/constants'; +import useMultiStepRegistrationExperimentVariation + from './data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation'; import RegistrationPage from './RegistrationPage'; import { AUTHN_PROGRESSIVE_PROFILING, COMPLETE_STATE, PENDING_STATE, REGISTER_PAGE, @@ -30,6 +32,7 @@ jest.mock('@edx/frontend-platform/i18n', () => ({ ...jest.requireActual('@edx/frontend-platform/i18n'), getLocale: jest.fn(), })); +jest.mock('./data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation', () => jest.fn()); const IntlRegistrationPage = injectIntl(RegistrationPage); const mockStore = configureStore(); @@ -128,6 +131,7 @@ describe('RegistrationPage', () => { institutionLogin: false, }; window.location = { search: '' }; + useMultiStepRegistrationExperimentVariation.mockReturnValue('default-register-page'); }); afterEach(() => { @@ -565,7 +569,11 @@ describe('RegistrationPage', () => { delete window.location; window.location = { href: getConfig().BASE_URL }; render(routerWrapper(reduxWrapper())); - expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.user.account.registered.client', {}); + // TODO: temporary change to fix test + // expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.user.account.registered.client', {}); + expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.user.account.registered.client', { + variation: 'default-register-page', + }); }); it('should populate form with pipeline user details', () => { diff --git a/src/register/components/ConfigurableRegistrationForm.jsx b/src/register/components/ConfigurableRegistrationForm.jsx index be1f9c27d1..5e69c7c097 100644 --- a/src/register/components/ConfigurableRegistrationForm.jsx +++ b/src/register/components/ConfigurableRegistrationForm.jsx @@ -6,6 +6,7 @@ import PropTypes from 'prop-types'; import { FormFieldRenderer } from '../../field-renderer'; import { FIELDS } from '../data/constants'; +import { FIRST_STEP, shouldDisplayFieldInExperiment } from '../data/optimizelyExperiment/helper'; import messages from '../messages'; import { CountryField, HonorCode, TermsOfService } from '../RegistrationFields'; @@ -31,6 +32,8 @@ const ConfigurableRegistrationForm = (props) => { setFieldErrors, setFormFields, autoSubmitRegistrationForm, + multiStepRegistrationExpVariation, + multiStepRegistrationPageStep, } = props; const countryList = useMemo(() => getCountryList(getLocale()), []); @@ -105,7 +108,9 @@ const ConfigurableRegistrationForm = (props) => { setFieldErrors(prevErrors => ({ ...prevErrors, [name]: '' })); }; - if (flags.showConfigurableRegistrationFields) { + if (flags.showConfigurableRegistrationFields && shouldDisplayFieldInExperiment( + 'other_fields', multiStepRegistrationExpVariation, multiStepRegistrationPageStep, + )) { Object.keys(fieldDescriptions).forEach(fieldName => { const fieldData = fieldDescriptions[fieldName]; switch (fieldData.name) { @@ -157,7 +162,9 @@ const ConfigurableRegistrationForm = (props) => { }); } - if (flags.showConfigurableEdxFields || showCountryField) { + if ((flags.showConfigurableEdxFields || showCountryField) && shouldDisplayFieldInExperiment( + 'country', multiStepRegistrationExpVariation, multiStepRegistrationPageStep, + )) { formFieldDescriptions.push( { ); } - if (flags.showMarketingEmailOptInCheckbox) { + if (flags.showMarketingEmailOptInCheckbox && shouldDisplayFieldInExperiment( + 'marketing_email_opt_in', multiStepRegistrationExpVariation, multiStepRegistrationPageStep, + )) { formFieldDescriptions.push( { ); } - if (flags.showConfigurableEdxFields || showTermsOfServiceAndHonorCode) { + if ((flags.showConfigurableEdxFields || showTermsOfServiceAndHonorCode) + && shouldDisplayFieldInExperiment( + 'honor_code', multiStepRegistrationExpVariation, multiStepRegistrationPageStep, + )) { formFieldDescriptions.push( @@ -227,11 +239,15 @@ ConfigurableRegistrationForm.propTypes = { setFieldErrors: PropTypes.func.isRequired, setFormFields: PropTypes.func.isRequired, autoSubmitRegistrationForm: PropTypes.bool, + multiStepRegistrationExpVariation: PropTypes.string, + multiStepRegistrationPageStep: PropTypes.string, }; ConfigurableRegistrationForm.defaultProps = { fieldDescriptions: {}, autoSubmitRegistrationForm: false, + multiStepRegistrationExpVariation: '', + multiStepRegistrationPageStep: FIRST_STEP, }; export default ConfigurableRegistrationForm; diff --git a/src/register/components/RegistrationFailure.jsx b/src/register/components/RegistrationFailure.jsx index c34c2af770..3d71536e33 100644 --- a/src/register/components/RegistrationFailure.jsx +++ b/src/register/components/RegistrationFailure.jsx @@ -13,12 +13,13 @@ import { TPA_AUTHENTICATION_FAILURE, TPA_SESSION_EXPIRED, } from '../data/constants'; +import { FIRST_STEP, MULTI_STEP_REGISTRATION_EXP_VARIATION } from '../data/optimizelyExperiment/helper'; import messages from '../messages'; const RegistrationFailureMessage = (props) => { const { formatMessage } = useIntl(); const { - context, errorCode, failureCount, + context, errorCode, failureCount, multiStepRegistrationExpVariation, multiStepRegistrationPageStep, } = props; useEffect(() => { @@ -49,7 +50,12 @@ const RegistrationFailureMessage = (props) => { errorMessage = formatMessage(messages['registration.tpa.session.expired'], { provider: context.provider }); break; default: - errorMessage = formatMessage(messages['registration.empty.form.submission.error']); + if (multiStepRegistrationExpVariation === MULTI_STEP_REGISTRATION_EXP_VARIATION + && multiStepRegistrationPageStep !== FIRST_STEP) { + errorMessage = formatMessage(messages['multistep.registration.form.submission.error']); + } else { + errorMessage = formatMessage(messages['registration.empty.form.submission.error']); + } break; } @@ -65,6 +71,8 @@ RegistrationFailureMessage.defaultProps = { context: { errorMessage: null, }, + multiStepRegistrationExpVariation: '', + multiStepRegistrationPageStep: FIRST_STEP, }; RegistrationFailureMessage.propTypes = { @@ -74,6 +82,8 @@ RegistrationFailureMessage.propTypes = { }), errorCode: PropTypes.string.isRequired, failureCount: PropTypes.number.isRequired, + multiStepRegistrationExpVariation: PropTypes.string, + multiStepRegistrationPageStep: PropTypes.string, }; export default RegistrationFailureMessage; diff --git a/src/register/components/tests/ConfigurableRegistrationForm.test.jsx b/src/register/components/tests/ConfigurableRegistrationForm.test.jsx index 537d11eb7e..90832efeae 100644 --- a/src/register/components/tests/ConfigurableRegistrationForm.test.jsx +++ b/src/register/components/tests/ConfigurableRegistrationForm.test.jsx @@ -11,6 +11,9 @@ import configureStore from 'redux-mock-store'; import { registerNewUser } from '../../data/actions'; import { FIELDS } from '../../data/constants'; +import { FIRST_STEP } from '../../data/optimizelyExperiment/helper'; +import useMultiStepRegistrationExperimentVariation + from '../../data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation'; import RegistrationPage from '../../RegistrationPage'; import ConfigurableRegistrationForm from '../ConfigurableRegistrationForm'; @@ -22,6 +25,7 @@ jest.mock('@edx/frontend-platform/i18n', () => ({ ...jest.requireActual('@edx/frontend-platform/i18n'), getLocale: jest.fn(), })); +jest.mock('../../data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation', () => jest.fn()); const IntlConfigurableRegistrationForm = injectIntl(ConfigurableRegistrationForm); const IntlRegistrationPage = injectIntl(RegistrationPage); @@ -93,6 +97,9 @@ describe('ConfigurableRegistrationForm', () => { registrationError: {}, registrationFormData, usernameSuggestions: [], + multiStepRegistrationPageStep: FIRST_STEP, + multiStepRegExpVariation: '', + isValidatingMultiStepRegistrationPage: false, }, commonComponents: { thirdPartyAuthApiStatus: null, @@ -121,6 +128,7 @@ describe('ConfigurableRegistrationForm', () => { }; window.location = { search: '' }; getLocale.mockImplementationOnce(() => ('en-us')); + useMultiStepRegistrationExperimentVariation.mockReturnValue('default-register-page'); }); afterEach(() => { diff --git a/src/register/components/tests/RegistrationFailure.test.jsx b/src/register/components/tests/RegistrationFailure.test.jsx index 003cc966c6..fb4ecca4f1 100644 --- a/src/register/components/tests/RegistrationFailure.test.jsx +++ b/src/register/components/tests/RegistrationFailure.test.jsx @@ -12,6 +12,8 @@ import configureStore from 'redux-mock-store'; import { FORBIDDEN_REQUEST, INTERNAL_SERVER_ERROR, TPA_AUTHENTICATION_FAILURE, TPA_SESSION_EXPIRED, } from '../../data/constants'; +import useMultiStepRegistrationExperimentVariation + from '../../data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation'; import RegistrationPage from '../../RegistrationPage'; import RegistrationFailureMessage from '../RegistrationFailure'; @@ -23,6 +25,7 @@ jest.mock('@edx/frontend-platform/i18n', () => ({ ...jest.requireActual('@edx/frontend-platform/i18n'), getLocale: jest.fn(), })); +jest.mock('../../data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation', () => jest.fn()); const IntlRegistrationPage = injectIntl(RegistrationPage); const IntlRegistrationFailure = injectIntl(RegistrationFailureMessage); @@ -121,6 +124,7 @@ describe('RegistrationFailure', () => { institutionLogin: false, }; window.location = { search: '' }; + useMultiStepRegistrationExperimentVariation.mockReturnValue('default-register-page'); }); afterEach(() => { diff --git a/src/register/components/tests/ThirdPartyAuth.test.jsx b/src/register/components/tests/ThirdPartyAuth.test.jsx index 917f10f94a..a6bd64af8d 100644 --- a/src/register/components/tests/ThirdPartyAuth.test.jsx +++ b/src/register/components/tests/ThirdPartyAuth.test.jsx @@ -12,6 +12,8 @@ import configureStore from 'redux-mock-store'; import { COMPLETE_STATE, LOGIN_PAGE, PENDING_STATE, REGISTER_PAGE, } from '../../../data/constants'; +import useMultiStepRegistrationExperimentVariation + from '../../data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation'; import RegistrationPage from '../../RegistrationPage'; jest.mock('@edx/frontend-platform/analytics', () => ({ @@ -22,6 +24,7 @@ jest.mock('@edx/frontend-platform/i18n', () => ({ ...jest.requireActual('@edx/frontend-platform/i18n'), getLocale: jest.fn(), })); +jest.mock('../../data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation', () => jest.fn()); const IntlRegistrationPage = injectIntl(RegistrationPage); const mockStore = configureStore(); @@ -120,6 +123,7 @@ describe('ThirdPartyAuth', () => { institutionLogin: false, }; window.location = { search: '' }; + useMultiStepRegistrationExperimentVariation.mockReturnValue('default-register-page'); }); afterEach(() => { diff --git a/src/register/data/actions.js b/src/register/data/actions.js index 9fa5aed500..530f3bc8ac 100644 --- a/src/register/data/actions.js +++ b/src/register/data/actions.js @@ -8,6 +8,7 @@ export const REGISTRATION_CLEAR_BACKEND_ERROR = 'REGISTRATION_CLEAR_BACKEND_ERRO export const REGISTER_SET_COUNTRY_CODE = 'REGISTER_SET_COUNTRY_CODE'; export const REGISTER_SET_USER_PIPELINE_DATA_LOADED = 'REGISTER_SET_USER_PIPELINE_DATA_LOADED'; export const REGISTER_SET_EMAIL_SUGGESTIONS = 'REGISTER_SET_EMAIL_SUGGESTIONS'; +export const REGISTER_SET_MULTI_STEP_REGISTRATION_EXP_DATA = 'REGISTER_SET_MULTI_STEP_REGISTRATION_EXP_DATA'; // Backup registration form export const backupRegistrationForm = () => ({ @@ -20,18 +21,19 @@ export const backupRegistrationFormBegin = (data) => ({ }); // Validate fields from the backend -export const fetchRealtimeValidations = (formPayload) => ({ +export const fetchRealtimeValidations = (formPayload, isValidatingMultiStepRegistrationPage) => ({ type: REGISTER_FORM_VALIDATIONS.BASE, - payload: { formPayload }, + payload: { formPayload, isValidatingMultiStepRegistrationPage }, }); -export const fetchRealtimeValidationsBegin = () => ({ +export const fetchRealtimeValidationsBegin = (isValidatingMultiStepRegistrationPage) => ({ type: REGISTER_FORM_VALIDATIONS.BEGIN, + payload: { isValidatingMultiStepRegistrationPage }, }); -export const fetchRealtimeValidationsSuccess = (validations) => ({ +export const fetchRealtimeValidationsSuccess = (validations, isValidatingMultiStepRegistrationPage) => ({ type: REGISTER_FORM_VALIDATIONS.SUCCESS, - payload: { validations }, + payload: { validations, isValidatingMultiStepRegistrationPage }, }); export const fetchRealtimeValidationsFailure = () => ({ @@ -83,3 +85,11 @@ export const setUserPipelineDataLoaded = (value) => ({ type: REGISTER_SET_USER_PIPELINE_DATA_LOADED, payload: { value }, }); + +// Multi Step Registration Experiment Actions +export const setMultiStepRegistrationExpData = ( + multiStepRegExpVariation, multiStepRegistrationPageStep, isValidatingMultiStepRegistrationPage, +) => ({ + type: REGISTER_SET_MULTI_STEP_REGISTRATION_EXP_DATA, + payload: { multiStepRegExpVariation, multiStepRegistrationPageStep, isValidatingMultiStepRegistrationPage }, +}); diff --git a/src/register/data/optimizelyExperiment/helper.js b/src/register/data/optimizelyExperiment/helper.js new file mode 100644 index 0000000000..bdb11d8f57 --- /dev/null +++ b/src/register/data/optimizelyExperiment/helper.js @@ -0,0 +1,95 @@ +/** + * This file contains data for Multi Step Registration Optimizely experiment + */ +import { getConfig } from '@edx/frontend-platform'; + +import messages from '../../messages'; + +export const NOT_INITIALIZED = 'experiment-not-initialized'; +export const DEFAULT_VARIATION = 'default-register-page'; +export const MULTI_STEP_REGISTRATION_EXP_VARIATION = 'multi-step-register-page'; + +export const FIRST_STEP = 'first-step'; +export const SECOND_STEP = 'second-step'; +export const THIRD_STEP = 'third-step'; + +export const MULTI_STEP_REGISTER_PAGE_FIRST_STEP_FIELDS = ['email', 'marketing_email_opt_in']; +export const MULTI_STEP_REGISTER_PAGE_SECOND_STEP_FIELDS = ['name', 'password']; +export const MULTI_STEP_REGISTER_PAGE_THIRD_STEP_FIELDS = ['username', 'country']; +export const MULTI_STEP_REGISTER_PAGE_COMMON_FIELDS = ['ThirdPartyAuth', 'tos_and_honor_code', 'honor_code']; + +const MULTI_STEP_REGISTRATION_EXP_PAGE = 'authn_register_page'; + +export function getMultiStepRegistrationExperimentVariation() { + try { + if (window.optimizely + && window.optimizely.get('data').experiments[getConfig().MULTI_STEP_REGISTRATION_EXPERIMENT_ID]) { + const selectedVariant = window.optimizely.get('state').getVariationMap()[ + getConfig().MULTI_STEP_REGISTRATION_EXPERIMENT_ID + ]; + return selectedVariant?.name; + } + } catch (e) { /* empty */ } + return ''; +} + +export function activateMultiStepRegistrationExperiment() { + window.optimizely = window.optimizely || []; + window.optimizely.push({ + type: 'page', + pageName: MULTI_STEP_REGISTRATION_EXP_PAGE, + }); +} + +/** + * We want to display username and honor_code fields in second page if user is in multi-step + * registration page experiment + */ +export const shouldDisplayFieldInExperiment = (fieldName, expVariation, registerPageStep) => ( + !expVariation || expVariation === NOT_INITIALIZED || expVariation === DEFAULT_VARIATION + || (expVariation === MULTI_STEP_REGISTRATION_EXP_VARIATION + && ( + MULTI_STEP_REGISTER_PAGE_COMMON_FIELDS.includes(fieldName) + || (registerPageStep === FIRST_STEP && MULTI_STEP_REGISTER_PAGE_FIRST_STEP_FIELDS.includes(fieldName)) + || (registerPageStep === SECOND_STEP && MULTI_STEP_REGISTER_PAGE_SECOND_STEP_FIELDS.includes(fieldName)) + || (registerPageStep === THIRD_STEP && MULTI_STEP_REGISTER_PAGE_THIRD_STEP_FIELDS.includes(fieldName)) + )) +); + +export const getRegisterButtonLabelInExperiment = ( + existingButtonLabel, expVariation, registerPageStep, formatMessage, +) => { + if (expVariation === MULTI_STEP_REGISTRATION_EXP_VARIATION && [FIRST_STEP, SECOND_STEP].includes(registerPageStep)) { + return formatMessage(messages['multistep.registration.exp.continue.button']); + } + return existingButtonLabel; +}; + +export const getRegisterButtonSubmitStateInExperiment = ( + registerSubmitState, validationsSubmitState, expVariation, registerPageStep, +) => { + if (expVariation === MULTI_STEP_REGISTRATION_EXP_VARIATION && registerPageStep !== THIRD_STEP) { + return validationsSubmitState; + } + return registerSubmitState; +}; + +export const getMultiStepRegistrationPreviousStep = (currentStep) => { + if (currentStep === THIRD_STEP) { + return SECOND_STEP; + } + if (currentStep === SECOND_STEP) { + return FIRST_STEP; + } + return currentStep; +}; + +export const getMultiStepRegistrationNextStep = (currentStep) => { + if (currentStep === FIRST_STEP) { + return SECOND_STEP; + } + if (currentStep === SECOND_STEP) { + return THIRD_STEP; + } + return currentStep; +}; diff --git a/src/register/data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation.jsx b/src/register/data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation.jsx new file mode 100644 index 0000000000..692101b7fc --- /dev/null +++ b/src/register/data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation.jsx @@ -0,0 +1,54 @@ +import { useEffect, useState } from 'react'; + +import { + activateMultiStepRegistrationExperiment, + getMultiStepRegistrationExperimentVariation, + NOT_INITIALIZED, +} from './helper'; +import { COMPLETE_STATE } from '../../../data/constants'; + +/** + * This hook returns activates multi step registration experiment and returns the experiment + * variation for the user. + */ +const useMultiStepRegistrationExperimentVariation = ( + initExpVariation, + registrationEmbedded, + tpaHint, + currentProvider, + thirdPartyAuthApiStatus, +) => { + const [variation, setVariation] = useState(initExpVariation); + + useEffect(() => { + if (initExpVariation || registrationEmbedded || !!tpaHint || !!currentProvider + || thirdPartyAuthApiStatus !== COMPLETE_STATE) { + return variation; + } + + const getVariation = () => { + const expVariation = getMultiStepRegistrationExperimentVariation(); + if (expVariation) { + setVariation(expVariation); + } else { + // This is to handle the case when user dont get variation for some reason, the register page + // shows unlimited spinner. + setVariation(NOT_INITIALIZED); + } + }; + + activateMultiStepRegistrationExperiment(); + + const timer = setTimeout(getVariation, 300); + + return () => { + clearTimeout(timer); + }; + }, [ // eslint-disable-line react-hooks/exhaustive-deps + currentProvider, initExpVariation, registrationEmbedded, thirdPartyAuthApiStatus, tpaHint, + ]); + + return variation; +}; + +export default useMultiStepRegistrationExperimentVariation; diff --git a/src/register/data/reducers.js b/src/register/data/reducers.js index 70c3a994d0..3243887768 100644 --- a/src/register/data/reducers.js +++ b/src/register/data/reducers.js @@ -5,9 +5,11 @@ import { REGISTER_NEW_USER, REGISTER_SET_COUNTRY_CODE, REGISTER_SET_EMAIL_SUGGESTIONS, + REGISTER_SET_MULTI_STEP_REGISTRATION_EXP_DATA, REGISTER_SET_USER_PIPELINE_DATA_LOADED, REGISTRATION_CLEAR_BACKEND_ERROR, } from './actions'; +import { FIRST_STEP } from './optimizelyExperiment/helper'; import { DEFAULT_STATE, PENDING_STATE, @@ -35,10 +37,14 @@ export const defaultState = { }, validations: null, submitState: DEFAULT_STATE, + validationsSubmitState: DEFAULT_STATE, userPipelineDataLoaded: false, usernameSuggestions: [], validationApiRateLimited: false, shouldBackupState: false, + multiStepRegExpVariation: '', + multiStepRegistrationPageStep: FIRST_STEP, + isValidatingMultiStepRegistrationPage: false, }; const reducer = (state = defaultState, action = {}) => { @@ -85,12 +91,22 @@ const reducer = (state = defaultState, action = {}) => { registrationError: { ...registrationErrorTemp }, }; } + case REGISTER_FORM_VALIDATIONS.BEGIN: { + return { + ...state, + validationsSubmitState: action.payload?.isValidatingMultiStepRegistrationPage + ? PENDING_STATE + : state.validationsSubmitState, + }; + } case REGISTER_FORM_VALIDATIONS.SUCCESS: { const { usernameSuggestions, ...validationWithoutUsernameSuggestions } = action.payload.validations; return { ...state, validations: validationWithoutUsernameSuggestions, + isValidatingMultiStepRegistrationPage: !!action.payload?.isValidatingMultiStepRegistrationPage, usernameSuggestions: usernameSuggestions || state.usernameSuggestions, + validationsSubmitState: DEFAULT_STATE, }; } case REGISTER_FORM_VALIDATIONS.FAILURE: @@ -98,6 +114,7 @@ const reducer = (state = defaultState, action = {}) => { ...state, validationApiRateLimited: true, validations: null, + validationsSubmitState: DEFAULT_STATE, }; case REGISTER_CLEAR_USERNAME_SUGGESTIONS: return { @@ -129,6 +146,14 @@ const reducer = (state = defaultState, action = {}) => { emailSuggestion: action.payload.emailSuggestion, }, }; + case REGISTER_SET_MULTI_STEP_REGISTRATION_EXP_DATA: { + return { + ...state, + multiStepRegExpVariation: action.payload.multiStepRegExpVariation, + multiStepRegistrationPageStep: action.payload.multiStepRegistrationPageStep, + isValidatingMultiStepRegistrationPage: !!action.payload?.isValidatingMultiStepRegistrationPage, + }; + } default: return { ...state, diff --git a/src/register/data/sagas.js b/src/register/data/sagas.js index 0325cd4bec..3edd395e12 100644 --- a/src/register/data/sagas.js +++ b/src/register/data/sagas.js @@ -40,10 +40,13 @@ export function* handleNewUserRegistration(action) { export function* fetchRealtimeValidations(action) { try { - yield put(fetchRealtimeValidationsBegin()); + yield put(fetchRealtimeValidationsBegin(action.payload?.isValidatingMultiStepRegistrationPage)); const { fieldValidations } = yield call(getFieldsValidations, action.payload.formPayload); - yield put(fetchRealtimeValidationsSuccess(camelCaseObject(fieldValidations))); + yield put(fetchRealtimeValidationsSuccess( + camelCaseObject(fieldValidations), + action.payload?.isValidatingMultiStepRegistrationPage, + )); } catch (e) { if (e.response && e.response.status === 403) { yield put(fetchRealtimeValidationsFailure()); diff --git a/src/register/data/tests/reducers.test.js b/src/register/data/tests/reducers.test.js index 3e2270ab93..909704d2b0 100644 --- a/src/register/data/tests/reducers.test.js +++ b/src/register/data/tests/reducers.test.js @@ -11,6 +11,7 @@ import { REGISTER_SET_USER_PIPELINE_DATA_LOADED, REGISTRATION_CLEAR_BACKEND_ERROR, } from '../actions'; +import { FIRST_STEP } from '../optimizelyExperiment/helper'; import reducer from '../reducers'; describe('Registration Reducer Tests', () => { @@ -34,10 +35,14 @@ describe('Registration Reducer Tests', () => { }, validations: null, submitState: DEFAULT_STATE, + validationsSubmitState: DEFAULT_STATE, userPipelineDataLoaded: false, usernameSuggestions: [], validationApiRateLimited: false, shouldBackupState: false, + multiStepRegExpVariation: '', + multiStepRegistrationPageStep: FIRST_STEP, + isValidatingMultiStepRegistrationPage: false, }; it('should return the initial state', () => { diff --git a/src/register/data/utils.js b/src/register/data/utils.js index b0cff129a3..747f2f2c30 100644 --- a/src/register/data/utils.js +++ b/src/register/data/utils.js @@ -43,30 +43,39 @@ export const isFormValid = ( Object.keys(payload).forEach(key => { switch (key) { case 'name': - fieldErrors.name = validateName(payload.name, formatMessage); + if (!fieldErrors.name) { + fieldErrors.name = validateName(payload.name, formatMessage); + } if (fieldErrors.name) { isValid = false; } break; case 'email': { - const { - fieldError, confirmEmailError, suggestion, - } = validateEmail(payload.email, configurableFormFields?.confirm_email, formatMessage); - if (fieldError) { - fieldErrors.email = fieldError; - isValid = false; - } - if (confirmEmailError) { - fieldErrors.confirm_email = confirmEmailError; - isValid = false; + if (!fieldErrors.email) { + const { + fieldError, confirmEmailError, suggestion, + } = validateEmail(payload.email, configurableFormFields?.confirm_email, formatMessage); + if (fieldError) { + fieldErrors.email = fieldError; + isValid = false; + } + if (confirmEmailError) { + fieldErrors.confirm_email = confirmEmailError; + isValid = false; + } + emailSuggestion = suggestion; } - emailSuggestion = suggestion; + if (fieldErrors.email) { isValid = false; } break; } case 'username': - fieldErrors.username = validateUsername(payload.username, formatMessage); + if (!fieldErrors.username) { + fieldErrors.username = validateUsername(payload.username, formatMessage); + } if (fieldErrors.username) { isValid = false; } break; case 'password': - fieldErrors.password = validatePasswordField(payload.password, formatMessage); + if (!fieldErrors.password) { + fieldErrors.password = validatePasswordField(payload.password, formatMessage); + } if (fieldErrors.password) { isValid = false; } break; default: diff --git a/src/register/messages.jsx b/src/register/messages.jsx index 39d9e7f549..cea5e5dfc5 100644 --- a/src/register/messages.jsx +++ b/src/register/messages.jsx @@ -201,6 +201,29 @@ const messages = defineMessages({ defaultMessage: 'Did you mean', description: 'Did you mean alert suggestion', }, + // MultiStep Registration experiment + 'multistep.registration.exp.continue.button': { + id: 'multistep.registration.exp.continue.button', + defaultMessage: 'Continue', + description: 'Label text for multistep registration page second step', + }, + 'multistep.registration.username.second.step.guideline.content': { + id: 'multistep.registration.username.second.step.guideline.content', + defaultMessage: 'Finish Registration', + description: 'Guideline content for username field in multi-step registration experiment step 2', + }, + 'multistep.registration.username.third.step.guideline.content': { + id: 'multistep.registration.username.third.step.guideline.content', + defaultMessage: 'To finalize your registration, please confirm your country of residence ' + + 'and create a public username that will identify you in your course communication forums. ' + + 'The username cannot be changed.', + description: 'Guideline content for username field in multi-step registration experiment step 2', + }, + 'multistep.registration.form.submission.error': { + id: 'multistep.registration.form.submission.error', + defaultMessage: 'Please check your responses for this and the previous step and try again.', + description: 'Error message that appears on top of the form when invalid form is submitted', + }, }); export default messages;