Adds realtime validations to register form.

This patch adds realtime/ validations for;
	*) name
	*) fullname
	*) password
	*) email

VAN-30
This commit is contained in:
Adeel Khan
2020-11-19 16:07:06 +05:00
parent a1f6685641
commit ae298a5ab3
9 changed files with 276 additions and 109 deletions

View File

@@ -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 {
<ValidationFormGroup
for="name"
invalid={this.state.errors.name !== ''}
invalidMessage={intl.formatMessage(messages['logistration.fullname.validation.message'])}
invalidMessage={this.state.errors.name}
>
<label htmlFor="name" className="h6 pt-3">
{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
/>
</ValidationFormGroup>
<ValidationFormGroup
for="username"
invalid={this.state.errors.username !== ''}
invalidMessage={intl.formatMessage(messages['logistration.username.validation.message'])}
invalidMessage={this.state.errors.username}
>
<label htmlFor="username" className="h6 pt-3">
{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
/>
</ValidationFormGroup>
<ValidationFormGroup
for="email"
invalid={this.state.errors.email !== ''}
invalidMessage={intl.formatMessage(messages['logistration.email.validation.message'])}
invalidMessage={this.state.errors.email}
>
<label htmlFor="email" className="h6 pt-3">
{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
/>
</ValidationFormGroup>
<ValidationFormGroup
for="password"
invalid={this.state.errors.password !== ''}
invalidMessage={intl.formatMessage(messages['logistration.register.page.password.validation.message'])}
invalidMessage={this.state.errors.password}
>
<label htmlFor="password" className="h6 pt-3">
{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
/>
</ValidationFormGroup>
@@ -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));

View File

@@ -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,
});

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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,
};
}

View File

@@ -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();
});
});

View File

@@ -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',

View File

