Clear field error on focus (#298)

Clear field related error messages for all forms on focus in event

VAN-505
This commit is contained in:
Mubbshar Anwar
2021-06-03 18:21:18 +05:00
committed by Waheed Ahmed
parent 92163ac7dc
commit e1546acb14
12 changed files with 94 additions and 217 deletions

View File

@@ -1,166 +0,0 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import {
Form,
Input,
ValidationFormGroup,
} from '@edx/paragon';
const AuthnCustomValidationFormGroup = (props) => {
const {
onBlur, onChange, onClick, onFocus,
} = props;
const [showHelpText, setShowHelpText] = useState(false);
const [showLabelText, setShowLabelText] = useState(false);
// handler code that need to be invoked via input
const onClickHandler = (e, clickCb) => {
setShowHelpText(true);
setShowLabelText(true);
if (clickCb) {
clickCb(e);
}
};
const onBlurHandler = (e, blurCb) => {
setShowHelpText(false);
setShowLabelText(false);
if (blurCb) {
blurCb(e);
}
};
const onChangeHandler = (e, changeCb) => {
if (changeCb) {
changeCb(e);
}
};
const onFocusHandler = (e, focusCb) => {
if (focusCb) {
focusCb(e);
}
};
const onOptionalHandler = (e, clickCb) => { clickCb(e); };
const showLabel = () => {
let className;
if (props.optionalFieldCheckbox || (!showLabelText && (props.value !== '' || props.type === 'select'))) {
className = 'sr-only';
} else if (showLabelText) {
className = 'pt-10 h6';
} else {
className = 'pt-10 focus-out';
}
return (
<Form.Label htmlFor={props.for} className={className}>{props.label}</Form.Label>
);
};
const showOptional = () => {
const additionalField = props.optionalFieldCheckbox ? (
<p role="presentation" id="additionalFields" className="mb-1 small" onClick={(e) => onOptionalHandler(e, onClick)}>
{props.checkboxMessage}
</p>
) : <span />;
return additionalField;
};
const inputProps = {
name: props.name,
id: props.for,
type: props.type,
value: props.value,
className: props.inputFieldStyle,
'aria-invalid': props.ariaInvalid,
autoComplete: 'on',
};
inputProps.onChange = (e) => onChangeHandler(e, onChange);
inputProps.onClick = (e) => onClickHandler(e, onClick);
inputProps.onBlur = (e) => onBlurHandler(e, onBlur);
inputProps.onFocus = (e) => onFocusHandler(e, onFocus);
if (props.type === 'select') {
inputProps.options = props.selectOptions;
inputProps.className = props.value === '' ? `${props.inputFieldStyle} text-muted` : props.inputFieldStyle;
}
if (props.type === 'checkbox') {
inputProps.checked = props.isChecked;
}
const validationGroupProps = {
for: props.for,
};
if (!props.optionalFieldCheckbox) {
validationGroupProps.invalid = props.invalid;
validationGroupProps.invalidMessage = props.invalidMessage;
validationGroupProps.helpText = showHelpText ? props.helpText : '';
} else {
validationGroupProps.className = props.optionalFieldCheckbox ? 'custom-control pt-10 mb-0' : '';
}
if (props.className) {
validationGroupProps.className = props.className;
}
return (
<ValidationFormGroup
{...validationGroupProps}
>
{showLabel()}
<Input
{...inputProps}
required
/>
{showOptional()}
</ValidationFormGroup>
);
};
AuthnCustomValidationFormGroup.defaultProps = {
name: '',
for: '',
label: '',
optionalFieldCheckbox: false,
type: '',
value: '',
invalid: false,
ariaInvalid: false,
invalidMessage: '',
inputFieldStyle: '',
helpText: '',
className: '',
onClick: null,
onBlur: null,
onChange: null,
onFocus: null,
isChecked: false,
checkboxMessage: '',
selectOptions: null,
};
AuthnCustomValidationFormGroup.propTypes = {
name: PropTypes.string,
for: PropTypes.string,
label: PropTypes.string,
type: PropTypes.string,
value: PropTypes.oneOfType([
PropTypes.string,
PropTypes.bool,
]),
invalid: PropTypes.bool,
ariaInvalid: PropTypes.bool,
invalidMessage: PropTypes.string,
helpText: PropTypes.string,
className: PropTypes.string,
inputFieldStyle: PropTypes.string,
isChecked: PropTypes.bool,
optionalFieldCheckbox: PropTypes.bool,
onClick: PropTypes.func,
onBlur: PropTypes.func,
onChange: PropTypes.func,
onFocus: PropTypes.func,
checkboxMessage: PropTypes.string,
selectOptions: PropTypes.arrayOf(PropTypes.shape({
key: PropTypes.string,
value: PropTypes.string,
})),
};
export default AuthnCustomValidationFormGroup;

View File

@@ -21,6 +21,13 @@ const PasswordField = (props) => {
setShowTooltip(props.showRequirements && false);
};
const handleFocus = (e) => {
if (props.handleFocus) {
props.handleFocus(e);
}
setTimeout(() => setShowTooltip(props.showRequirements && true), 150);
};
const HideButton = (
<IconButton icon={faEyeSlash} onClick={setHiddenTrue} alt={formatMessage(messages['hide.password'])} aria-label={formatMessage(messages['hide.password'])} />
);
@@ -54,7 +61,7 @@ const PasswordField = (props) => {
type={isPasswordHidden ? 'password' : 'text'}
name={props.name}
value={props.value}
onFocus={() => setTimeout(() => setShowTooltip(props.showRequirements && true), 150)}
onFocus={handleFocus}
onBlur={handleBlur}
onChange={props.handleChange}
controlClassName={props.borderClass}
@@ -73,6 +80,7 @@ PasswordField.defaultProps = {
borderClass: '',
errorMessage: '',
handleBlur: null,
handleFocus: null,
handleChange: () => {},
showRequirements: true,
};
@@ -82,6 +90,7 @@ PasswordField.propTypes = {
errorMessage: PropTypes.string,
floatingLabel: PropTypes.string.isRequired,
handleBlur: PropTypes.func,
handleFocus: PropTypes.func,
handleChange: PropTypes.func,
intl: intlShape.isRequired,
name: PropTypes.string.isRequired,

View File

@@ -7,7 +7,6 @@ export { default as SocialAuthProviders } from './SocialAuthProviders';
export { default as ThirdPartyAuthAlert } from './ThirdPartyAuthAlert';
export { default as InstitutionLogistration } from './InstitutionLogistration';
export { RenderInstitutionButton } from './InstitutionLogistration';
export { default as AuthnValidationFormGroup } from './AuthnValidationFormGroup';
export { default as APIFailureMessage } from './APIFailureMessage';
export { default as reducer } from './data/reducers';
export { default as saga } from './data/sagas';

View File

@@ -1,46 +0,0 @@
import React from 'react';
import { mount } from 'enzyme';
import AuthnCustomValidationFormGroup from '../AuthnValidationFormGroup';
describe('AuthnCustomValidationFormGroup', () => {
let props = {
label: 'Email Label',
for: 'email',
name: 'email',
type: 'email',
value: '',
helpText: 'Email field help text',
};
it('should show label in place of placeholder when field is empty', () => {
const validationFormGroup = mount(<AuthnCustomValidationFormGroup {...props} />);
expect(validationFormGroup.find('label').prop('className')).toEqual('pgn__form-label pt-10 focus-out');
});
it('should show label on top of field when field is focused in', () => {
const validationFormGroup = mount(<AuthnCustomValidationFormGroup {...props} />);
validationFormGroup.find('input').simulate('focus');
expect(validationFormGroup.find('label').prop('className')).toEqual('pgn__form-label pt-10 h6');
});
it('should keep label hidden for checkbox field', () => {
props = {
...props,
type: 'checkbox',
optionalFieldCheckbox: true,
};
const validationFormGroup = mount(<AuthnCustomValidationFormGroup {...props} />);
expect(validationFormGroup.find('label').prop('className')).toEqual('pgn__form-label sr-only');
});
it('should keep label hidden when input field is not empty', () => {
props = {
...props,
value: 'test',
};
const validationFormGroup = mount(<AuthnCustomValidationFormGroup {...props} />);
expect(validationFormGroup.find('label').prop('className')).toEqual('pgn__form-label sr-only');
});
});

View File

@@ -13,6 +13,7 @@ describe('FormGroup', () => {
helpText: ['Email field help text'],
name: 'email',
value: '',
handleFocus: jest.fn(),
};
it('should show help text on field focus', () => {
@@ -33,6 +34,7 @@ describe('PasswordField', () => {
floatingLabel: 'Password',
name: 'password',
value: 'password123',
handleFocus: jest.fn(),
};
});

View File

@@ -121,6 +121,18 @@ describe('ForgotPasswordPage', () => {
expect(forgotPasswordPage.find('.pgn__form-text-invalid').text()).toEqual(validationMessage);
});
it('should clear error message on focus event', async () => {
const validationMessage = 'Enter your email';
const forgotPasswordPage = mount(reduxWrapper(<IntlForgotPasswordPage {...props} />));
await act(async () => { await forgotPasswordPage.find('button.btn-brand').simulate('click'); });
forgotPasswordPage.update();
expect(forgotPasswordPage.find('.pgn__form-text-invalid').text()).toEqual(validationMessage);
forgotPasswordPage.find('input#email').simulate('focus');
expect(forgotPasswordPage.find('#email-invalid-feedback').exists()).toEqual(false);
});
it('check cookie rendered', () => {
const forgotPage = mount(reduxWrapper(<IntlForgotPasswordPage {...props} />));
expect(forgotPage.find(<CookiePolicyBanner />)).toBeTruthy();

View File

@@ -97,6 +97,12 @@ class LoginPage extends React.Component {
this.props.loginRequest(payload);
}
handleOnFocus = (e) => {
const { errors } = this.state;
errors[e.target.name] = '';
this.setState({ errors });
}
validateEmail(email) {
const { errors } = this.state;
@@ -210,9 +216,10 @@ class LoginPage extends React.Component {
{this.props.resetPassword && !this.props.loginError ? <ResetPasswordSuccess /> : null}
<Form>
<FormGroup
name="email"
name="emailOrUsername"
value={this.state.emailOrUsername}
handleChange={(e) => this.setState({ emailOrUsername: e.target.value, isSubmitted: false })}
handleFocus={this.handleOnFocus}
errorMessage={this.state.errors.emailOrUsername}
floatingLabel={intl.formatMessage(messages['login.user.identity.label'])}
/>
@@ -221,6 +228,7 @@ class LoginPage extends React.Component {
value={this.state.password}
showRequirements={false}
handleChange={(e) => this.setState({ password: e.target.value, isSubmitted: false })}
handleFocus={this.handleOnFocus}
errorMessage={this.state.errors.password}
floatingLabel={intl.formatMessage(messages['login.password.label'])}
/>

View File

@@ -87,7 +87,7 @@ describe('LoginPage', () => {
store.dispatch = jest.fn(store.dispatch);
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
loginPage.find('input#email').simulate('change', { target: { value: 'test@example.com' } });
loginPage.find('input#emailOrUsername').simulate('change', { target: { value: 'test@example.com' } });
loginPage.find('input#password').simulate('change', { target: { value: 'password' } });
loginPage.find('button.btn-brand').simulate('click');
@@ -123,12 +123,24 @@ describe('LoginPage', () => {
const loginPage = (mount(reduxWrapper(<IntlLoginPage {...props} />))).find('LoginPage');
loginPage.find('input#password').simulate('change', { target: { value: 'test', name: 'password' } });
loginPage.find('input#email').simulate('change', { target: { value: 'te', name: 'email' } });
loginPage.find('input#emailOrUsername').simulate('change', { target: { value: 'te', name: 'email' } });
loginPage.find('button.btn-brand').simulate('click');
expect(loginPage.state('errors')).toEqual(errorState);
});
it('should reset field related error messages on onFocus event', () => {
const errorState = { emailOrUsername: '', password: '' };
store.dispatch = jest.fn(store.dispatch);
const loginPage = (mount(reduxWrapper(<IntlLoginPage {...props} />))).find('LoginPage');
loginPage.find('button.btn-brand').simulate('click');
loginPage.find('input#emailOrUsername').simulate('focus');
loginPage.find('input#password').simulate('focus');
expect(loginPage.state('errors')).toEqual(errorState);
});
// ******** test form buttons and links ********
it('should match default button state', () => {

View File

@@ -225,6 +225,12 @@ class RegistrationPage extends React.Component {
}
}
handleOnFocus = (e) => {
const { errors } = this.state;
errors[e.target.name] = '';
this.setState({ errors });
}
handleSuggestionClick = (suggestion) => {
const { errors } = this.state;
errors.username = '';
@@ -451,6 +457,7 @@ class RegistrationPage extends React.Component {
value={this.state.name}
handleBlur={this.handleOnBlur}
handleChange={this.handleOnChange}
handleFocus={this.handleOnFocus}
errorMessage={this.state.errors.name}
floatingLabel={intl.formatMessage(messages['registration.fullname.label'])}
/>
@@ -459,6 +466,7 @@ class RegistrationPage extends React.Component {
value={this.state.username}
handleBlur={this.handleOnBlur}
handleChange={this.handleOnChange}
handleFocus={this.handleOnFocus}
errorMessage={this.state.errors.username}
helpText={[intl.formatMessage(messages['help.text.username.1']), intl.formatMessage(messages['help.text.username.2'])]}
floatingLabel={intl.formatMessage(messages['registration.username.label'])}
@@ -471,6 +479,7 @@ class RegistrationPage extends React.Component {
handleBlur={this.handleOnBlur}
handleChange={this.handleOnChange}
errorMessage={this.state.errors.email}
handleFocus={this.handleOnFocus}
helpText={[intl.formatMessage(messages['help.text.email'])]}
floatingLabel={intl.formatMessage(messages['registration.email.label'])}
borderClass={this.state.borderClass}
@@ -484,6 +493,7 @@ class RegistrationPage extends React.Component {
value={this.state.password}
handleBlur={this.handleOnBlur}
handleChange={this.handleOnChange}
handleFocus={this.handleOnFocus}
errorMessage={this.state.errors.password}
floatingLabel={intl.formatMessage(messages['registration.password.label'])}
/>
@@ -494,6 +504,7 @@ class RegistrationPage extends React.Component {
value={this.state.country}
handleBlur={this.handleOnBlur}
handleChange={this.handleOnChange}
handleFocus={this.handleOnFocus}
errorMessage={this.state.errors.country}
floatingLabel={intl.formatMessage(messages['registration.country.label'])}
trailingElement={<Icon src={ExpandMore} />}

View File

@@ -293,6 +293,32 @@ describe('RegistrationPage', () => {
expect(registrationPage.state('errorCode')).toEqual('duplicate-username');
});
// ******** test clear error messages on focus in ********
it('should clear field related error messages on input field Focus', () => {
const errors = {
email: '',
name: '',
username: '',
password: '',
country: '',
};
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
registrationPage.find('button.btn-brand').simulate('click');
expect(registrationPage.find('div[feedback-for="name"]').text()).toEqual(emptyFieldValidation.name);
registrationPage.find('input#name').simulate('focus');
expect(registrationPage.find('div[feedback-for="username"]').text()).toEqual(emptyFieldValidation.username);
registrationPage.find('input#username').simulate('focus');
expect(registrationPage.find('div[feedback-for="email"]').text()).toEqual(emptyFieldValidation.email);
registrationPage.find('input#email').simulate('focus');
expect(registrationPage.find('div[feedback-for="password"]').text()).toEqual(emptyFieldValidation.password);
registrationPage.find('input#password').simulate('focus');
expect(registrationPage.find('div[feedback-for="country"]').text()).toEqual(emptyFieldValidation.country);
registrationPage.find('select#country').simulate('blur', { target: { value: 'US', name: 'country' } });
expect(registrationPage.find('RegistrationPage').state('errors')).toEqual(errors);
});
// ******** test alert messages ********
it('should match third party auth alert', () => {

View File

@@ -84,6 +84,10 @@ const ResetPasswordPage = (props) => {
validateInput('confirmPassword', value);
};
const handleOnFocus = (e) => {
setFormErrors({ [e.target.name]: '' });
};
const handleSubmit = (e) => {
e.preventDefault();
@@ -137,6 +141,7 @@ const ResetPasswordPage = (props) => {
value={newPassword}
handleChange={(e) => setNewPassword(e.target.value)}
handleBlur={(e) => validateInput(e.target.name, e.target.value)}
handleFocus={handleOnFocus}
errorMessage={formErrors.newPassword}
floatingLabel={intl.formatMessage(messages['new.password.label'])}
/>
@@ -144,6 +149,7 @@ const ResetPasswordPage = (props) => {
name="confirmPassword"
value={confirmPassword}
handleChange={handleConfirmPasswordChange}
handleFocus={handleOnFocus}
errorMessage={formErrors.confirmPassword}
showRequirements={false}
floatingLabel={intl.formatMessage(messages['confirm.password.label'])}

View File

@@ -99,6 +99,10 @@ describe('ResetPasswordPage', () => {
);
expect(resetPasswordPage.find('div[feedback-for="newPassword"]').text()).toEqual('Password criteria has not been met');
expect(resetPasswordPage.find('div[feedback-for="confirmPassword"]').text()).toEqual('Confirm your password');
resetPasswordPage.find('input#newPassword').simulate('focus');
expect(resetPasswordPage.find('div[feedback-for="newPassword"]').exists()).toBe(false);
resetPasswordPage.find('input#confirmPassword').simulate('focus');
expect(resetPasswordPage.find('div[feedback-for="confirmPassword"]').exists()).toBe(false);
});
it('should show error message when new and confirm password do not match', () => {