diff --git a/src/logistration/RegistrationPage.jsx b/src/logistration/RegistrationPage.jsx index 027880ac..5ff81864 100644 --- a/src/logistration/RegistrationPage.jsx +++ b/src/logistration/RegistrationPage.jsx @@ -13,7 +13,12 @@ import { } from '@edx/frontend-platform/i18n'; import camelCase from 'lodash.camelcase'; -import { getThirdPartyAuthContext, registerNewUser, fetchRegistrationForm } from './data/actions'; +import { + getThirdPartyAuthContext, + registerNewUser, + fetchRegistrationForm, + fetchRealtimeValidations, +} from './data/actions'; import { registrationRequestSelector, thirdPartyAuthContextSelector } from './data/selectors'; import { RedirectLogistration } from '../common-components'; import RegistrationFailure from './RegistrationFailure'; @@ -36,6 +41,8 @@ class RegistrationPage extends React.Component { constructor(props, context) { super(props, context); + this.intl = props.intl; + this.state = { email: '', name: '', @@ -52,6 +59,7 @@ class RegistrationPage extends React.Component { levelOfEducation: '', confirmEmail: '', enableOptionalField: false, + validationFieldName: '', errors: { email: '', name: '', @@ -82,6 +90,19 @@ class RegistrationPage extends React.Component { this.props.fetchRegistrationForm(); } + shouldComponentUpdate(nextProps) { + if (this.props.validations !== nextProps.validations) { + const { errors } = this.state; + const errorMsg = nextProps.validations.validation_decisions[this.state.validationFieldName]; + errors[this.state.validationFieldName] = errorMsg; + this.setState({ + errors, + }); + return false; + } + return true; + } + handleInstitutionLogin = () => { this.setState(prevState => ({ institutionLogin: !prevState.institutionLogin })); } @@ -115,7 +136,7 @@ class RegistrationPage extends React.Component { if (!this.state.formValid) { // Special case where honor code and tos is a single field, true by default. We don't need - // to validate this field. + // 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); @@ -125,6 +146,22 @@ class RegistrationPage extends React.Component { this.props.registerNewUser(payload); } + handleOnBlur(e) { + this.setState({ + validationFieldName: e.target.name, + }); + + const payload = { + email: this.state.email, + username: this.state.username, + password: this.state.password, + name: this.state.name, + honor_code: this.state.honorCode, + country: this.state.country, + }; + this.props.fetchRealtimeValidations(payload); + } + handleOnChange(e) { const targetValue = e.target.type === 'checkbox' ? e.target.checked : e.target.value; this.setState({ @@ -156,23 +193,23 @@ class RegistrationPage extends React.Component { switch (inputName) { case 'email': emailValid = value.match(/^([\w.%+-]+)@([\w-]+\.)+([\w]{2,})$/i); - errors.email = emailValid ? '' : null; + errors.email = emailValid ? '' : this.intl.formatMessage(messages['logistration.email.validation.message']); break; case 'name': nameValid = value.length >= 1; - errors.name = nameValid ? '' : null; + errors.name = nameValid ? '' : this.intl.formatMessage(messages['logistration.fullname.validation.message']); break; case 'username': usernameValid = value.length >= 2 && value.length <= 30; - errors.username = usernameValid ? '' : null; + errors.username = usernameValid ? '' : this.intl.formatMessage(messages['logistration.username.validation.message']); break; case 'password': passwordValid = !!(value.length >= 8 && value.match(/\d+/g)); - errors.password = passwordValid ? '' : null; + errors.password = passwordValid ? '' : this.intl.formatMessage(messages['logistration.register.page.password.validation.message']); break; case 'country': countryValid = value !== ''; - errors.country = countryValid ? '' : null; + errors.country = countryValid ? '' : this.intl.formatMessage(messages['logistration.country.validation.message']); break; case 'honor_code': honorCodeValid = value !== false; @@ -411,7 +448,7 @@ class RegistrationPage extends React.Component { @@ -527,6 +568,7 @@ RegistrationPage.defaultProps = { secondaryProviders: [], }, formData: null, + validations: null, }; RegistrationPage.propTypes = { @@ -561,6 +603,16 @@ RegistrationPage.propTypes = { formData: PropTypes.shape({ fields: PropTypes.array, }), + fetchRealtimeValidations: PropTypes.func.isRequired, + validations: PropTypes.shape({ + validation_decisions: PropTypes.shape({ + country: PropTypes.string, + email: PropTypes.string, + name: PropTypes.string, + password: PropTypes.string, + username: PropTypes.string, + }), + }), }; const mapStateToProps = state => { @@ -572,6 +624,7 @@ const mapStateToProps = state => { registrationResult, thirdPartyAuthContext, formData: state.logistration.formData, + validations: state.logistration.validations, }; }; @@ -580,6 +633,7 @@ export default connect( { getThirdPartyAuthContext, fetchRegistrationForm, + fetchRealtimeValidations, registerNewUser, }, )(injectIntl(RegistrationPage)); diff --git a/src/logistration/data/actions.js b/src/logistration/data/actions.js index eeaeedd1..a238988c 100644 --- a/src/logistration/data/actions.js +++ b/src/logistration/data/actions.js @@ -4,6 +4,7 @@ export const REGISTER_NEW_USER = new AsyncActionType('REGISTRATION', 'REGISTER_N 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'); +export const REGISTER_FORM_VALIDATIONS = new AsyncActionType('REGISTRATION', 'GET_FORM_VALIDATIONS'); // Register @@ -83,3 +84,22 @@ export const fetchRegistrationFormSuccess = (formData) => ({ export const fetchRegistrationFormFailure = () => ({ type: REGISTER_FORM.FAILURE, }); + +// Realtime Field validations +export const fetchRealtimeValidations = (formPayload) => ({ + type: REGISTER_FORM_VALIDATIONS.BASE, + payload: { formPayload }, +}); + +export const fetchRealtimeValidationsBegin = () => ({ + type: REGISTER_FORM_VALIDATIONS.BEGIN, +}); + +export const fetchRealtimeValidationsSuccess = (validations) => ({ + type: REGISTER_FORM_VALIDATIONS.SUCCESS, + payload: { validations }, +}); + +export const fetchRealtimeValidationsFailure = () => ({ + type: REGISTER_FORM_VALIDATIONS.FAILURE, +}); diff --git a/src/logistration/data/reducers.js b/src/logistration/data/reducers.js index dc8a8325..0e990acd 100644 --- a/src/logistration/data/reducers.js +++ b/src/logistration/data/reducers.js @@ -3,6 +3,7 @@ import { LOGIN_REQUEST, THIRD_PARTY_AUTH_CONTEXT, REGISTER_FORM, + REGISTER_FORM_VALIDATIONS, } from './actions'; import { DEFAULT_STATE, PENDING_STATE } from '../../data/constants'; @@ -13,6 +14,7 @@ export const defaultState = { registrationError: null, registrationResult: {}, formData: null, + validations: null, }; const reducer = (state = defaultState, action) => { @@ -75,6 +77,19 @@ const reducer = (state = defaultState, action) => { return { ...state, }; + case REGISTER_FORM_VALIDATIONS.BEGIN: + return { + ...state, + }; + case REGISTER_FORM_VALIDATIONS.SUCCESS: + return { + ...state, + validations: action.payload.validations, + }; + case REGISTER_FORM_VALIDATIONS.FAILURE: + return { + ...state, + }; default: return state; } diff --git a/src/logistration/data/sagas.js b/src/logistration/data/sagas.js index 9f99dbbf..1574d7cd 100644 --- a/src/logistration/data/sagas.js +++ b/src/logistration/data/sagas.js @@ -12,6 +12,10 @@ import { loginRequestBegin, loginRequestFailure, loginRequestSuccess, + REGISTER_FORM_VALIDATIONS, + fetchRealtimeValidationsBegin, + fetchRealtimeValidationsSuccess, + fetchRealtimeValidationsFailure, THIRD_PARTY_AUTH_CONTEXT, getThirdPartyAuthContextBegin, getThirdPartyAuthContextSuccess, @@ -24,6 +28,7 @@ import { // Services import { + getFieldsValidations, getRegistrationForm, getThirdPartyAuthContext, postNewUser, @@ -94,9 +99,24 @@ export function* fetchRegistrationForm() { } } +export function* fetchRealtimeValidations(action) { + try { + yield put(fetchRealtimeValidationsBegin()); + const { fieldValidations } = yield call(getFieldsValidations, action.payload.formPayload); + + yield put(fetchRealtimeValidationsSuccess( + fieldValidations, + )); + } catch (e) { + yield put(fetchRealtimeValidationsFailure()); + 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); + yield takeEvery(REGISTER_FORM_VALIDATIONS.BASE, fetchRealtimeValidations); } diff --git a/src/logistration/data/service.js b/src/logistration/data/service.js index 4e231a8a..c4804524 100644 --- a/src/logistration/data/service.js +++ b/src/logistration/data/service.js @@ -85,3 +85,24 @@ export async function getRegistrationForm() { registrationForm: data, }; } + +export async function getFieldsValidations(formPayload) { + const requestConfig = { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + isPublic: true, + }; + + const { data } = await getAuthenticatedHttpClient() + .post( + `${getConfig().LMS_BASE_URL}/api/user/v1/validation/registration`, + querystring.stringify(formPayload), + requestConfig, + ) + .catch((e) => { + throw (e); + }); + + return { + fieldValidations: data, + }; +} diff --git a/src/logistration/data/tests/sagas.test.js b/src/logistration/data/tests/sagas.test.js index 84c1012e..a0ac1de5 100644 --- a/src/logistration/data/tests/sagas.test.js +++ b/src/logistration/data/tests/sagas.test.js @@ -1,6 +1,9 @@ import { runSaga } from 'redux-saga'; import { + fetchRealtimeValidationsBegin, + fetchRealtimeValidationsSuccess, + fetchRealtimeValidationsFailure, getThirdPartyAuthContextBegin, getThirdPartyAuthContextSuccess, getThirdPartyAuthContextFailure, @@ -8,7 +11,7 @@ import { fetchRegistrationFormSuccess, fetchRegistrationFormFailure, } from '../actions'; -import { fetchThirdPartyAuthContext, fetchRegistrationForm } from '../sagas'; +import { fetchRealtimeValidations, fetchThirdPartyAuthContext, fetchRegistrationForm } from '../sagas'; import * as api from '../service'; describe('fetchThirdPartyAuthContext', () => { @@ -109,3 +112,56 @@ describe('fetchRegistrationForm', () => { getRegistrationForm.mockClear(); }); }); + +describe('fetchRealtimeValidations', () => { + const params = { + payload: { + formData: { + email: 'test@test.com', + username: '', + password: 'test-password', + name: 'test-name', + honor_code: true, + country: 'test-country', + }, + }, + }; + + const data = { + validation_decisions: { + username: 'Username must be between 2 and 30 characters long.', + }, + }; + + it('should call service and dispatch success action', async () => { + const getFieldsValidations = jest.spyOn(api, 'getFieldsValidations') + .mockImplementation(() => Promise.resolve({ fieldValidations: data })); + + const dispatched = []; + await runSaga( + { dispatch: (action) => dispatched.push(action) }, + fetchRealtimeValidations, + params, + ); + + expect(getFieldsValidations).toHaveBeenCalledTimes(1); + expect(dispatched).toEqual([fetchRealtimeValidationsBegin(), fetchRealtimeValidationsSuccess(data)]); + getFieldsValidations.mockClear(); + }); + + it('should call service and dispatch error action', async () => { + const getFieldsValidations = jest.spyOn(api, 'getFieldsValidations') + .mockImplementation(() => Promise.reject()); + + const dispatched = []; + await runSaga( + { dispatch: (action) => dispatched.push(action) }, + fetchRealtimeValidations, + params, + ); + + expect(getFieldsValidations).toHaveBeenCalledTimes(1); + expect(dispatched).toEqual([fetchRealtimeValidationsBegin(), fetchRealtimeValidationsFailure()]); + getFieldsValidations.mockClear(); + }); +}); diff --git a/src/logistration/messages.jsx b/src/logistration/messages.jsx index acec906d..1918e0f7 100644 --- a/src/logistration/messages.jsx +++ b/src/logistration/messages.jsx @@ -176,6 +176,11 @@ const messages = defineMessages({ defaultMessage: 'Username must be between 2 and 30 characters long.', description: 'Validation message that appears when username is invalid', }, + 'logistration.country.validation.message': { + id: 'logistration.country.validation.message', + defaultMessage: 'Select your country or region of residence.', + description: 'Validation message that appears when country is not selected', + }, 'logistration.support.education.research': { id: 'logistration.support.education.research', defaultMessage: 'Support education research by providing additional information', diff --git a/src/logistration/tests/RegistrationPage.test.jsx b/src/logistration/tests/RegistrationPage.test.jsx index 4dd90e6d..4c142523 100644 --- a/src/logistration/tests/RegistrationPage.test.jsx +++ b/src/logistration/tests/RegistrationPage.test.jsx @@ -9,7 +9,7 @@ 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'; +import { fetchRegistrationForm, fetchRealtimeValidations, registerNewUser } from '../data/actions'; const IntlRegistrationPage = injectIntl(RegistrationPage); const mockStore = configureStore(); @@ -237,6 +237,62 @@ describe('./RegistrationPage.js', () => { expect(store.dispatch).toHaveBeenCalledWith(fetchRegistrationForm()); }); + it('should dispatch fetchRealtimeValidations on Blur', () => { + store = mockStore({ + ...initialState, + logistration: { + ...initialState.logistration, + }, + }); + + const formPayload = { + email: '', + username: '', + password: '', + name: '', + honor_code: true, + country: '', + }; + store.dispatch = jest.fn(store.dispatch); + + const registrationPage = mount(reduxWrapper()); + + registrationPage.find('input#username').simulate('blur', { target: { value: '', name: 'username' } }); + expect(store.dispatch).toHaveBeenCalledWith(fetchRealtimeValidations(formPayload)); + + registrationPage.find('input#name').simulate('blur', { target: { value: '', name: 'name' } }); + expect(store.dispatch).toHaveBeenCalledWith(fetchRealtimeValidations(formPayload)); + + registrationPage.find('input#email').simulate('blur', { target: { value: '', name: 'email' } }); + expect(store.dispatch).toHaveBeenCalledWith(fetchRealtimeValidations(formPayload)); + + registrationPage.find('input#password').simulate('blur', { target: { value: '', name: 'password' } }); + expect(store.dispatch).toHaveBeenCalledWith(fetchRealtimeValidations(formPayload)); + }); + + it('should not dispatch registerNewUser on Submit', () => { + store = mockStore({ + ...initialState, + logistration: { + ...initialState.logistration, + }, + }); + + const formPayload = { + email: '', + username: '', + password: '', + name: '', + honor_code: true, + country: '', + }; + store.dispatch = jest.fn(store.dispatch); + + const registrationPage = mount(reduxWrapper()); + registrationPage.find('button.submit').simulate('click'); + expect(store.dispatch).not.toHaveBeenCalledWith(registerNewUser(formPayload)); + }); + it('should match default section snapshot', () => { const tree = renderer.create(reduxWrapper()); expect(tree.toJSON()).toMatchSnapshot(); diff --git a/src/logistration/tests/__snapshots__/RegistrationPage.test.jsx.snap b/src/logistration/tests/__snapshots__/RegistrationPage.test.jsx.snap index e324841f..a7098a92 100644 --- a/src/logistration/tests/__snapshots__/RegistrationPage.test.jsx.snap +++ b/src/logistration/tests/__snapshots__/RegistrationPage.test.jsx.snap @@ -73,18 +73,13 @@ exports[`./RegistrationPage.js should match TPA provider snapshot 1`] = ` className="form-control" id="name" name="name" + onBlur={[Function]} onChange={[Function]} placeholder="" required={true} type="text" value="" /> - - Enter your full name. -
- - Username must be between 2 and 30 characters long. -
- - Enter a valid email address that contains at least 3 characters. -
- - This password is too short. It must contain at least 8 characters. This password must contain at least 1 number. -
- - Enter your full name. -
- - Username must be between 2 and 30 characters long. -
- - Enter a valid email address that contains at least 3 characters. -
- - This password is too short. It must contain at least 8 characters. This password must contain at least 1 number. -
- - Enter your full name. -
- - Username must be between 2 and 30 characters long. -
- - Enter a valid email address that contains at least 3 characters. -
- - This password is too short. It must contain at least 8 characters. This password must contain at least 1 number. -
- - Enter your full name. -
- - Username must be between 2 and 30 characters long. -
- - Enter a valid email address that contains at least 3 characters. -
- - This password is too short. It must contain at least 8 characters. This password must contain at least 1 number. -