@@ -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(<IntlRegistrationPage {...props} />));
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(<IntlRegistrationPage {...props} />));
registrationPage.find('button.submit').simulate('click');
expect(store.dispatch).not.toHaveBeenCalledWith(registerNewUser(formPayload));
});
it('should match default section snapshot', () => {
const tree = renderer.create(reduxWrapper(<IntlRegistrationPage {...props} />));
expect(tree.toJSON()).toMatchSnapshot();

View File

@@ -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=""
/>
<strong
className="invalid-feedback"
id="name-invalid-feedback"
>
Enter your full name.
</strong>
</div>
<div
className="form-group"
@@ -100,18 +95,13 @@ exports[`./RegistrationPage.js should match TPA provider snapshot 1`] = `
className="form-control"
id="username"
name="username"
onBlur={[Function]}
onChange={[Function]}
placeholder=""
required={true}
type="text"
value=""
/>
<strong
className="invalid-feedback"
id="username-invalid-feedback"
>
Username must be between 2 and 30 characters long.
</strong>
</div>
<div
className="form-group"
@@ -127,18 +117,13 @@ exports[`./RegistrationPage.js should match TPA provider snapshot 1`] = `
className="form-control"
id="email"
name="email"
onBlur={[Function]}
onChange={[Function]}
placeholder=""
required={true}
type="email"
value=""
/>
<strong
className="invalid-feedback"
id="email-invalid-feedback"
>
Enter a valid email address that contains at least 3 characters.
</strong>
</div>
<div
className="form-group"
@@ -154,18 +139,13 @@ exports[`./RegistrationPage.js should match TPA provider snapshot 1`] = `
className="form-control"
id="password"
name="password"
onBlur={[Function]}
onChange={[Function]}
placeholder=""
required={true}
type="password"
value=""
/>
<strong
className="invalid-feedback"
id="password-invalid-feedback"
>
This password is too short. It must contain at least 8 characters. This password must contain at least 1 number.
</strong>
</div>
<div
className="form-group custom-control"
@@ -270,18 +250,13 @@ exports[`./RegistrationPage.js should match default section snapshot 1`] = `
className="form-control"
id="name"
name="name"
onBlur={[Function]}
onChange={[Function]}
placeholder=""
required={true}
type="text"
value=""
/>
<strong
className="invalid-feedback"
id="name-invalid-feedback"
>
Enter your full name.
</strong>
</div>
<div
className="form-group"
@@ -297,18 +272,13 @@ exports[`./RegistrationPage.js should match default section snapshot 1`] = `
className="form-control"
id="username"
name="username"
onBlur={[Function]}
onChange={[Function]}
placeholder=""
required={true}
type="text"
value=""
/>
<strong
className="invalid-feedback"
id="username-invalid-feedback"
>
Username must be between 2 and 30 characters long.
</strong>
</div>
<div
className="form-group"
@@ -324,18 +294,13 @@ exports[`./RegistrationPage.js should match default section snapshot 1`] = `
className="form-control"
id="email"
name="email"
onBlur={[Function]}
onChange={[Function]}
placeholder=""
required={true}
type="email"
value=""
/>
<strong
className="invalid-feedback"
id="email-invalid-feedback"
>
Enter a valid email address that contains at least 3 characters.
</strong>
</div>
<div
className="form-group"
@@ -351,18 +316,13 @@ exports[`./RegistrationPage.js should match default section snapshot 1`] = `
className="form-control"
id="password"
name="password"
onBlur={[Function]}
onChange={[Function]}
placeholder=""
required={true}
type="password"
value=""
/>
<strong
className="invalid-feedback"
id="password-invalid-feedback"
>
This password is too short. It must contain at least 8 characters. This password must contain at least 1 number.
</strong>
</div>
<div
className="form-group custom-control"
@@ -467,18 +427,13 @@ exports[`./RegistrationPage.js should match pending button state snapshot 1`] =
className="form-control"
id="name"
name="name"
onBlur={[Function]}
onChange={[Function]}
placeholder=""
required={true}
type="text"
value=""
/>
<strong
className="invalid-feedback"
id="name-invalid-feedback"
>
Enter your full name.
</strong>
</div>
<div
className="form-group"
@@ -494,18 +449,13 @@ exports[`./RegistrationPage.js should match pending button state snapshot 1`] =
className="form-control"
id="username"
name="username"
onBlur={[Function]}
onChange={[Function]}
placeholder=""
required={true}
type="text"
value=""
/>
<strong
className="invalid-feedback"
id="username-invalid-feedback"
>
Username must be between 2 and 30 characters long.
</strong>
</div>
<div
className="form-group"
@@ -521,18 +471,13 @@ exports[`./RegistrationPage.js should match pending button state snapshot 1`] =
className="form-control"
id="email"
name="email"
onBlur={[Function]}
onChange={[Function]}
placeholder=""
required={true}
type="email"
value=""
/>
<strong
className="invalid-feedback"
id="email-invalid-feedback"
>
Enter a valid email address that contains at least 3 characters.
</strong>
</div>
<div
className="form-group"
@@ -548,18 +493,13 @@ exports[`./RegistrationPage.js should match pending button state snapshot 1`] =
className="form-control"
id="password"
name="password"
onBlur={[Function]}
onChange={[Function]}
placeholder=""
required={true}
type="password"
value=""
/>
<strong
className="invalid-feedback"
id="password-invalid-feedback"
>
This password is too short. It must contain at least 8 characters. This password must contain at least 1 number.
</strong>
</div>
<div
className="form-group custom-control"
@@ -711,18 +651,13 @@ exports[`./RegistrationPage.js should show error message on 409 1`] = `
className="form-control"
id="name"
name="name"
onBlur={[Function]}
onChange={[Function]}
placeholder=""
required={true}
type="text"
value=""
/>
<strong
className="invalid-feedback"
id="name-invalid-feedback"
>
Enter your full name.
</strong>
</div>
<div
className="form-group"
@@ -738,18 +673,13 @@ exports[`./RegistrationPage.js should show error message on 409 1`] = `
className="form-control"
id="username"
name="username"
onBlur={[Function]}
onChange={[Function]}
placeholder=""
required={true}
type="text"
value=""
/>
<strong
className="invalid-feedback"
id="username-invalid-feedback"
>
Username must be between 2 and 30 characters long.
</strong>
</div>
<div
className="form-group"
@@ -765,18 +695,13 @@ exports[`./RegistrationPage.js should show error message on 409 1`] = `
className="form-control"
id="email"
name="email"
onBlur={[Function]}
onChange={[Function]}
placeholder=""
required={true}
type="email"
value=""
/>
<strong
className="invalid-feedback"
id="email-invalid-feedback"
>
Enter a valid email address that contains at least 3 characters.
</strong>
</div>
<div
className="form-group"
@@ -792,18 +717,13 @@ exports[`./RegistrationPage.js should show error message on 409 1`] = `
className="form-control"
id="password"
name="password"
onBlur={[Function]}
onChange={[Function]}
placeholder=""
required={true}
type="password"
value=""
/>
<strong
className="invalid-feedback"
id="password-invalid-feedback"
>
This password is too short. It must contain at least 8 characters. This password must contain at least 1 number.
</strong>
</div>
<div
className="form-group custom-control"