diff --git a/src/_style.scss b/src/_style.scss index ea43c55c..951e825b 100644 --- a/src/_style.scss +++ b/src/_style.scss @@ -624,6 +624,7 @@ select.form-control { position: absolute; background-color: #fff; width: 464px; + z-index: 100 !important; } .email-error-alert { @@ -857,7 +858,6 @@ select.form-control { font-size: 0.75rem; line-height: 1.25rem; } - margin-top: 1rem; margin-left: 3px; } .suggested-username { diff --git a/src/common-components/data/actions.js b/src/common-components/data/actions.js index 28a34982..6eb655b5 100644 --- a/src/common-components/data/actions.js +++ b/src/common-components/data/actions.js @@ -12,9 +12,9 @@ export const getThirdPartyAuthContextBegin = () => ({ type: THIRD_PARTY_AUTH_CONTEXT.BEGIN, }); -export const getThirdPartyAuthContextSuccess = (thirdPartyAuthContext) => ({ +export const getThirdPartyAuthContextSuccess = (fieldDescriptions, thirdPartyAuthContext) => ({ type: THIRD_PARTY_AUTH_CONTEXT.SUCCESS, - payload: { thirdPartyAuthContext }, + payload: { fieldDescriptions, thirdPartyAuthContext }, }); export const getThirdPartyAuthContextFailure = () => ({ diff --git a/src/common-components/data/reducers.js b/src/common-components/data/reducers.js index 6a944bd2..9cac42b2 100644 --- a/src/common-components/data/reducers.js +++ b/src/common-components/data/reducers.js @@ -3,6 +3,8 @@ import { THIRD_PARTY_AUTH_CONTEXT } from './actions'; import { PENDING_STATE, COMPLETE_STATE } from '../../data/constants'; export const defaultState = { + extendedProfile: [], + fieldDescriptions: {}, thirdPartyAuthApiStatus: null, }; @@ -16,6 +18,8 @@ const reducer = (state = defaultState, action) => { case THIRD_PARTY_AUTH_CONTEXT.SUCCESS: return { ...state, + extendedProfile: action.payload.fieldDescriptions.extendedProfile, + fieldDescriptions: action.payload.fieldDescriptions.fields, thirdPartyAuthContext: action.payload.thirdPartyAuthContext, thirdPartyAuthApiStatus: COMPLETE_STATE, }; diff --git a/src/common-components/data/sagas.js b/src/common-components/data/sagas.js index d3ac7f03..4c56845a 100644 --- a/src/common-components/data/sagas.js +++ b/src/common-components/data/sagas.js @@ -18,10 +18,10 @@ import { export function* fetchThirdPartyAuthContext(action) { try { yield put(getThirdPartyAuthContextBegin()); - const { thirdPartyAuthContext } = yield call(getThirdPartyAuthContext, action.payload.urlParams); + const { fieldDescriptions, thirdPartyAuthContext } = yield call(getThirdPartyAuthContext, action.payload.urlParams); yield put(getThirdPartyAuthContextSuccess( - thirdPartyAuthContext, + fieldDescriptions, thirdPartyAuthContext, )); } catch (e) { yield put(getThirdPartyAuthContextFailure()); diff --git a/src/common-components/data/selectors.js b/src/common-components/data/selectors.js index f5bf2173..393385e3 100644 --- a/src/common-components/data/selectors.js +++ b/src/common-components/data/selectors.js @@ -8,3 +8,13 @@ export const thirdPartyAuthContextSelector = createSelector( commonComponentsSelector, commonComponents => commonComponents.thirdPartyAuthContext, ); + +export const fieldDescriptionSelector = createSelector( + commonComponentsSelector, + commonComponents => commonComponents.fieldDescriptions, +); + +export const extendedProfileSelector = createSelector( + commonComponentsSelector, + commonComponents => commonComponents.extendedProfile, +); diff --git a/src/common-components/data/service.js b/src/common-components/data/service.js index cd6d97b1..e083c871 100644 --- a/src/common-components/data/service.js +++ b/src/common-components/data/service.js @@ -18,6 +18,11 @@ export async function getThirdPartyAuthContext(urlParams) { throw (e); }); return { - thirdPartyAuthContext: camelCaseObject(convertKeyNames(data, { fullname: 'name' })), + fieldDescriptions: data.registration_fields || {}, + // For backward compatibility with the API, once https://github.com/openedx/edx-platform/pull/30198 is merged + // and deployed update it to use data.context_data + thirdPartyAuthContext: camelCaseObject( + convertKeyNames(data.context_data || data, { fullname: 'name' }), + ), }; } diff --git a/src/common-components/data/tests/sagas.test.js b/src/common-components/data/tests/sagas.test.js index fe0feb8a..1b40ce90 100644 --- a/src/common-components/data/tests/sagas.test.js +++ b/src/common-components/data/tests/sagas.test.js @@ -26,7 +26,7 @@ describe('fetchThirdPartyAuthContext', () => { it('should call service and dispatch success action', async () => { const getThirdPartyAuthContext = jest.spyOn(api, 'getThirdPartyAuthContext') - .mockImplementation(() => Promise.resolve({ thirdPartyAuthContext: data })); + .mockImplementation(() => Promise.resolve({ thirdPartyAuthContext: data, fieldDescriptions: {} })); const dispatched = []; await runSaga( @@ -38,7 +38,7 @@ describe('fetchThirdPartyAuthContext', () => { expect(getThirdPartyAuthContext).toHaveBeenCalledTimes(1); expect(dispatched).toEqual([ actions.getThirdPartyAuthContextBegin(), - actions.getThirdPartyAuthContextSuccess(data), + actions.getThirdPartyAuthContextSuccess({}, data), ]); getThirdPartyAuthContext.mockClear(); }); diff --git a/src/field-renderer/FieldRenderer.jsx b/src/field-renderer/FieldRenderer.jsx index b8d43836..6756a97a 100644 --- a/src/field-renderer/FieldRenderer.jsx +++ b/src/field-renderer/FieldRenderer.jsx @@ -6,7 +6,17 @@ import { ExpandMore } from '@edx/paragon/icons'; const FormFieldRenderer = (props) => { let formField = null; - const { fieldData, onChangeHandler, value } = props; + const { + errorMessage, fieldData, onChangeHandler, isRequired, value, + } = props; + + const handleFocus = (e) => { + if (props.handleFocus) { props.handleFocus(e); } + }; + + const handleOnBlur = (e) => { + if (props.handleBlur) { props.handleBlur(e); } + }; switch (fieldData.type) { case 'select': { @@ -14,63 +24,95 @@ const FormFieldRenderer = (props) => { return null; } formField = ( - + onChangeHandler(e)} trailingElement={} floatingLabel={fieldData.label} + onBlur={handleOnBlur} + onFocus={handleFocus} > {fieldData.options.map(option => ( ))} + {isRequired && errorMessage && ( + + {errorMessage} + + )} ); break; } case 'textarea': { formField = ( - + onChangeHandler(e)} floatingLabel={fieldData.label} + onBlur={handleOnBlur} + onFocus={handleFocus} /> + {isRequired && errorMessage && ( + + {errorMessage} + + )} ); break; } case 'text': { formField = ( - + onChangeHandler(e)} floatingLabel={fieldData.label} + onBlur={handleOnBlur} + onFocus={handleFocus} /> + {isRequired && errorMessage && ( + + {errorMessage} + + )} ); break; } case 'checkbox': { formField = ( - + onChangeHandler(e)} + onBlur={handleOnBlur} + onFocus={handleFocus} > {fieldData.label} + {isRequired && errorMessage && ( + + {errorMessage} + + )} ); break; @@ -83,6 +125,10 @@ const FormFieldRenderer = (props) => { }; FormFieldRenderer.defaultProps = { value: '', + handleBlur: null, + handleFocus: null, + errorMessage: '', + isRequired: false, }; FormFieldRenderer.propTypes = { @@ -92,6 +138,10 @@ FormFieldRenderer.propTypes = { name: PropTypes.string, }).isRequired, onChangeHandler: PropTypes.func.isRequired, + handleBlur: PropTypes.func, + handleFocus: PropTypes.func, + errorMessage: PropTypes.string, + isRequired: PropTypes.bool, value: PropTypes.string, }; diff --git a/src/field-renderer/tests/FieldRenderer.test.jsx b/src/field-renderer/tests/FieldRenderer.test.jsx index 58b358b3..d0fc20f6 100644 --- a/src/field-renderer/tests/FieldRenderer.test.jsx +++ b/src/field-renderer/tests/FieldRenderer.test.jsx @@ -102,4 +102,96 @@ describe('FieldRendererTests', () => { const fieldRenderer = mount( {}} />); expect(fieldRenderer.html()).toBeNull(); }); + + it('should run onBlur and onFocus functions for a field if given', () => { + const fieldData = { type: 'text', label: 'Test', name: 'test-field' }; + let functionValue = ''; + + const onBlur = (e) => { + functionValue = `${e.target.name} blurred`; + }; + + const onFocus = (e) => { + functionValue = `${e.target.name} focussed`; + }; + + const fieldRenderer = mount( + , + ); + const field = fieldRenderer.find('#test-field').last(); + + field.simulate('focus'); + expect(functionValue).toEqual('test-field focussed'); + + field.simulate('blur'); + expect(functionValue).toEqual('test-field blurred'); + }); + + it('should render error message for required text fields', () => { + const fieldData = { type: 'text', label: 'First Name', name: 'first-name-field' }; + + const fieldRenderer = mount( + , + ); + + expect(fieldRenderer.find('.form-text-size').last().text()).toEqual('Enter your first name'); + }); + + it('should render error message for required select fields', () => { + const fieldData = { + type: 'select', label: 'Preference', name: 'preference-field', options: [['a', 'Opt 1'], ['b', 'Opt 2']], + }; + + const fieldRenderer = mount( + , + ); + + expect(fieldRenderer.find('.form-text-size').last().text()).toEqual('Select your preference'); + }); + + it('should render error message for required textarea fields', () => { + const fieldData = { type: 'textarea', label: 'Goals', name: 'goals-field' }; + + const fieldRenderer = mount( + , + ); + + expect(fieldRenderer.find('.form-text-size').last().text()).toEqual('Tell us your goals'); + }); + + it('should render error message for required checkbox fields', () => { + const fieldData = { type: 'checkbox', label: 'Honor Code', name: 'honor-code-field' }; + + const fieldRenderer = mount( + , + ); + + expect(fieldRenderer.find('.form-text-size').last().text()).toEqual('You must agree to our Honor Code'); + }); }); diff --git a/src/index.jsx b/src/index.jsx index 19c8a2bd..4a810beb 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -41,6 +41,7 @@ initialize({ MARKETING_EMAILS_OPT_IN: process.env.MARKETING_EMAILS_OPT_IN || '', ENABLE_COPPA_COMPLIANCE: process.env.ENABLE_COPPA_COMPLIANCE || '', SHOW_DYNAMIC_PROFILING_PAGE: process.env.SHOW_DYNAMIC_PROFILING_PAGE || false, + ENABLE_DYNAMIC_REGISTRATION_FIELDS: process.env.ENABLE_DYNAMIC_REGISTRATION_FIELDS || false, }); }, }, diff --git a/src/register/CountryDropdown.jsx b/src/register/CountryDropdown.jsx index d6ffc3a5..751e9ef2 100644 --- a/src/register/CountryDropdown.jsx +++ b/src/register/CountryDropdown.jsx @@ -176,7 +176,7 @@ class CountryDropdown extends React.Component { render() { return ( -
+
{ + const { + intl, errorMessage, onChangeHandler, fieldType, value, + } = props; + + if (fieldType === 'tos_and_honor_code') { + return ( +
+ + {intl.formatMessage(messages['terms.of.service.and.honor.code'])} + + ), + privacyPolicy: ( + + {intl.formatMessage(messages['privacy.policy'])} + + ), + }} + /> +
+ ); + } + + return ( +
+ + + {intl.formatMessage(messages['honor.code'])} + + ), + }} + /> + + {errorMessage && ( + + {errorMessage} + + )} +
+ ); +}; + +HonorCode.defaultProps = { + errorMessage: '', + onChangeHandler: null, + fieldType: 'honor_code', + value: false, +}; + +HonorCode.propTypes = { + intl: intlShape.isRequired, + errorMessage: PropTypes.string, + onChangeHandler: PropTypes.func, + fieldType: PropTypes.string, + value: PropTypes.bool, +}; + +export default injectIntl(HonorCode); diff --git a/src/register/RegistrationPage.jsx b/src/register/RegistrationPage.jsx index 1c83bca7..ee5fd44c 100644 --- a/src/register/RegistrationPage.jsx +++ b/src/register/RegistrationPage.jsx @@ -5,35 +5,43 @@ import Skeleton from 'react-loading-skeleton'; import { Helmet } from 'react-helmet'; import PropTypes, { string } from 'prop-types'; -import { getConfig } from '@edx/frontend-platform'; +import { getConfig, snakeCaseObject } from '@edx/frontend-platform'; import { sendPageEvent, sendTrackEvent } from '@edx/frontend-platform/analytics'; import { - injectIntl, intlShape, getCountryList, getLocale, FormattedMessage, + injectIntl, intlShape, getCountryList, getLocale, } from '@edx/frontend-platform/i18n'; import { - Alert, Form, Hyperlink, StatefulButton, Icon, + Alert, Form, StatefulButton, Icon, } from '@edx/paragon'; import { Error, Close } from '@edx/paragon/icons'; - +import FormFieldRenderer from '../field-renderer'; import { clearUsernameSuggestions, registerNewUser, resetRegistrationForm, fetchRealtimeValidations, } from './data/actions'; import { - FORM_SUBMISSION_ERROR, DEFAULT_SERVICE_PROVIDER_DOMAINS, DEFAULT_TOP_LEVEL_DOMAINS, COMMON_EMAIL_PROVIDERS, + FIELDS, FORM_SUBMISSION_ERROR, DEFAULT_SERVICE_PROVIDER_DOMAINS, DEFAULT_TOP_LEVEL_DOMAINS, COMMON_EMAIL_PROVIDERS, } from './data/constants'; import { - registrationErrorSelector, registrationRequestSelector, validationsSelector, usernameSuggestionsSelector, + registrationErrorSelector, + registrationRequestSelector, + validationsSelector, + usernameSuggestionsSelector, } from './data/selectors'; import messages from './messages'; import RegistrationFailure from './RegistrationFailure'; import UsernameField from './UsernameField'; +import HonorCode from './HonorCode'; import { RedirectLogistration, SocialAuthProviders, ThirdPartyAuthAlert, RenderInstitutionButton, InstitutionLogistration, FormGroup, PasswordField, } from '../common-components'; import { getThirdPartyAuthContext } from '../common-components/data/actions'; -import { thirdPartyAuthContextSelector } from '../common-components/data/selectors'; +import { + extendedProfileSelector, + fieldDescriptionSelector, + thirdPartyAuthContextSelector, +} from '../common-components/data/selectors'; import EnterpriseSSO from '../common-components/EnterpriseSSO'; import { DEFAULT_STATE, PENDING_STATE, REGISTER_PAGE, VALID_EMAIL_REGEX, LETTER_REGEX, NUMBER_REGEX, VALID_NAME_REGEX, @@ -43,6 +51,7 @@ import { } from '../data/utils'; import CountryDropdown from './CountryDropdown'; import { getLevenshteinSuggestion, getSuggestionForInvalidEmail } from './utils'; +import TermsOfService from './TermsOfService'; class RegistrationPage extends React.Component { constructor(props, context) { @@ -51,6 +60,9 @@ class RegistrationPage extends React.Component { this.handleOnClose = this.handleOnClose.bind(this); this.queryParams = getAllPossibleQueryParam(); + // TODO: Once we have tested it and ready for openedX we can remove this flag and make the code + // permanent part of Authn and remove extra code + this.showDynamicRegistrationFields = getConfig().ENABLE_DYNAMIC_REGISTRATION_FIELDS; this.tpaHint = getTpaHint(); this.state = { country: '', @@ -75,6 +87,7 @@ class RegistrationPage extends React.Component { optimizelyExperimentName: '', readOnly: true, validatePassword: false, + values: {}, }; } @@ -171,16 +184,28 @@ class RegistrationPage extends React.Component { } }; + onChangeHandler = (e) => { + const { name, value, checked } = e.target; + const { errors, values } = this.state; + if (e.target.type === 'checkbox') { + errors[name] = ''; + values[name] = checked; + } else { + values[name] = value; + } + const state = { errors, values }; + this.setState({ ...state }); + }; + handleSubmit = (e) => { e.preventDefault(); const { startTime } = this.state; const totalRegistrationTime = (Date.now() - startTime) / 1000; - const payload = { + const dynamicFieldErrorMessages = {}; + let payload = { name: this.state.name, username: this.state.username, email: this.state.email, - country: this.state.country, - honor_code: true, is_authn_mfe: true, }; @@ -190,7 +215,28 @@ class RegistrationPage extends React.Component { payload.password = this.state.password; } - if (!this.isFormValid(payload)) { + if (this.showDynamicRegistrationFields) { + payload.extendedProfile = []; + Object.keys(this.props.fieldDescriptions).forEach((fieldName) => { + if (this.props.extendedProfile.includes(fieldName)) { + payload.extendedProfile.push({ fieldName, fieldValue: this.state.values[fieldName] }); + } else { + payload[fieldName] = this.state.values[fieldName]; + } + dynamicFieldErrorMessages[fieldName] = this.props.fieldDescriptions[fieldName].error_message; + }); + if ( + this.props.fieldDescriptions[FIELDS.HONOR_CODE] + && this.props.fieldDescriptions[FIELDS.HONOR_CODE].type === 'tos_and_honor_code' + ) { + payload[FIELDS.HONOR_CODE] = true; + } + } else { + payload.country = this.state.country; + payload.honor_code = true; + } + + if (!this.isFormValid(payload, dynamicFieldErrorMessages)) { this.setState(prevState => ({ errorCode: FORM_SUBMISSION_ERROR, failureCount: prevState.failureCount + 1, @@ -202,6 +248,7 @@ class RegistrationPage extends React.Component { payload.marketing_emails_opt_in = this.state.marketingOptIn; } + payload = snakeCaseObject(payload); payload.totalRegistrationTime = totalRegistrationTime; this.setState({ totalRegistrationTime, @@ -282,13 +329,22 @@ class RegistrationPage extends React.Component { this.props.clearUsernameSuggestions(); } - isFormValid(payload) { + validateDynamicFields = (e) => { + const { errors } = this.state; + const { name, value } = e.target; + if (!value) { + errors[name] = this.props.fieldDescriptions[name].error_message; + } + this.setState({ errors }); + } + + isFormValid(payload, dynamicFieldError) { const { errors } = this.state; let isValid = true; Object.keys(payload).forEach(key => { if (!payload[key]) { - errors[key] = this.props.intl.formatMessage(messages[`empty.${key}.field.error`]); + errors[key] = (key in dynamicFieldError) ? dynamicFieldError[key] : this.props.intl.formatMessage(messages[`empty.${key}.field.error`]); } // Mark form invalid, if there was already a validation error for this key or we added empty field error if (errors[key]) { @@ -521,6 +577,73 @@ class RegistrationPage extends React.Component { }); } + const honorCode = []; + const formFields = this.showDynamicRegistrationFields ? ( + Object.keys(this.props.fieldDescriptions).map((fieldName) => { + const fieldData = this.props.fieldDescriptions[fieldName]; + switch (fieldData.name) { + case FIELDS.COUNTRY: + return ( + + this.setState(prevState => ({ values: { ...prevState.values, country: value } })) + } + errorCode={this.state.errorCode} + readOnly={this.state.readOnly} + /> + + ); + case FIELDS.HONOR_CODE: + honorCode.push( + + + , + ); + return null; + case FIELDS.TERMS_OF_SERVICE: + honorCode.push( + + + , + ); + return null; + default: + return ( + + + + ); + } + }) + ) : null; + return ( <> @@ -602,20 +725,24 @@ class RegistrationPage extends React.Component { floatingLabel={intl.formatMessage(messages['registration.password.label'])} /> )} - this.setState({ country: value })} - errorCode={this.state.errorCode} - readOnly={this.state.readOnly} - /> + {!(this.showDynamicRegistrationFields) + && ( + this.setState({ country: value })} + errorCode={this.state.errorCode} + readOnly={this.state.readOnly} + /> + )} + {formFields} {(getConfig().MARKETING_EMAILS_OPT_IN) && ( )} -
- - {intl.formatMessage(messages['terms.of.service.and.honor.code'])} - - ), - privacyPolicy: ( - - {intl.formatMessage(messages['privacy.policy'])} - - ), - }} + {!(this.showDynamicRegistrationFields) ? ( + -
+ ) :
{honorCode}
} { validationDecisions: validationsSelector(state), statusCode: state.register.statusCode, usernameSuggestions: usernameSuggestionsSelector(state), + fieldDescriptions: fieldDescriptionSelector(state), + extendedProfile: extendedProfileSelector(state), }; }; diff --git a/src/register/TermsOfService.jsx b/src/register/TermsOfService.jsx new file mode 100644 index 00000000..a4a20dff --- /dev/null +++ b/src/register/TermsOfService.jsx @@ -0,0 +1,60 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { getConfig } from '@edx/frontend-platform'; +import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n'; +import { Hyperlink, Form } from '@edx/paragon'; +import messages from './messages'; + +const TermsOfService = (props) => { + const { + intl, errorMessage, onChangeHandler, value, + } = props; + + return ( +
+ + + {intl.formatMessage(messages['terms.of.service'])} + + ), + }} + /> + + {errorMessage && ( + + {errorMessage} + + )} +
+ ); +}; + +TermsOfService.defaultProps = { + errorMessage: '', + value: false, +}; + +TermsOfService.propTypes = { + intl: intlShape.isRequired, + errorMessage: PropTypes.string, + onChangeHandler: PropTypes.func.isRequired, + value: PropTypes.bool, +}; + +export default injectIntl(TermsOfService); diff --git a/src/register/data/constants.js b/src/register/data/constants.js index 2a8e2d8e..6e1c0c30 100644 --- a/src/register/data/constants.js +++ b/src/register/data/constants.js @@ -1,3 +1,10 @@ +// Registration Fields +export const FIELDS = { + COUNTRY: 'country', + HONOR_CODE: 'honor_code', + TERMS_OF_SERVICE: 'terms_of_service', +}; + // Registration Error Codes export const FORBIDDEN_REQUEST = 'forbidden-request'; export const FORM_SUBMISSION_ERROR = 'form-submission-error'; diff --git a/src/register/data/reducers.js b/src/register/data/reducers.js index 813c6156..b53a63d7 100644 --- a/src/register/data/reducers.js +++ b/src/register/data/reducers.js @@ -1,8 +1,14 @@ import { - REGISTRATION_FORM, REGISTER_NEW_USER, REGISTER_FORM_VALIDATIONS, REGISTER_CLEAR_USERNAME_SUGGESTIONS, + REGISTRATION_FORM, + REGISTER_NEW_USER, + REGISTER_FORM_VALIDATIONS, + REGISTER_CLEAR_USERNAME_SUGGESTIONS, } from './actions'; -import { DEFAULT_STATE, PENDING_STATE } from '../../data/constants'; +import { + DEFAULT_STATE, + PENDING_STATE, +} from '../../data/constants'; export const defaultState = { registrationError: {}, @@ -11,6 +17,9 @@ export const defaultState = { validations: null, statusCode: null, usernameSuggestions: [], + extendedProfile: [], + fieldDescriptions: {}, + formRenderState: DEFAULT_STATE, }; const reducer = (state = defaultState, action) => { diff --git a/src/register/data/sagas.js b/src/register/data/sagas.js index 5d338da9..0966317a 100644 --- a/src/register/data/sagas.js +++ b/src/register/data/sagas.js @@ -55,7 +55,6 @@ export function* fetchRealtimeValidations(action) { } } } - export default function* saga() { yield takeEvery(REGISTER_NEW_USER.BASE, handleNewUserRegistration); yield takeEvery(REGISTER_FORM_VALIDATIONS.BASE, fetchRealtimeValidations); diff --git a/src/register/data/tests/reducers.test.js b/src/register/data/tests/reducers.test.js index 2fdae6ae..a92acc07 100644 --- a/src/register/data/tests/reducers.test.js +++ b/src/register/data/tests/reducers.test.js @@ -14,6 +14,9 @@ describe('register reducer', () => { validations: null, statusCode: null, usernameSuggestions: [], + extendedProfile: [], + fieldDescriptions: {}, + formRenderState: DEFAULT_STATE, }, ); }); diff --git a/src/register/messages.jsx b/src/register/messages.jsx index 2d86c596..73bba916 100644 --- a/src/register/messages.jsx +++ b/src/register/messages.jsx @@ -188,6 +188,16 @@ const messages = defineMessages({ defaultMessage: 'Privacy Policy', description: 'Text for the hyperlink that redirects user to privacy policy', }, + 'honor.code': { + id: 'honor.code', + defaultMessage: 'Honor Code', + description: 'Text for the hyperlink that redirects user to the honor code', + }, + 'terms.of.service': { + id: 'terms.of.service', + defaultMessage: 'Terms of Service', + description: 'Text for the hyperlink that redirects user to the terms of service', + }, // Optional fields 'registration.year.of.birth.label': { id: 'registration.year.of.birth.label', diff --git a/src/register/tests/HonorCode.test.jsx b/src/register/tests/HonorCode.test.jsx new file mode 100644 index 00000000..8e590e12 --- /dev/null +++ b/src/register/tests/HonorCode.test.jsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { mount } from 'enzyme'; + +import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n'; +import { mergeConfig } from '@edx/frontend-platform'; +import HonorCode from '../HonorCode'; + +const IntlHonorCode = injectIntl(HonorCode); + +describe('HonorCodeTest', () => { + mergeConfig({ + PRIVACY_POLICY: 'http://privacy-policy.com', + TOS_AND_HONOR_CODE: 'http://tos-and-honot-code.com', + }); + let value = false; + + const changeHandler = (e) => { + value = e.target.checked; + }; + + beforeEach(() => { + value = false; + }); + + it('should render error msg if honor code is not checked', () => { + const honorCode = mount( + + + , + ); + expect(honorCode.find('.form-text-size').last().text()).toEqual('You must agree to the edx Honor Code'); + }); + + it('should render Honor code field', () => { + const expectedMsg = 'I agree to the Your Platform Name Here Honor Codein a new tab'; + const honorCode = mount( + + + , + ); + + honorCode.find('#honor-code').last().simulate('change', { target: { checked: true, type: 'checkbox' } }); + expect(honorCode.find('#honor-code').find('label').text()).toEqual(expectedMsg); + expect(value).toEqual(true); + }); + + it('should render Terms of Service and Honor code field', () => { + const HonorCodeProps = mount( + + + , + ); + const expectedMsg = 'By creating an account, you agree to the Terms of Service and Honor Codein a new tab and you ' + + 'acknowledge that Your Platform Name Here and each Member process your personal data in ' + + 'accordance with the Privacy Policyin a new tab.'; + const field = HonorCodeProps.find('#honor-code'); + expect(field.text()).toEqual(expectedMsg); + }); +}); diff --git a/src/register/tests/RegistrationPage.test.jsx b/src/register/tests/RegistrationPage.test.jsx index 841a677b..b223f02e 100644 --- a/src/register/tests/RegistrationPage.test.jsx +++ b/src/register/tests/RegistrationPage.test.jsx @@ -16,7 +16,9 @@ import { registerNewUser, resetRegistrationForm, } from '../data/actions'; -import { FORBIDDEN_REQUEST, INTERNAL_SERVER_ERROR, TPA_SESSION_EXPIRED } from '../data/constants'; +import { + FIELDS, FORBIDDEN_REQUEST, INTERNAL_SERVER_ERROR, TPA_SESSION_EXPIRED, +} from '../data/constants'; import RegistrationFailureMessage from '../RegistrationFailure'; import RegistrationPage from '../RegistrationPage'; @@ -773,4 +775,87 @@ describe('RegistrationPage', () => { }); }); }); + + describe('TestDynamicFields', () => { + it('should render fields returned by backend', () => { + mergeConfig({ + ENABLE_DYNAMIC_REGISTRATION_FIELDS: true, + }); + store = mockStore({ + ...initialState, + commonComponents: { + ...initialState.commonComponents, + fieldDescriptions: { + country: { name: 'country', error_message: true }, + profession: { name: 'profession', type: 'text', label: 'Profession' }, + honor_code: { name: FIELDS.HONOR_CODE, error_message: 'You must agree to Honor Code of our site' }, + terms_of_service: { + name: FIELDS.TERMS_OF_SERVICE, + error_message: 'You must agree to the Terms and Service agreement of our site', + }, + }, + }, + }); + const registerPage = mount(reduxWrapper()); + expect(registerPage.find('#country').exists()).toBeTruthy(); + expect(registerPage.find('#profession').exists()).toBeTruthy(); + expect(registerPage.find('#honor-code').exists()).toBeTruthy(); + expect(registerPage.find('#tos').exists()).toBeTruthy(); + }); + + it('should submit form with fields returned by backend in payload', () => { + jest.spyOn(global.Date, 'now').mockImplementation(() => 0); + store = mockStore({ + ...initialState, + commonComponents: { + ...initialState.commonComponents, + fieldDescriptions: { + country: { name: 'country', error_message: true }, + profession: { name: 'profession', type: 'text', label: 'Profession' }, + honor_code: { name: 'honor_code', type: 'tos_and_honor_code' }, + }, + extendedProfile: ['profession'], + }, + }); + + const payload = { + name: 'John Doe', + username: 'john_doe', + email: 'john.doe@example.com', + password: 'password1', + country: 'Pakistan', + totalRegistrationTime: 0, + is_authn_mfe: true, + honor_code: true, + extended_profile: [{ field_name: 'profession', field_value: 'Engineer' }], + }; + + store.dispatch = jest.fn(store.dispatch); + const registerPage = mount(reduxWrapper()); + + populateRequiredFields(registerPage, payload); + registerPage.find('input#profession').simulate('change', { target: { value: 'Engineer', name: 'profession' } }); + registerPage.find('button.btn-brand').simulate('click'); + expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK' })); + }); + + it('should show error message for fields returned by backend', () => { + store = mockStore({ + ...initialState, + commonComponents: { + ...initialState.commonComponents, + fieldDescriptions: { + profession: { + name: 'profession', type: 'text', label: 'Profession', error_message: 'Enter profession', + }, + }, + }, + }); + + const registrationPage = mount(reduxWrapper()); + registrationPage.find('button.btn-brand').simulate('click'); + + expect(registrationPage.find('#profession-error').last().text()).toEqual('Enter profession'); + }); + }); }); diff --git a/src/register/tests/TermsOfService.test.jsx b/src/register/tests/TermsOfService.test.jsx new file mode 100644 index 00000000..3a71b01e --- /dev/null +++ b/src/register/tests/TermsOfService.test.jsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { mount } from 'enzyme'; + +import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n'; +import { mergeConfig } from '@edx/frontend-platform'; +import TermsOfService from '../TermsOfService'; + +const IntlTermsOfService = injectIntl(TermsOfService); + +describe('TermsOfServiceTest', () => { + mergeConfig({ + TOS_LINK: 'http://tos-and-honot-code.com', + }); + let value = false; + + const changeHandler = (e) => { + value = e.target.checked; + }; + + beforeEach(() => { + value = false; + }); + + it('should render error msg if Terms of Service checkbox is not checked', () => { + const errorMessage = 'You must agree to the edx Terms of Service'; + const termsOfService = mount( + + + , + ); + expect(termsOfService.find('.form-text-size').last().text()).toEqual(errorMessage); + }); + + it('should render Terms of Service field', () => { + const termsOfService = mount( + + + , + ); + const expectedMsg = 'I agree to the Your Platform Name Here Terms of Servicein a new tab'; + expect(termsOfService.find('#terms-of-service').find('label').text()).toEqual(expectedMsg); + expect(value).toEqual(false); + }); + + it('should change value when Terms of Service field is checked', () => { + const termsOfService = mount( + + + , + ); + const field = termsOfService.find('input#tos'); + field.simulate('change', { target: { checked: true, type: 'checkbox' } }); + expect(value).toEqual(true); + }); +});