diff --git a/src/data/constants.js b/src/data/constants.js index e8b1f5ac..7a7d956f 100644 --- a/src/data/constants.js +++ b/src/data/constants.js @@ -10,3 +10,18 @@ export const SUPPORTED_ICON_CLASSES = ['apple', 'facebook', 'google', 'microsoft // Stateful Submit Button States export const DEFAULT_STATE = 'default'; export const PENDING_STATE = 'pending'; + +export const REGISTRATION_VALIDITY_MAP = {}; +export const REGISTRATION_OPTIONAL_MAP = {}; +export const REGISTRATION_EXTRA_FIELDS = [ + 'confirm_email', + 'level_of_education', + 'gender', + 'year_of_birth', + 'mailing_address', + 'goals', + 'honor_code', + 'terms_of_service', + 'city', + 'country', +]; diff --git a/src/data/utils/dataUtils.js b/src/data/utils/dataUtils.js index 5104d099..65693a7a 100644 --- a/src/data/utils/dataUtils.js +++ b/src/data/utils/dataUtils.js @@ -36,3 +36,11 @@ export function convertKeyNames(object, nameMap) { return modifyObjectKeys(object, transformer); } + +export const processLink = (link) => { + let matches; + link.replace(/(.*?)([^<]+)<\/a>(.*)/g, function () { // eslint-disable-line func-names + matches = Array.prototype.slice.call(arguments, 1, 5); // eslint-disable-line prefer-rest-params + }); + return matches; +}; diff --git a/src/logistration/LoginFailure.jsx b/src/logistration/LoginFailure.jsx index 03be8952..a5743991 100644 --- a/src/logistration/LoginFailure.jsx +++ b/src/logistration/LoginFailure.jsx @@ -4,31 +4,14 @@ import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/ import { Alert, Hyperlink } from '@edx/paragon'; import PropTypes from 'prop-types'; +import { processLink } from '../data/utils/dataUtils'; import { NON_COMPLIANT_PASSWORD_EXCEPTION } from './data/constants'; import messages from './messages'; -const processLink = (link) => { - let matches; - link.replace(/(.*)([^<]+)<\/a>(.*)/g, function () { // eslint-disable-line func-names - matches = Array.prototype.slice.call(arguments, 1, 5); // eslint-disable-line prefer-rest-params - }); - return matches; -}; - const LoginFailureMessage = (props) => { const errorMessage = props.errors; const { errorCode, intl } = props; - const Link = (args) => ( - <> - {args.beforeLink} - - {args.linkText} - - {args.afterLink} - - ); - let errorList; switch (errorCode) { @@ -58,12 +41,9 @@ const LoginFailureMessage = (props) => { const [beforeLink, link, linkText, afterLink] = matches; return (
  • - + {beforeLink} + {linkText} + {afterLink}
  • ); } diff --git a/src/logistration/RegistrationPage.jsx b/src/logistration/RegistrationPage.jsx index 1a568df5..7966258f 100644 --- a/src/logistration/RegistrationPage.jsx +++ b/src/logistration/RegistrationPage.jsx @@ -1,22 +1,36 @@ import React from 'react'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; -import { Input, StatefulButton, ValidationFormGroup } from '@edx/paragon'; import { - getLocale, getCountryList, injectIntl, intlShape, + Input, + StatefulButton, + Hyperlink, + ValidationFormGroup, +} from '@edx/paragon'; + +import { + injectIntl, intlShape, } from '@edx/frontend-platform/i18n'; -import { getThirdPartyAuthContext, registerNewUser } from './data/actions'; +import camelCase from 'lodash.camelcase'; +import { getThirdPartyAuthContext, registerNewUser, fetchRegistrationForm } from './data/actions'; import { registrationRequestSelector, thirdPartyAuthContextSelector } from './data/selectors'; import { RedirectLogistration } from '../common-components'; import RegistrationFailure from './RegistrationFailure'; import { - DEFAULT_REDIRECT_URL, DEFAULT_STATE, LOGIN_PAGE, REGISTER_PAGE, + DEFAULT_REDIRECT_URL, + DEFAULT_STATE, + LOGIN_PAGE, + REGISTER_PAGE, + REGISTRATION_VALIDITY_MAP, + REGISTRATION_OPTIONAL_MAP, + REGISTRATION_EXTRA_FIELDS, } from '../data/constants'; import SocialAuthProviders from './SocialAuthProviders'; import ThirdPartyAuthAlert from './ThirdPartyAuthAlert'; import InstitutionLogistration, { RenderInstitutionButton } from './InstitutionLogistration'; import messages from './messages'; +import { processLink } from '../data/utils/dataUtils'; class RegistrationPage extends React.Component { constructor(props, context) { @@ -24,22 +38,36 @@ class RegistrationPage extends React.Component { this.state = { email: '', - fullname: '', + name: '', username: '', password: '', country: '', + city: '', + gender: '', + yearOfBirth: '', + mailingAddress: '', + goals: '', + honorCode: true, + termsOfService: true, + levelOfEducation: '', + confirmEmail: '', + enableOptionalField: false, errors: { email: '', - fullname: '', + name: '', username: '', password: '', country: '', + honorCode: '', + termsOfService: '', }, emailValid: false, nameValid: false, usernameValid: false, passwordValid: false, countryValid: false, + honorCodeValid: false, + termsOfServiceValid: false, formValid: false, institutionLogin: false, }; @@ -51,6 +79,7 @@ class RegistrationPage extends React.Component { redirect_to: params.get('next') || DEFAULT_REDIRECT_URL, }; this.props.getThirdPartyAuthContext(payload); + this.props.fetchRegistrationForm(); } handleInstitutionLogin = () => { @@ -64,10 +93,16 @@ class RegistrationPage extends React.Component { email: this.state.email, username: this.state.username, password: this.state.password, - name: this.state.fullname, - honor_code: true, - country: this.state.country, + name: this.state.name, }; + + const fieldMap = { ...REGISTRATION_VALIDITY_MAP, ...REGISTRATION_OPTIONAL_MAP }; + Object.keys(fieldMap).forEach((key) => { + const value = fieldMap[key]; + if (value) { + payload[key] = this.state[camelCase(key)]; + } + }); const next = params.get('next'); const courseId = params.get('course_id'); if (next) { @@ -78,19 +113,31 @@ class RegistrationPage extends React.Component { } if (!this.state.formValid) { - Object.entries(payload).forEach(([key, value]) => { - this.validateInput(key, value); - }); + // Special case where honor code and tos is a single field, true by default. We don't need + // to validate this field. + Object.entries(payload).filter(([key]) => (key !== 'honor_code' || !('terms_of_service' in REGISTRATION_EXTRA_FIELDS))) + .forEach(([key, value]) => { + this.validateInput(key, value); + }); return; } this.props.registerNewUser(payload); } handleOnChange(e) { + const targetValue = e.target.type === 'checkbox' ? e.target.checked : e.target.value; this.setState({ - [e.target.name]: e.target.value, + [camelCase(e.target.name)]: targetValue, + }); + this.validateInput(e.target.name, targetValue); + } + + handleOnOptional(e) { + const optionalEnable = this.state.enableOptionalField; + const targetValue = e.target.id === 'additionalFields' ? !optionalEnable : e.target.checked; + this.setState({ + enableOptionalField: targetValue, }); - this.validateInput(e.target.name, e.target.value); } validateInput(inputName, value) { @@ -101,6 +148,8 @@ class RegistrationPage extends React.Component { usernameValid, passwordValid, countryValid, + honorCodeValid, + termsOfServiceValid, } = this.state; switch (inputName) { @@ -108,22 +157,30 @@ class RegistrationPage extends React.Component { emailValid = value.match(/^([\w.%+-]+)@([\w-]+\.)+([\w]{2,})$/i); errors.email = emailValid ? '' : null; break; - case 'fullname': + case 'name': nameValid = value.length >= 1; - errors.fullname = nameValid ? '' : null; + errors.name = nameValid ? '' : null; break; case 'username': usernameValid = value.length >= 2 && value.length <= 30; errors.username = usernameValid ? '' : null; break; case 'password': - passwordValid = value.length >= 8 && value.match(/\d+/g); + passwordValid = !!(value.length >= 8 && value.match(/\d+/g)); errors.password = passwordValid ? '' : null; break; case 'country': countryValid = value !== ''; errors.country = countryValid ? '' : null; break; + case 'honor_code': + honorCodeValid = value !== false; + errors.honorCode = honorCodeValid ? '' : null; + break; + case 'terms_of_service': + termsOfServiceValid = value !== false; + errors.termsOfService = termsOfServiceValid ? '' : null; + break; default: break; } @@ -135,6 +192,8 @@ class RegistrationPage extends React.Component { usernameValid, passwordValid, countryValid, + honorCodeValid, + termsOfServiceValid, }, this.validateForm); } @@ -144,18 +203,148 @@ class RegistrationPage extends React.Component { nameValid, usernameValid, passwordValid, - countryValid, } = this.state; + + const validityMap = REGISTRATION_VALIDITY_MAP; + const validStates = []; + Object.keys(validityMap).forEach((key) => { + const value = validityMap[key]; + if (value) { + const state = camelCase(key); + const stateValid = `${state}Valid`; + validStates.push(stateValid); + } + }); + let extraFieldsValid = true; + validStates.forEach((value) => { + extraFieldsValid = extraFieldsValid && this.state[value]; + }); + this.setState({ - formValid: emailValid && nameValid && usernameValid && passwordValid && countryValid, + formValid: emailValid && nameValid && usernameValid && passwordValid && extraFieldsValid, }); } - renderCountryList() { - const locale = getLocale(); - let items = [{ value: '', label: 'Country or Region of Residence (required)' }]; - items = items.concat(getCountryList(locale).map(({ code, name }) => ({ value: code, label: name }))); - return items; + addExtraRequiredFields() { + const fields = this.props.formData.fields.map((field) => { + let options = null; + if (REGISTRATION_EXTRA_FIELDS.includes(field.name)) { + if (field.required) { + const stateVar = camelCase(field.name); + + let beforeLink; + let link; + let linkText; + let afterLink; + + const props = { + id: field.name, + name: field.name, + type: field.type, + value: this.state[stateVar], + required: true, + onChange: e => this.handleOnChange(e), + }; + + REGISTRATION_VALIDITY_MAP[field.name] = true; + if (field.type === 'plaintext' && field.name === 'honor_code') { // special case where honor code and tos are combined + afterLink = field.label; + const nodes = []; + do { + const matches = processLink(afterLink); + [beforeLink, link, linkText, afterLink] = matches; + nodes.push( + <> + {beforeLink} + {linkText} + , + ); + } while (afterLink.includes('a href')); + nodes.push(<>{afterLink}); + + return ( + <> +

    + { nodes } + + ); + } + if (field.type === 'checkbox') { + const matches = processLink(field.label); + [beforeLink, link, linkText, afterLink] = matches; + props.checked = this.state[stateVar]; + return ( + + + {beforeLink} + {linkText} + {afterLink} + + ); + } + if (field.type === 'select') { + options = field.options.map((item) => ({ + value: item.value, + label: item.name, + })); + props.options = options; + } + return ( + + + + + ); + } + } + return (<>); + }); + return fields; + } + + addExtraOptionalFields() { + const fields = this.props.formData.fields.map((field) => { + let options = null; + if (REGISTRATION_EXTRA_FIELDS.includes(field.name)) { + if (!field.required) { + REGISTRATION_OPTIONAL_MAP[field.name] = true; + const props = { + id: field.name, + name: field.name, + type: field.type, + onChange: e => this.handleOnChange(e), + }; + if (field.name !== 'honor_code' && field.name !== 'country') { + if (field.type === 'select') { + options = field.options.map((item) => ({ + value: item.value, + label: item.name, + })); + props.options = options; + } + return ( + + + + + ); + } + } + } + return (<>); + }); + return fields; } render() { @@ -164,6 +353,10 @@ class RegistrationPage extends React.Component { currentProvider, finishAuthUrl, providers, secondaryProviders, } = this.props.thirdPartyAuthContext; + if (!this.props.formData) { + return

    ; + } + if (this.state.institutionLogin) { return ( - + this.handleOnChange(e)} required /> @@ -235,12 +428,12 @@ class RegistrationPage extends React.Component { invalid={this.state.errors.username !== ''} invalidMessage="Username must be between 2 and 30 characters long." > - + this.handleOnChange(e)} required @@ -251,12 +444,12 @@ class RegistrationPage extends React.Component { invalid={this.state.errors.email !== ''} invalidMessage="Enter a valid email address that contains at least 3 characters." > - + this.handleOnChange(e)} required @@ -267,34 +460,34 @@ class RegistrationPage extends React.Component { invalid={this.state.errors.password !== ''} invalidMessage="This password is too short. It must contain at least 8 characters. This password must contain at least 1 number." > - + this.handleOnChange(e)} required /> + { this.addExtraRequiredFields() } - this.handleOnChange(e)} + name="optional" + id="optional" + type="checkbox" + value={this.state.enableOptionalField} + checked={this.state.enableOptionalField} + onChange={e => this.handleOnOptional(e)} required /> + - By creating an account, you agree to the Terms of Service and Honor Code and you acknowledge that edX and each Member process your personal data in accordance with the Privacy Policy. + { this.state.enableOptionalField ? this.addExtraOptionalFields() : null} { @@ -361,6 +560,7 @@ const mapStateToProps = state => { submitState: state.logistration.submitState, registrationResult, thirdPartyAuthContext, + formData: state.logistration.formData, }; }; @@ -368,6 +568,7 @@ export default connect( mapStateToProps, { getThirdPartyAuthContext, + fetchRegistrationForm, registerNewUser, }, )(injectIntl(RegistrationPage)); diff --git a/src/logistration/data/actions.js b/src/logistration/data/actions.js index 18813d53..eeaeedd1 100644 --- a/src/logistration/data/actions.js +++ b/src/logistration/data/actions.js @@ -3,6 +3,7 @@ import { AsyncActionType } from '../../data/utils'; export const REGISTER_NEW_USER = new AsyncActionType('REGISTRATION', 'REGISTER_NEW_USER'); export const LOGIN_REQUEST = new AsyncActionType('LOGIN', 'REQUEST'); export const THIRD_PARTY_AUTH_CONTEXT = new AsyncActionType('THIRD_PARTY_AUTH', 'GET_THIRD_PARTY_AUTH_CONTEXT'); +export const REGISTER_FORM = new AsyncActionType('REGISTRATION', 'GET_FORM_FIELDS'); // Register @@ -64,3 +65,21 @@ export const getThirdPartyAuthContextSuccess = (thirdPartyAuthContext) => ({ export const getThirdPartyAuthContextFailure = () => ({ type: THIRD_PARTY_AUTH_CONTEXT.FAILURE, }); + +// Registration Form Fields +export const fetchRegistrationForm = () => ({ + type: REGISTER_FORM.BASE, +}); + +export const fetchRegistrationFormBegin = () => ({ + type: REGISTER_FORM.BEGIN, +}); + +export const fetchRegistrationFormSuccess = (formData) => ({ + type: REGISTER_FORM.SUCCESS, + payload: { formData }, +}); + +export const fetchRegistrationFormFailure = () => ({ + type: REGISTER_FORM.FAILURE, +}); diff --git a/src/logistration/data/reducers.js b/src/logistration/data/reducers.js index 013b9dd3..dc8a8325 100644 --- a/src/logistration/data/reducers.js +++ b/src/logistration/data/reducers.js @@ -2,6 +2,7 @@ import { REGISTER_NEW_USER, LOGIN_REQUEST, THIRD_PARTY_AUTH_CONTEXT, + REGISTER_FORM, } from './actions'; import { DEFAULT_STATE, PENDING_STATE } from '../../data/constants'; @@ -11,6 +12,7 @@ export const defaultState = { loginResult: {}, registrationError: null, registrationResult: {}, + formData: null, }; const reducer = (state = defaultState, action) => { @@ -60,6 +62,19 @@ const reducer = (state = defaultState, action) => { return { ...state, }; + case REGISTER_FORM.BEGIN: + return { + ...state, + }; + case REGISTER_FORM.SUCCESS: + return { + ...state, + formData: action.payload.formData, + }; + case REGISTER_FORM.FAILURE: + return { + ...state, + }; default: return state; } diff --git a/src/logistration/data/sagas.js b/src/logistration/data/sagas.js index 9eea09e5..9f99dbbf 100644 --- a/src/logistration/data/sagas.js +++ b/src/logistration/data/sagas.js @@ -16,10 +16,19 @@ import { getThirdPartyAuthContextBegin, getThirdPartyAuthContextSuccess, getThirdPartyAuthContextFailure, + REGISTER_FORM, + fetchRegistrationFormBegin, + fetchRegistrationFormSuccess, + fetchRegistrationFormFailure, } from './actions'; // Services -import { getThirdPartyAuthContext, postNewUser, login } from './service'; +import { + getRegistrationForm, + getThirdPartyAuthContext, + postNewUser, + login, +} from './service'; export function* handleNewUserRegistration(action) { try { @@ -71,8 +80,23 @@ export function* fetchThirdPartyAuthContext(action) { } } +export function* fetchRegistrationForm() { + try { + yield put(fetchRegistrationFormBegin()); + const { registrationForm } = yield call(getRegistrationForm); + + yield put(fetchRegistrationFormSuccess( + registrationForm, + )); + } catch (e) { + yield put(fetchRegistrationFormFailure()); + throw e; + } +} + export default function* saga() { yield takeEvery(REGISTER_NEW_USER.BASE, handleNewUserRegistration); yield takeEvery(LOGIN_REQUEST.BASE, handleLoginRequest); yield takeEvery(THIRD_PARTY_AUTH_CONTEXT.BASE, fetchThirdPartyAuthContext); + yield takeEvery(REGISTER_FORM.BASE, fetchRegistrationForm); } diff --git a/src/logistration/data/service.js b/src/logistration/data/service.js index 9351c963..4e231a8a 100644 --- a/src/logistration/data/service.js +++ b/src/logistration/data/service.js @@ -65,3 +65,23 @@ export async function getThirdPartyAuthContext(urlParams) { thirdPartyAuthContext: camelCaseObject(data), }; } + +export async function getRegistrationForm() { + const requestConfig = { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + isPublic: true, + }; + + const { data } = await getAuthenticatedHttpClient() + .get( + `${getConfig().LMS_BASE_URL}/user_api/v1/account/registration/`, + requestConfig, + ) + .catch((e) => { + throw (e); + }); + + return { + registrationForm: data, + }; +} diff --git a/src/logistration/data/tests/sagas.test.js b/src/logistration/data/tests/sagas.test.js index 7d0ba752..84c1012e 100644 --- a/src/logistration/data/tests/sagas.test.js +++ b/src/logistration/data/tests/sagas.test.js @@ -4,8 +4,11 @@ import { getThirdPartyAuthContextBegin, getThirdPartyAuthContextSuccess, getThirdPartyAuthContextFailure, + fetchRegistrationFormBegin, + fetchRegistrationFormSuccess, + fetchRegistrationFormFailure, } from '../actions'; -import { fetchThirdPartyAuthContext } from '../sagas'; +import { fetchThirdPartyAuthContext, fetchRegistrationForm } from '../sagas'; import * as api from '../service'; describe('fetchThirdPartyAuthContext', () => { @@ -53,3 +56,56 @@ describe('fetchThirdPartyAuthContext', () => { getThirdPartyAuthContext.mockClear(); }); }); + +describe('fetchRegistrationForm', () => { + const data = { + fields: [{ + label: 'City', + name: 'city', + type: 'text', + errorMessages: { + required: 'invalid city', + }, + required: true, + }, + { + label: 'I agree to the Your Platform Name Here Honor Code', + name: 'honor_code', + type: 'checkbox', + errorMessages: { + required: 'invalid honor code', + }, + required: true, + }], + }; + + it('should call service and dispatch success action', async () => { + const getRegistrationForm = jest.spyOn(api, 'getRegistrationForm') + .mockImplementation(() => Promise.resolve({ registrationForm: data })); + + const dispatched = []; + await runSaga( + { dispatch: (action) => dispatched.push(action) }, + fetchRegistrationForm, + ); + + expect(getRegistrationForm).toHaveBeenCalledTimes(1); + expect(dispatched).toEqual([fetchRegistrationFormBegin(), fetchRegistrationFormSuccess(data)]); + getRegistrationForm.mockClear(); + }); + + it('should call service and dispatch error action', async () => { + const getRegistrationForm = jest.spyOn(api, 'getRegistrationForm') + .mockImplementation(() => Promise.reject()); + + const dispatched = []; + await runSaga( + { dispatch: (action) => dispatched.push(action) }, + fetchRegistrationForm, + ); + + expect(getRegistrationForm).toHaveBeenCalledTimes(1); + expect(dispatched).toEqual([fetchRegistrationFormBegin(), fetchRegistrationFormFailure()]); + getRegistrationForm.mockClear(); + }); +}); diff --git a/src/logistration/tests/RegistrationPage.test.jsx b/src/logistration/tests/RegistrationPage.test.jsx index 8bca972f..f0d01f13 100644 --- a/src/logistration/tests/RegistrationPage.test.jsx +++ b/src/logistration/tests/RegistrationPage.test.jsx @@ -1,14 +1,15 @@ import React from 'react'; import { Provider } from 'react-redux'; import renderer from 'react-test-renderer'; -import configureStore from 'redux-mock-store'; import { mount } from 'enzyme'; +import configureStore from 'redux-mock-store'; import { getConfig } from '@edx/frontend-platform'; import { IntlProvider, injectIntl, configure } from '@edx/frontend-platform/i18n'; import RegistrationPage from '../RegistrationPage'; import { RenderInstitutionButton } from '../InstitutionLogistration'; import { PENDING_STATE } from '../../data/constants'; +import { fetchRegistrationForm } from '../data/actions'; const IntlRegistrationPage = injectIntl(RegistrationPage); const mockStore = configureStore(); @@ -23,6 +24,17 @@ describe('./RegistrationPage.js', () => { providers: [], secondaryProviders: [], }, + registrationError: null, + formData: { + fields: [{ + label: 'I agree to the Your Platform Name Here Honor Code', + name: 'honor_code', + type: 'checkbox', + errorMessages: { + required: 'You must agree to the Your Platform Name Here Honor Code', + }, + }], + }, }, }; @@ -73,6 +85,170 @@ describe('./RegistrationPage.js', () => { jest.clearAllMocks(); }); + it('should show error message on invalid email', () => { + const validationMessage = 'Enter a valid email address that contains at least 3 characters.'; + store = mockStore({ + ...initialState, + logistration: { + ...initialState.logistration, + }, + }); + + const registrationPage = mount(reduxWrapper()); + registrationPage.find('input#email').simulate('change', { target: { value: '', name: 'email' } }); + registrationPage.update(); + expect(registrationPage.find('#email-invalid-feedback').text()).toEqual(validationMessage); + }); + + it('should show error message on invalid username', () => { + const validationMessage = 'Username must be between 2 and 30 characters long.'; + store = mockStore({ + ...initialState, + logistration: { + ...initialState.logistration, + }, + }); + const registrationPage = mount(reduxWrapper()); + registrationPage.find('input#username').simulate('change', { target: { value: '', name: 'username' } }); + registrationPage.update(); + expect(registrationPage.find('#username-invalid-feedback').text()).toEqual(validationMessage); + }); + + it('should show error message on invalid name', () => { + const validationMessage = 'Enter your full name.'; + store = mockStore({ + ...initialState, + logistration: { + ...initialState.logistration, + }, + }); + const registrationPage = mount(reduxWrapper()); + registrationPage.find('input#name').simulate('change', { target: { value: '', name: 'name' } }); + registrationPage.update(); + expect(registrationPage.find('#name-invalid-feedback').text()).toEqual(validationMessage); + }); + + it('should show error message on invalid password', () => { + const validationMessage = 'This password is too short. It must contain at least 8 characters. This password must contain at least 1 number.'; + store = mockStore({ + ...initialState, + logistration: { + ...initialState.logistration, + }, + }); + const registrationPage = mount(reduxWrapper()); + registrationPage.find('input#password').simulate('change', { target: { value: '', name: 'password' } }); + registrationPage.update(); + expect(registrationPage.find('#password-invalid-feedback').text()).toEqual(validationMessage); + }); + + it('should show error messages on invalid extra fields', () => { + const validationMessage = { + honorCode: 'You must agree to the Your Platform Name Here Honor Code', + country: 'Select your country or region of residence.', + }; + store = mockStore({ + ...initialState, + logistration: { + ...initialState.logistration, + formData: { + fields: [ + { + label: 'I agree to the Your Platform Name Here Honor Code', + name: 'honor_code', + type: 'checkbox', + errorMessages: { + required: validationMessage.honorCode, + }, + required: true, + }, + { + label: 'The country or region where you live.', + name: 'country', + type: 'select', + options: [{ + value: '', name: '--', + }, + { + value: 'AF', name: 'Afghanistan', + }], + errorMessages: { + required: validationMessage.country, + }, + required: true, + }, + ], + }, + }, + }); + const registrationPage = mount(reduxWrapper()); + + registrationPage.find('input#honor_code').simulate('change', { target: { checked: false, name: 'honor_code', type: 'checkbox' } }); + registrationPage.update(); + expect(registrationPage.find('#honor_code-invalid-feedback').text()).toEqual(validationMessage.honorCode); + + registrationPage.find('select#country').simulate('change', { target: { checked: false, name: 'country', type: 'checkbox' } }); + registrationPage.update(); + expect(registrationPage.find('#country-invalid-feedback').text()).toEqual(validationMessage.country); + }); + + it('should toggle optional fields state on checkbox click', () => { + store = mockStore({ + ...initialState, + logistration: { + ...initialState.logistration, + }, + }); + const registrationPage = mount(reduxWrapper()); + registrationPage.find('input#optional').simulate('change', { target: { checked: true } }); + registrationPage.update(); + expect(registrationPage.find('RegistrationPage').state('enableOptionalField')).toEqual(true); + }); + + it('should show optional fields section on optional check enabled', () => { + store = mockStore({ + ...initialState, + logistration: { + ...initialState.logistration, + formData: { + fields: [{ + label: 'Tell us why you\'re interested in edX', + name: 'goals', + type: 'textarea', + required: false, + }, + { + label: 'Highest level of Education completed.', + name: 'level_of_education', + type: 'select', + options: [{ + value: '', name: '--', + }, + { + value: 'p', name: 'Doctorate', + }], + required: false, + }], + }, + }, + }); + const registrationPage = mount(reduxWrapper()); + registrationPage.find('input#optional').simulate('change', { target: { checked: true } }); + registrationPage.update(); + expect(registrationPage.find('textarea#goals').length).toEqual(1); + expect(registrationPage.find('select#level_of_education').length).toEqual(1); + }); + + it('should dispatch fetchRegistrationForm on ComponentDidMount', () => { + store = mockStore({ + ...initialState, + }); + + store.dispatch = jest.fn(store.dispatch); + mount(reduxWrapper()); + expect(store.dispatch).toHaveBeenCalledWith(fetchRegistrationForm()); + }); + it('should match default section snapshot', () => { const tree = renderer.create(reduxWrapper()); expect(tree.toJSON()).toMatchSnapshot(); @@ -191,6 +367,16 @@ describe('./RegistrationPage.js', () => { registerUrl, }], }, + formData: { + fields: [{ + label: 'I agree to the Your Platform Name Here Honor Code', + name: 'honor_code', + type: 'checkbox', + errorMessages: { + required: 'You must agree to the Your Platform Name Here Honor Code', + }, + }], + }, }, }); @@ -247,11 +433,11 @@ describe('./RegistrationPage.js', () => { }, ], }, - response_status: 'complete', }, }); const tree = renderer.create(reduxWrapper()).toJSON(); expect(tree).toMatchSnapshot(); + windowSpy.mockClear(); }); }); diff --git a/src/logistration/tests/__snapshots__/RegistrationPage.test.jsx.snap b/src/logistration/tests/__snapshots__/RegistrationPage.test.jsx.snap index b5b3826d..bc01d9c0 100644 --- a/src/logistration/tests/__snapshots__/RegistrationPage.test.jsx.snap +++ b/src/logistration/tests/__snapshots__/RegistrationPage.test.jsx.snap @@ -64,24 +64,24 @@ exports[`./RegistrationPage.js should match TPA provider snapshot 1`] = ` > Enter your full name. @@ -91,17 +91,17 @@ exports[`./RegistrationPage.js should match TPA provider snapshot 1`] = ` >
    - - - - Select your country or region of residence. - + Support education research by providing additional information +

    - - By creating an account, you agree to the - - Terms of Service and Honor Code - - and you acknowledge that edX and each Member process your personal data in accordance with the - - Privacy Policy - - . -