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 {
{intl.formatMessage(messages['logistration.fullname.label'])}
@@ -423,13 +460,14 @@ class RegistrationPage extends React.Component {
placeholder=""
value={this.state.name}
onChange={e => this.handleOnChange(e)}
+ onBlur={e => this.handleOnBlur(e)}
required
/>
{intl.formatMessage(messages['logistration.username.label'])}
@@ -441,13 +479,14 @@ class RegistrationPage extends React.Component {
placeholder=""
value={this.state.username}
onChange={e => this.handleOnChange(e)}
+ onBlur={e => this.handleOnBlur(e)}
required
/>
{intl.formatMessage(messages['logistration.register.page.email.label'])}
@@ -459,13 +498,14 @@ class RegistrationPage extends React.Component {
placeholder=""
value={this.state.email}
onChange={e => this.handleOnChange(e)}
+ onBlur={e => this.handleOnBlur(e)}
required
/>
{intl.formatMessage(messages['logistration.password.label'])}
@@ -477,6 +517,7 @@ class RegistrationPage extends React.Component {
placeholder=""
value={this.state.password}
onChange={e => this.handleOnChange(e)}
+ onBlur={e => this.handleOnBlur(e)}
required
/>
@@ -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.
-