diff --git a/src/logistration/RegistrationFailure.jsx b/src/logistration/RegistrationFailure.jsx index 6f5901e2..4841ada9 100644 --- a/src/logistration/RegistrationFailure.jsx +++ b/src/logistration/RegistrationFailure.jsx @@ -3,6 +3,10 @@ import PropTypes from 'prop-types'; import { FormattedMessage } from '@edx/frontend-platform/i18n'; import { Alert } from '@edx/paragon'; +const hasNoErrors = (userErrors) => ( + userErrors.every((errorList) => (!errorList[0])) +); + const RegistrationFailureMessage = (props) => { const errorMessage = props.errors; const userErrors = []; @@ -24,20 +28,22 @@ const RegistrationFailureMessage = (props) => { }); return ( - - - - - - - {userErrors} - - - + hasNoErrors(userErrors) ? null : ( + + + + + + + {userErrors} + + + + ) ); }; diff --git a/src/logistration/RegistrationPage.jsx b/src/logistration/RegistrationPage.jsx index bd0496ed..036169f7 100644 --- a/src/logistration/RegistrationPage.jsx +++ b/src/logistration/RegistrationPage.jsx @@ -61,7 +61,15 @@ class RegistrationPage extends React.Component { confirmEmail: '', enableOptionalField: false, validationFieldName: '', - emptyFields: {}, + validationErrorsAlertMessages: { + name: [{ user_message: '' }], + username: [{ user_message: '' }], + email: [{ user_message: '' }], + emailFormat: [{ user_message: '' }], + password: [{ user_message: '' }], + country: [{ user_message: '' }], + }, + currentValidations: null, errors: { email: '', name: '', @@ -78,8 +86,8 @@ class RegistrationPage extends React.Component { countryValid: false, honorCodeValid: true, termsOfServiceValid: false, - formValid: false, institutionLogin: false, + formValid: false, }; } @@ -93,44 +101,34 @@ class RegistrationPage extends React.Component { } shouldComponentUpdate(nextProps) { - if (this.props.validations !== nextProps.validations) { + if (nextProps.statusCode !== 403 && this.props.validations !== nextProps.validations) { const { errors } = this.state; const errorMsg = nextProps.validations.validation_decisions[this.state.validationFieldName]; errors[this.state.validationFieldName] = errorMsg; const stateValidKey = `${camelCase(this.state.validationFieldName)}Valid`; - const stateValidValue = !errorMsg; + const currentValidations = nextProps.validations.validation_decisions; - this.setState(({ [stateValidKey]: stateValidValue }), () => { - const { - emailValid, - nameValid, - usernameValid, - passwordValid, - } = this.state; - - const validityMap = REGISTRATION_VALIDITY_MAP; - let extraFieldsValid = true; - Object.entries(validityMap).forEach(([key, value]) => { - if (value) { - const stateValid = `${camelCase(key)}Valid`; - extraFieldsValid = extraFieldsValid && this.state[stateValid]; - } - }); - - const formValid = emailValid && nameValid && usernameValid && passwordValid && extraFieldsValid; - this.setState({ - errors, - formValid, - }); + this.setState({ + [stateValidKey]: !errorMsg, + errors, + currentValidations, }); return false; } + if (this.props.thirdPartyAuthContext.pipelineUserDetails !== nextProps.thirdPartyAuthContext.pipelineUserDetails) { this.setState({ ...nextProps.thirdPartyAuthContext.pipelineUserDetails, }); return false; } + + if (this.props.registrationError !== nextProps.registrationError) { + this.setState({ + formValid: false, + }); + return false; + } return true; } @@ -141,19 +139,23 @@ class RegistrationPage extends React.Component { handleSubmit = (e) => { e.preventDefault(); const params = (new URL(document.location)).searchParams; - const payload = { - name: this.state.name, - username: this.state.username, - email: this.state.email, - password: this.state.password, - }; + const payload = {}; + const payloadMap = new Map(); + payloadMap.set('name', this.state.name); + payloadMap.set('username', this.state.username); + payloadMap.set('email', this.state.email); + + if (!this.props.thirdPartyAuthContext.currentProvider) { + payloadMap.set('password', this.state.password); + } const fieldMap = { ...REGISTRATION_VALIDITY_MAP, ...REGISTRATION_OPTIONAL_MAP }; Object.entries(fieldMap).forEach(([key, value]) => { if (value) { - payload[key] = this.state[camelCase(key)]; + payloadMap.set(key, this.state[camelCase(key)]); } }); + payloadMap.forEach((value, key) => { payload[key] = value; }); const next = params.get('next'); const courseId = params.get('course_id'); @@ -163,16 +165,35 @@ class RegistrationPage extends React.Component { if (courseId) { payload.course_id = params.course_id; } - if (!this.state.formValid) { + + let finalValidation = this.isFormValid(); + if (!this.isFormValid()) { // 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_VALIDITY_MAP)) - .forEach(([key, value]) => { - this.validateInput(key, value); - }); - return; + payloadMap.forEach((value, key) => { + if (key !== 'honor_code' || 'terms_of_service' in REGISTRATION_VALIDITY_MAP) { + finalValidation = this.validateInput(key, value); + } + }); } - this.props.registerNewUser(payload); + if (finalValidation) { + this.props.registerNewUser(payload); + } else { + this.props.fetchRealtimeValidations(payload); + } + } + + checkNoValidationsErrors(validations) { + let keyValidList = null; + keyValidList = Object.entries(validations).map(([key]) => { + const validation = validations[key][0]; + return !validation.user_message; + }); + return keyValidList.every((current) => current === true); + } + + isFormValid() { + return this.state.formValid; } handleOnBlur(e) { @@ -206,39 +227,85 @@ class RegistrationPage extends React.Component { }); } - validateInput(inputName, value) { + handleOnClick(e) { const { errors } = this.state; - const { emptyFields } = this.state; - let { - emailValid, - nameValid, - usernameValid, - passwordValid, - countryValid, - honorCodeValid, - termsOfServiceValid, + if (this.state.currentValidations) { + const fieldName = e.target.name; + errors[fieldName] = this.state.currentValidations[fieldName]; + const stateValidKey = `${camelCase(fieldName)}Valid`; + this.setState(prevState => ({ + [stateValidKey]: !prevState.currentValidations[fieldName], + errors, + })); + } + } + + validateInput(inputName, value) { + const { + errors, + validationErrorsAlertMessages, } = this.state; + let { + honorCodeValid, + termsOfServiceValid, + formValid, + } = this.state; + + const validations = this.state.currentValidations; switch (inputName) { case 'email': - emailValid = value.length >= 1; - emptyFields.email = this.generateUserMessage(emailValid, 'logistration.email.validation.message'); + if (this.props.statusCode !== 403 && validations && validations.email) { + validationErrorsAlertMessages.email = [{ user_message: validations.email }]; + } else if (value.length < 1) { + const errorEmpty = this.generateUserMessage(value.length < 1, 'logistration.email.validation.message'); + validationErrorsAlertMessages.email = errorEmpty; + } else { + const errorCharlength = this.generateUserMessage(value.length <= 2, 'logistration.email.ratelimit.less.chars.validation.message'); + const formatError = this.generateUserMessage(!value.match(/^([\w.%+-]+)@([\w-]+\.)+([\w]{2,})$/i), 'logistration.email.ratelimit.incorrect.format.validation.message'); + validationErrorsAlertMessages.email = errorCharlength; + validationErrorsAlertMessages.emailFormat = formatError; + } break; case 'name': - nameValid = value.length >= 1; - emptyFields.name = this.generateUserMessage(nameValid, 'logistration.fullname.validation.message'); + if (this.props.statusCode !== 403 && validations && validations.name) { + validationErrorsAlertMessages.name = [{ user_message: validations.name }]; + } else if (value.length < 1) { + const errorEmpty = this.generateUserMessage(value.length < 1, 'logistration.fullname.validation.message'); + validationErrorsAlertMessages.name = errorEmpty; + } else { + validationErrorsAlertMessages.name = [{ user_message: '' }]; + } break; case 'username': - usernameValid = value.length >= 1; - emptyFields.username = this.generateUserMessage(usernameValid, 'logistration.username.validation.message'); + if (this.props.statusCode !== 403 && validations && validations.username) { + validationErrorsAlertMessages.username = [{ user_message: validations.username }]; + } else if (value.length < 1) { + const errorEmpty = this.generateUserMessage(value.length < 1, 'logistration.username.validation.message'); + validationErrorsAlertMessages.username = errorEmpty; + } else { + const errorCharLength = this.generateUserMessage(value.length <= 1, 'logistration.username.ratelimit.less.chars.message'); + validationErrorsAlertMessages.username = errorCharLength; + } break; case 'password': - passwordValid = value.length >= 1; - emptyFields.password = this.generateUserMessage(passwordValid, 'logistration.register.page.password.validation.message'); + if (this.props.statusCode !== 403 && validations && validations.password) { + validationErrorsAlertMessages.password = [{ user_message: validations.password }]; + } else if (value.length < 1) { + const errorEmpty = this.generateUserMessage(value.length < 1, 'logistration.register.page.password.validation.message'); + validationErrorsAlertMessages.password = errorEmpty; + } else { + const errorCharLength = this.generateUserMessage(value.length < 8, 'logistration.email.ratelimit.password.validation.message'); + validationErrorsAlertMessages.password = errorCharLength; + } break; case 'country': - countryValid = value !== ''; - emptyFields.country = this.generateUserMessage(countryValid, 'logistration.country.validation.message'); + if (this.props.statusCode !== 403 && validations && validations.country) { + validationErrorsAlertMessages.country = [{ user_message: validations.country }]; + } else { + const emptyError = this.generateUserMessage(value === '', 'logistration.country.validation.message'); + validationErrorsAlertMessages.country = emptyError; + } break; case 'honor_code': honorCodeValid = value !== false; @@ -252,16 +319,14 @@ class RegistrationPage extends React.Component { break; } + formValid = this.checkNoValidationsErrors(validationErrorsAlertMessages); this.setState({ - emptyFields, - emailValid, - nameValid, - usernameValid, - passwordValid, - countryValid, + formValid, + validationErrorsAlertMessages, honorCodeValid, termsOfServiceValid, }); + return formValid; } addExtraRequiredFields() { @@ -341,6 +406,7 @@ class RegistrationPage extends React.Component { })); props.options = options; props.onBlur = e => this.handleOnBlur(e); + props.onClick = e => this.handleOnClick(e); } return ( 0) { - errorsObject = this.state.emptyFields; + if (!this.checkNoValidationsErrors(this.state.validationErrorsAlertMessages)) { + errorsObject = this.state.validationErrorsAlertMessages; } else if (this.props.registrationError) { errorsObject = this.props.registrationError; } else { @@ -492,6 +558,7 @@ class RegistrationPage extends React.Component { invalid={this.state.errors.name !== ''} invalidMessage={this.state.errors.name} className="mb-0" + helpText="This name will be used by any certificates that you earn." > {intl.formatMessage(messages['logistration.fullname.label'])} @@ -504,6 +571,7 @@ class RegistrationPage extends React.Component { value={this.state.name} onChange={e => this.handleOnChange(e)} onBlur={e => this.handleOnBlur(e)} + onClick={e => this.handleOnClick(e)} required /> @@ -512,6 +580,7 @@ class RegistrationPage extends React.Component { invalid={this.state.errors.username !== ''} invalidMessage={this.state.errors.username} className="mb-0" + helpText="The name that will identify you in your courses. It cannot be changed later." > {intl.formatMessage(messages['logistration.username.label'])} @@ -522,8 +591,10 @@ class RegistrationPage extends React.Component { type="text" placeholder="" value={this.state.username} + maxLength="30" onChange={e => this.handleOnChange(e)} onBlur={e => this.handleOnBlur(e)} + onClick={e => this.handleOnClick(e)} required /> @@ -532,6 +603,7 @@ class RegistrationPage extends React.Component { invalid={this.state.errors.email !== ''} invalidMessage={this.state.errors.email} className="mb-0" + helpText="This is what you will use to login." > {intl.formatMessage(messages['logistration.register.page.email.label'])} @@ -544,6 +616,7 @@ class RegistrationPage extends React.Component { value={this.state.email} onChange={e => this.handleOnChange(e)} onBlur={e => this.handleOnBlur(e)} + onClick={e => this.handleOnClick(e)} required /> @@ -553,6 +626,7 @@ class RegistrationPage extends React.Component { invalid={this.state.errors.password !== ''} invalidMessage={this.state.errors.password} className="mb-0" + helpText="Your password must contain at least 8 characters, including 1 letter & 1 number." > {intl.formatMessage(messages['logistration.password.label'])} @@ -565,6 +639,7 @@ class RegistrationPage extends React.Component { value={this.state.password} onChange={e => this.handleOnChange(e)} onBlur={e => this.handleOnBlur(e)} + onClick={e => this.handleOnClick(e)} required /> @@ -620,6 +695,7 @@ RegistrationPage.defaultProps = { }, formData: null, validations: null, + statusCode: null, }; RegistrationPage.propTypes = { @@ -664,6 +740,7 @@ RegistrationPage.propTypes = { username: PropTypes.string, }), }), + statusCode: PropTypes.number, }; const mapStateToProps = state => { @@ -676,6 +753,7 @@ const mapStateToProps = state => { thirdPartyAuthContext, formData: state.logistration.formData, validations: state.logistration.validations, + statusCode: state.logistration.statusCode, }; }; diff --git a/src/logistration/data/actions.js b/src/logistration/data/actions.js index a238988c..b15479de 100644 --- a/src/logistration/data/actions.js +++ b/src/logistration/data/actions.js @@ -100,6 +100,7 @@ export const fetchRealtimeValidationsSuccess = (validations) => ({ payload: { validations }, }); -export const fetchRealtimeValidationsFailure = () => ({ +export const fetchRealtimeValidationsFailure = (error, statusCode) => ({ type: REGISTER_FORM_VALIDATIONS.FAILURE, + payload: { error, statusCode }, }); diff --git a/src/logistration/data/reducers.js b/src/logistration/data/reducers.js index 0e990acd..78f24fea 100644 --- a/src/logistration/data/reducers.js +++ b/src/logistration/data/reducers.js @@ -15,6 +15,7 @@ export const defaultState = { registrationResult: {}, formData: null, validations: null, + statusCode: null, }; const reducer = (state = defaultState, action) => { @@ -89,6 +90,8 @@ const reducer = (state = defaultState, action) => { case REGISTER_FORM_VALIDATIONS.FAILURE: return { ...state, + validations: action.payload.error, + statusCode: action.payload.statusCode, }; default: return state; diff --git a/src/logistration/data/sagas.js b/src/logistration/data/sagas.js index 72dc27a8..f9798ab6 100644 --- a/src/logistration/data/sagas.js +++ b/src/logistration/data/sagas.js @@ -116,7 +116,10 @@ export function* fetchRealtimeValidations(action) { fieldValidations, )); } catch (e) { - yield put(fetchRealtimeValidationsFailure()); + const statusCodes = [403]; + if (e.response && statusCodes.includes(e.response.status)) { + yield put(fetchRealtimeValidationsFailure(e.response.data, e.response.status)); + } logError(e); } } diff --git a/src/logistration/data/tests/sagas.test.js b/src/logistration/data/tests/sagas.test.js index a9b2c1f9..469e38f0 100644 --- a/src/logistration/data/tests/sagas.test.js +++ b/src/logistration/data/tests/sagas.test.js @@ -180,8 +180,16 @@ describe('fetchRealtimeValidations', () => { }); it('should call service and dispatch error action', async () => { + const validationRatelimitResponse = { + response: { + status: 403, + data: { + detail: 'You do not have permission to perform this action.', + }, + }, + }; const getFieldsValidations = jest.spyOn(api, 'getFieldsValidations') - .mockImplementation(() => Promise.reject(new Error('something went wrong'))); + .mockImplementation(() => Promise.reject(validationRatelimitResponse)); const dispatched = []; await runSaga( @@ -194,7 +202,10 @@ describe('fetchRealtimeValidations', () => { expect(loggingService.logError).toHaveBeenCalled(); expect(dispatched).toEqual([ actions.fetchRealtimeValidationsBegin(), - actions.fetchRealtimeValidationsFailure(), + actions.fetchRealtimeValidationsFailure( + validationRatelimitResponse.response.data, + validationRatelimitResponse.response.status, + ), ]); getFieldsValidations.mockClear(); }); diff --git a/src/logistration/messages.jsx b/src/logistration/messages.jsx index 62f4036b..d1ca6dec 100644 --- a/src/logistration/messages.jsx +++ b/src/logistration/messages.jsx @@ -131,6 +131,21 @@ const messages = defineMessages({ defaultMessage: 'Please enter your Email.', description: 'Validation message that appears when email address is empty', }, + 'logistration.email.ratelimit.less.chars.validation.message': { + id: 'logistration.email.ratelimit.less.chars.validation.message', + defaultMessage: 'Email must have 3 characters.', + description: 'Validation message that appears when email address is less than 3 characters', + }, + 'logistration.email.ratelimit.incorrect.format.validation.message': { + id: 'logistration.email.ratelimit.incorrect.format.validation.message', + defaultMessage: 'The email address you provided isn\'t formatted correctly.', + description: 'Validation message that appears when email address is not formatted correctly with no backend validations available.', + }, + 'logistration.email.ratelimit.password.validation.message': { + id: 'logistration.email.ratelimit.password.validation.message', + defaultMessage: 'Your password must contain at least 8 characters', + description: 'Validation message that appears when password is not formatted correctly with no backend validations available.', + }, 'logistration.email.help.message': { id: 'logistration.email.help.message', defaultMessage: 'The email address you used to register with edX.', @@ -176,6 +191,11 @@ const messages = defineMessages({ defaultMessage: 'Please enter your Public Username.', description: 'Validation message that appears when username is invalid', }, + 'logistration.username.ratelimit.less.chars.message': { + id: 'logistration.username.ratelimit.less.chars.message', + defaultMessage: 'Public Username must have atleast 2 characters.', + description: 'Validation message that appears when username is less than 2 characters and with no backend validations available.', + }, 'logistration.country.validation.message': { id: 'logistration.country.validation.message', defaultMessage: 'Select your country or region of residence.', diff --git a/src/logistration/tests/RegistrationPage.test.jsx b/src/logistration/tests/RegistrationPage.test.jsx index c4ab9572..0e58d909 100644 --- a/src/logistration/tests/RegistrationPage.test.jsx +++ b/src/logistration/tests/RegistrationPage.test.jsx @@ -223,6 +223,58 @@ describe('./RegistrationPage.js', () => { expect(store.dispatch).toHaveBeenCalledWith(fetchRealtimeValidations(formPayload)); }); + it('tests shouldComponentUpdate change validations and formValid state', () => { + const nextProps = { + validations: { + validation_decisions: { + username: 'Username must be between 2 and 30 characters long.', + }, + }, + thirdPartyAuthContext: { + pipelineUserDetails: { + name: 'test', + email: 'test@example.com', + username: 'test-username', + }, + }, + registrationError: { + username: [{ username: 'Username must be between 2 and 30 characters long.' }], + }, + }; + + const root = mount(reduxWrapper()); + + const shouldUpdate = root.find('RegistrationPage').instance().shouldComponentUpdate(nextProps); + expect(root.find('RegistrationPage').state('currentValidations')).not.toEqual(null); + expect(root.find('RegistrationPage').state('formValid')).not.toEqual(true); + expect(shouldUpdate).toBe(false); + }); + + it('tests onClick should change errors state via realtime validation', () => { + const nextProps = { + validations: { + validation_decisions: { + username: 'Username must be between 2 and 30 characters long.', + }, + }, + }; + + const errors = { + email: '', + name: '', + username: 'Username must be between 2 and 30 characters long.', + password: '', + country: '', + honorCode: '', + termsOfService: '', + }; + + const root = mount(reduxWrapper()); + root.find('RegistrationPage').instance().shouldComponentUpdate(nextProps); + root.find('input#username').simulate('click', { target: { value: '', name: 'username' } }); + expect(root.find('RegistrationPage').state('errors')).toEqual(errors); + }); + it('should not dispatch registerNewUser on Submit', () => { const formPayload = { email: '', diff --git a/src/logistration/tests/__snapshots__/RegistrationPage.test.jsx.snap b/src/logistration/tests/__snapshots__/RegistrationPage.test.jsx.snap index a9c78d8d..3cb2143f 100644 --- a/src/logistration/tests/__snapshots__/RegistrationPage.test.jsx.snap +++ b/src/logistration/tests/__snapshots__/RegistrationPage.test.jsx.snap @@ -44,17 +44,24 @@ exports[`./RegistrationPage.js should display no password field when current pro Full Name (required) + + This name will be used by any certificates that you earn. + + + The name that will identify you in your courses. It cannot be changed later. + + + This is what you will use to login. + + + This name will be used by any certificates that you earn. + + + The name that will identify you in your courses. It cannot be changed later. + + + This is what you will use to login. + + + Your password must contain at least 8 characters, including 1 letter & 1 number. + + + This name will be used by any certificates that you earn. + + + The name that will identify you in your courses. It cannot be changed later. + + + This is what you will use to login. + + + Your password must contain at least 8 characters, including 1 letter & 1 number. + + + This name will be used by any certificates that you earn. + + + The name that will identify you in your courses. It cannot be changed later. + + + This is what you will use to login. + + + Your password must contain at least 8 characters, including 1 letter & 1 number. + + + This name will be used by any certificates that you earn. + + + The name that will identify you in your courses. It cannot be changed later. + + + This is what you will use to login. + + + Your password must contain at least 8 characters, including 1 letter & 1 number. +