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 ? (
-
-
-
- ) : (
-
+ )}
>
);
};
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;