Skip to content

Commit

Permalink
ZENKO-2733 Create account: Error Handling
Browse files Browse the repository at this point in the history
  • Loading branch information
nicolas2bert committed Aug 19, 2020
1 parent 44baae1 commit bf470eb
Show file tree
Hide file tree
Showing 8 changed files with 541 additions and 595 deletions.
1 change: 1 addition & 0 deletions .flowconfig
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,6 @@ unsafe-getters-setters=warn

[options]
include_warnings=false
esproposal.optional_chaining=enable

[strict]
2 changes: 2 additions & 0 deletions .jest-setup.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import Enzyme from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
import "core-js/stable";
import "regenerator-runtime/runtime";

Enzyme.configure({ adapter: new Adapter() });

Expand Down
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,13 @@
},
"dependencies": {
"@fortawesome/fontawesome-free": "5.7.2",
"@hapi/joi": "^17.1.1",
"@hookform/resolvers": "^0.1.0",
"@scality/core-ui": "github:scality/core-ui.git#add-dist-folder",
"async": "^3.2.0",
"aws-sdk": "^2.616.0",
"connected-react-router": "^6.7.0",
"core-js": "^3.6.5",
"file-loader": "^5.0.2",
"history": "^4.10.1",
"immutable": "^4.0.0-rc.12",
Expand All @@ -65,6 +68,7 @@
"react": "^16.12.0",
"react-debounce-input": "3.2.0",
"react-dom": "^16.12.0",
"react-hook-form": "^6.3.3",
"react-redux": "^7.1.3",
"react-router-dom": "^5.1.2",
"react-select": "3.0.3",
Expand Down
71 changes: 30 additions & 41 deletions src/react/account/AccountCreate.jsx
Original file line number Diff line number Diff line change
@@ -1,52 +1,44 @@
// @flow
import { Banner, Button } from '@scality/core-ui';
import Form, * as F from '../ui-elements/FormLayout';
import { accountEmailValidation, accountNameValidation, accountQuotaValidation } from '../utils/validator';
import { clearError, createAccount } from '../actions';
import { useDispatch, useSelector } from 'react-redux';
import type { AppState } from '../../types/state';
import Joi from '@hapi/joi';
import React from 'react';
import { goBack } from 'connected-react-router';
import { useInput } from '../utils/hooks';
import { joiResolver } from '@hookform/resolvers';
import { useForm } from 'react-hook-form';

const regexpEmailAddress = /^\S+@\S+.\S+$/;
const regexpName = /^[\w+=,.@ -]+$/;

const schema = Joi.object({
name: Joi.string().label('Name').required().min(2).max(64).regex(regexpName).message('Invalid Name'),
email: Joi.string().label('Root Account Email').required().max(256).regex(regexpEmailAddress).message('Invalid Root Account Email'),
quota: Joi.number().label('Quota').allow('').optional().positive().integer(),
});

function AccountCreate() {
const { value: name, onChange: onChangeName, errorMessage: errorName, hasError: hasErrorName, validation: validationName } = useInput('', accountNameValidation);
const { value: email, onChange: onChangeEmail, errorMessage: errorEmail, hasError: hasErrorEmail, validation: validationEmail } = useInput('', accountEmailValidation);
const { value: quotaMax, onChange: onChangeQuotaMax, errorMessage: errorQuota, hasError: hasErrorQuota, validation: validationQuota } = useInput('', accountQuotaValidation);
const { register, handleSubmit, errors } = useForm({
resolver: joiResolver(schema),
});

const hasError = useSelector((state: AppState) => !!state.uiErrors.errorMsg && state.uiErrors.errorType === 'byComponent');
const errorMessage = useSelector((state: AppState) => state.uiErrors.errorMsg);

const dispatch = useDispatch();

const isValid = (): boolean => {
const isValidName = validationName(name);
const isValidEmail = validationEmail(email);
const isValidQuota = validationQuota(quotaMax);
return isValidName && isValidEmail && isValidQuota;
};

const submit = (e: SyntheticInputEvent<HTMLButtonElement>) => {
if (e) {
e.preventDefault();
}
if (hasError) {
dispatch(clearError());
}
if (!isValid()) {
return;
}
const quotaMaxInt = quotaMax ? parseInt(quotaMax, 10) : 0;
const onSubmit = ({ email, name, quota }) => {
const quotaMaxInt = quota || 0;
const payload = { userName: name, email, quotaMax: quotaMaxInt };
dispatch(createAccount(payload));
};

const onChangeWithClearError = (e, onChange) => {
const clearServerError = () => {
if (hasError) {
dispatch(clearError());
}
onChange(e);
};

return <Form autoComplete='off'>
Expand All @@ -58,12 +50,11 @@ function AccountCreate() {
<F.Input
type='text'
id='name'
value={name}
onChange={e => onChangeWithClearError(e, onChangeName)}
onBlur={e => validationName(e.target.value)}
hasError={hasErrorName}
name='name'
innerRef={register}
onChange={clearServerError}
autoComplete='new-password' />
<F.ErrorInput id='error-name' hasError={hasErrorName}> {errorName} </F.ErrorInput>
<F.ErrorInput id='error-name' hasError={errors.name}> {errors.name?.message} </F.ErrorInput>
</F.Fieldset>
<F.Fieldset>
<F.Label tooltipMessages={['Must be unique', 'When a new Account is created, a unique email is attached as the Root owner of this account, for initial authentication purpose']}>
Expand All @@ -72,12 +63,11 @@ function AccountCreate() {
<F.Input
type='text'
id='email'
value={email}
onChange={e => onChangeWithClearError(e, onChangeEmail)}
onBlur={e => validationEmail(e.target.value)}
hasError={hasErrorEmail}
name='email'
innerRef={register}
onChange={clearServerError}
autoComplete='off' />
<F.ErrorInput id='error-email' hasError={hasErrorEmail}> {errorEmail} </F.ErrorInput>
<F.ErrorInput id='error-email' hasError={errors.email}> {errors.email?.message} </F.ErrorInput>
</F.Fieldset>
<F.Fieldset>
<F.Label tooltipMessages={['Hard quota: the account cannot go over the limit', 'The limit can be changed after the creation', 'If the field is empty, there will be no limit']}>
Expand All @@ -86,13 +76,12 @@ function AccountCreate() {
<F.Input
type='number'
id='quota'
value={quotaMax}
onChange={e => onChangeWithClearError(e, onChangeQuotaMax)}
min="0"
onBlur={e => validationQuota(e.target.value)}
hasError={hasErrorQuota}
name='quota'
innerRef={register}
onChange={clearServerError}
autoComplete='off' />
<F.ErrorInput id='error-quota' hasError={hasErrorQuota}> {errorQuota} </F.ErrorInput>
<F.ErrorInput id='error-quota' hasError={errors.quota}> {errors.quota?.message} </F.ErrorInput>
</F.Fieldset>
<F.Footer>
<F.FooterError>
Expand All @@ -108,7 +97,7 @@ function AccountCreate() {
</F.FooterError>
<F.FooterButtons>
<Button variant="secondary" onClick={() => dispatch(goBack())} text='Cancel'/>
<Button id='create-account-btn' variant="info" onClick={submit} text='Create'/>
<Button id='create-account-btn' variant="info" onClick={handleSubmit(onSubmit)} text='Create'/>
</F.FooterButtons>
</F.Footer>
</Form>;
Expand Down
145 changes: 38 additions & 107 deletions src/react/account/__tests__/AccountCreate.test.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import AccountCreate from '../AccountCreate';
import React from 'react';
import { act } from 'react-dom/test-utils';
import { reduxMount } from '../../utils/test';

describe('AccountCreate', () => {
Expand Down Expand Up @@ -49,7 +50,7 @@ describe('AccountCreate', () => {
name: '',
email: '[email protected]',
quota: '1',
expectedNameError: 'Field cannot be left blank.',
expectedNameError: '"Name" is not allowed to be empty',
expectedEmailError: '',
expectedQuotaError: '',
},
Expand All @@ -59,24 +60,24 @@ describe('AccountCreate', () => {
email: '',
quota: '1',
expectedNameError: '',
expectedEmailError: 'Field cannot be left blank.',
expectedEmailError: '"Root Account Email" is not allowed to be empty',
expectedQuotaError: '',
},
{
description: 'should render 2 errors if name and email are missing',
name: '',
email: '',
quota: '1',
expectedNameError: 'Field cannot be left blank.',
expectedEmailError: 'Field cannot be left blank.',
expectedNameError: '"Name" is not allowed to be empty',
expectedEmailError: '"Root Account Email" is not allowed to be empty',
expectedQuotaError: '',
},
{
description: 'should render error if name is too short',
name: 'b',
email: '[email protected]',
quota: '1',
expectedNameError: 'Account name should be at least 2 characters long.',
expectedNameError: '"Name" length must be at least 2 characters long',
expectedEmailError: '',
expectedQuotaError: '',
},
Expand All @@ -85,7 +86,7 @@ describe('AccountCreate', () => {
name: 'b'.repeat(65),
email: '[email protected]',
quota: '1',
expectedNameError: 'The length of the property is too long. The maximum length is 64.',
expectedNameError: '"Name" length must be less than or equal to 64 characters long',
expectedEmailError: '',
expectedQuotaError: '',
},
Expand All @@ -94,7 +95,7 @@ describe('AccountCreate', () => {
name: '^^',
email: '[email protected]',
quota: '1',
expectedNameError: 'Invalid account name.',
expectedNameError: 'Invalid Name',
expectedEmailError: '',
expectedQuotaError: '',
},
Expand All @@ -104,7 +105,7 @@ describe('AccountCreate', () => {
email: 'invalid',
quota: '1',
expectedNameError: '',
expectedEmailError: 'Invalid email address.',
expectedEmailError: 'Invalid Root Account Email',
expectedQuotaError: '',
},
{
Expand All @@ -113,7 +114,7 @@ describe('AccountCreate', () => {
email: `${'b'.repeat(257)}@long.com`,
quota: '1',
expectedNameError: '',
expectedEmailError: 'The length of the property is too long. The maximum length is 256.',
expectedEmailError: '"Root Account Email" length must be less than or equal to 256 characters long',
expectedQuotaError: '',
},
{
Expand All @@ -123,17 +124,39 @@ describe('AccountCreate', () => {
quota: '-1',
expectedNameError: '',
expectedEmailError: '',
expectedQuotaError: 'Quota has to be a positive number. 0 means no quota.',
expectedQuotaError: '"Quota" must be a positive number',
},
{
description: 'should render error if quota is set to 0',
name: 'bart',
email: '[email protected]',
quota: '0',
expectedNameError: '',
expectedEmailError: '',
expectedQuotaError: '"Quota" must be a positive number',
},
];

tests.forEach(t => {
it(`Simulate click: ${t.description}`, () => {
it(`Simulate click: ${t.description}`, async () => {
const { component } = reduxMount(<AccountCreate/>);
component.find('input#name').simulate('change', { target: { value: t.name } });
component.find('input#email').simulate('change', { target: { value: t.email } });
component.find('input#quota').simulate('change', { target: { value: t.quota } });
component.find('Button#create-account-btn').simulate('click');
// NOTE: All validation methods in React Hook Form are treated
// as async functions, so it's important to wrap async around your act.
await act(async () => {
const elementName = component.find('input#name');
elementName.getDOMNode().value = t.name;
elementName.getDOMNode().dispatchEvent(new Event('input'));

const elementEmail = component.find('input#email');
elementEmail.getDOMNode().value = t.email;
elementEmail.getDOMNode().dispatchEvent(new Event('input'));

const elementQuota = component.find('input#quota');
elementQuota.getDOMNode().value = t.quota;
elementQuota.getDOMNode().dispatchEvent(new Event('input'));

component.find('Button#create-account-btn').simulate('click');
});

if (t.expectedNameError) {
expect(component.find('ErrorInput#error-name').text()).toContain(t.expectedNameError);
Expand All @@ -152,96 +175,4 @@ describe('AccountCreate', () => {
}
});
});

// * blur
it('Simulate blur: should render no error if name is valid', () => {
const { component } = reduxMount(<AccountCreate/>);
component.find('input#name').simulate('change', { target: { value: 'ba' } });
component.find('input#name').simulate('blur');

expect(component.find('ErrorInput#error-name').text()).toBeFalsy();
expect(component.find('ErrorInput#error-email').text()).toBeFalsy();
expect(component.find('ErrorInput#error-quota').text()).toBeFalsy();
});

it('Simulate blur: should render error if name is too short', () => {
const { component } = reduxMount(<AccountCreate/>);
component.find('input#name').simulate('change', { target: { value: 'b' } });
component.find('input#name').simulate('blur');

expect(component.find('ErrorInput#error-name').text()).toContain('Account name should be at least 2 characters long.');
expect(component.find('ErrorInput#error-email').text()).toBeFalsy();
expect(component.find('ErrorInput#error-quota').text()).toBeFalsy();
});

it('Simulate blur: should render error if name is invalid', () => {
const { component } = reduxMount(<AccountCreate/>);
component.find('input#name').simulate('change', { target: { value: '^^' } });
component.find('input#name').simulate('blur');

expect(component.find('ErrorInput#error-name').text()).toContain('Invalid account name.');
expect(component.find('ErrorInput#error-email').text()).toBeFalsy();
expect(component.find('ErrorInput#error-quota').text()).toBeFalsy();
});

it('Simulate blur: should render error if name is too long (> 64)', () => {
const { component } = reduxMount(<AccountCreate/>);
component.find('input#name').simulate('change', { target: { value: 'b'.repeat(65) } });
component.find('input#name').simulate('blur');

expect(component.find('ErrorInput#error-name').text()).toContain('The length of the property is too long. The maximum length is 64.');
expect(component.find('ErrorInput#error-email').text()).toBeFalsy();
expect(component.find('ErrorInput#error-quota').text()).toBeFalsy();
});

it('Simulate blur: should render no error if email is valid', () => {
const { component } = reduxMount(<AccountCreate/>);
component.find('input#email').simulate('change', { target: { value: '[email protected]' } });
component.find('input#email').simulate('blur');

expect(component.find('ErrorInput#error-name').text()).toBeFalsy();
expect(component.find('ErrorInput#error-email').text()).toBeFalsy();
expect(component.find('ErrorInput#error-quota').text()).toBeFalsy();
});

it('Simulate blur: should render error if email is invalid', () => {
const { component } = reduxMount(<AccountCreate/>);
component.find('input#email').simulate('change', { target: { value: 'invalid' } });
component.find('input#email').simulate('blur');

expect(component.find('ErrorInput#error-name').text()).toBeFalsy();
expect(component.find('ErrorInput#error-email').text()).toContain('Invalid email address.');
expect(component.find('ErrorInput#error-quota').text()).toBeFalsy();
});

it('Simulate blur: should render error if email is too long (> 256)', () => {
const { component } = reduxMount(<AccountCreate/>);
component.find('input#email').simulate('change', { target: { value: `${'b'.repeat(257)}@long.com` } });
component.find('input#email').simulate('blur');

expect(component.find('ErrorInput#error-name').text()).toBeFalsy();
expect(component.find('ErrorInput#error-email').text()).toContain('The length of the property is too long. The maximum length is 256.');
expect(component.find('ErrorInput#error-quota').text()).toBeFalsy();
});

it('Simulate blur: should render no error if quota is valid', () => {
const { component } = reduxMount(<AccountCreate/>);
component.find('input#quota').simulate('change', { quota: { value: '1' } });
component.find('input#quota').simulate('blur');

expect(component.find('ErrorInput#error-quota').text()).toBeFalsy();
expect(component.find('ErrorInput#error-email').text()).toBeFalsy();
expect(component.find('ErrorInput#error-name').text()).toBeFalsy();
});

it('Simulate blur: should render error if quota is invalid', () => {
const { component } = reduxMount(<AccountCreate/>);
component.find('input#quota').simulate('change', { target: { value: '-1' } });
component.find('input#quota').simulate('blur');

expect(component.find('ErrorInput#error-name').text()).toBeFalsy();
expect(component.find('ErrorInput#error-email').text()).toBeFalsy();
expect(component.find('ErrorInput#error-quota').text()).toContain('Quota has to be a positive number. 0 means no quota.');
});

});
Loading

0 comments on commit bf470eb

Please sign in to comment.