Add validation to text inputs (#299)

* Add validation to text inputs (#56)

* [CI Skip] 0.24.0-beta.3

skip-checks: true

* Change imports

* Refactor ValidatedInput.tsx to stop cloning elements

* Fix linter

* Fix lint

Co-authored-by: Github Actions <action@github.com>
This commit is contained in:
Ivan Rukhavets 2020-04-02 12:19:42 +03:00 committed by GitHub
parent d20f6e1a38
commit 1af44b4264
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 275 additions and 97 deletions

View File

@ -32,6 +32,16 @@ describe('Create Account', () => {
const check = (input: ReactWrapper): unknown => input.simulate('change', { target: { checked: true } });
const type = async (input: ReactWrapper, value: string): Promise<void> => {
input.simulate('change', { target: { value } });
await act(flushAllPromises);
wrapper.update();
};
const enterName = (name: string): Promise<void> => type(wrapper.find('input'), name);
const password = (password: string) => (): Promise<void> => type(wrapper.find('input[type="password"]').first(), password);
const repeat = (password: string) => (): Promise<void> => type(wrapper.find('input[type="password"]').last(), password);
beforeEach(async () => {
onActionStub = jest.fn();
jest.spyOn(messaging, 'createSeed').mockResolvedValue(exampleAccount);
@ -67,11 +77,10 @@ describe('Create Account', () => {
});
describe('Phase 2', () => {
const type = (input: ReactWrapper, value: string): unknown => input.simulate('change', { target: { value } });
beforeEach(() => {
beforeEach(async () => {
check(wrapper.find('input[type="checkbox"]'));
wrapper.find('button').simulate('click');
await act(flushAllPromises);
});
it('only account name input is visible at first', () => {
@ -81,55 +90,96 @@ describe('Create Account', () => {
expect(wrapper.find(Button)).toHaveLength(0);
});
it('after typing less than 3 characters into name input, password input is not visible', () => {
type(wrapper.find('input'), 'ab');
it('input should not be highlighted as error until first interaction', () => {
expect(wrapper.find(Input).prop('withError')).toBe(false);
});
it('after typing less than 3 characters into name input, password input is not visible', async () => {
await enterName('ab');
expect(wrapper.find(Input).prop('withError')).toBe(true);
expect(wrapper.find('ErrorMessage').text()).toBe('Account name is too short');
expect(wrapper.find(InputWithLabel).find('[data-input-password]')).toHaveLength(0);
expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]')).toHaveLength(0);
expect(wrapper.find(Button)).toHaveLength(0);
});
it('after typing 3 characters into name input, first password input is visible', () => {
type(wrapper.find('input'), 'abc');
it('input should keep showing error when something has been typed but then erased', async () => {
await enterName('ab');
await enterName('');
expect(wrapper.find(Input).prop('withError')).toBe(true);
});
it('after typing 3 characters into name input, first password input is visible', async () => {
await enterName('abc');
expect(wrapper.find(Input).first().prop('withError')).toBe(false);
expect(wrapper.find(InputWithLabel).find('[data-input-password]').find(Input)).toHaveLength(1);
expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]')).toHaveLength(0);
expect(wrapper.find(Button)).toHaveLength(0);
});
it('password shorter than 6 characters should be not valid', () => {
type(wrapper.find('input'), 'abc');
type(wrapper.find('input[type="password"]'), 'abcde');
it('password shorter than 6 characters should be not valid', async () => {
await enterName('abc').then(password('abcde'));
expect(wrapper.find(InputWithLabel).find('[data-input-password]').find(Input).prop('withError')).toBe(true);
expect(wrapper.find('ErrorMessage').text()).toBe('Password is too short');
expect(wrapper.find(InputWithLabel).find('[data-input-password]').find(Input)).toHaveLength(1);
expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]')).toHaveLength(0);
expect(wrapper.find(Button)).toHaveLength(0);
});
it('submit button is not visible until both passwords are equal', () => {
type(wrapper.find('input'), 'abc');
type(wrapper.find('input[type="password"]').first(), 'abcdef');
type(wrapper.find('input[type="password"]').last(), 'abcdeg');
it('submit button is not visible until both passwords are equal', async () => {
await enterName('abc').then(password('abcdef')).then(repeat('abcdeg'));
expect(wrapper.find('ErrorMessage').text()).toBe('Passwords do not match');
expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]').find(Input).prop('withError')).toBe(true);
expect(wrapper.find(Button)).toHaveLength(0);
});
it('submit button is visible when both passwords are equal', () => {
type(wrapper.find('input'), 'abc');
type(wrapper.find('input[type="password"]').first(), 'abcdef');
type(wrapper.find('input[type="password"]').last(), 'abcdef');
it('submit button is visible when both passwords are equal', async () => {
await enterName('abc').then(password('abcdef')).then(repeat('abcdef'));
expect(wrapper.find('ErrorMessage')).toHaveLength(0);
expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]').find(Input).prop('withError')).toBe(false);
expect(wrapper.find(Button)).toHaveLength(1);
});
it('saves account with provided name and password', async () => {
type(wrapper.find('input'), 'abc');
type(wrapper.find('input[type="password"]').first(), 'abcdef');
type(wrapper.find('input[type="password"]').last(), 'abcdef');
await enterName('abc').then(password('abcdef')).then(repeat('abcdef'));
wrapper.find(Button).find('button').simulate('click');
await act(flushAllPromises);
expect(messaging.createAccountSuri).toBeCalledWith('abc', 'abcdef', exampleAccount.seed);
await flushAllPromises();
expect(onActionStub).toBeCalledWith('/');
});
});
describe('Both passwords are equal, but then', () => {
beforeEach(async () => {
check(wrapper.find('input[type="checkbox"]'));
wrapper.find('button').simulate('click');
await act(flushAllPromises);
await enterName('abc').then(password('abcdef')).then(repeat('abcdef'));
});
it('first password input is cleared - second one and button get hidden', async () => {
await type(wrapper.find('input[type="password"]').first(), '');
expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]')).toHaveLength(0);
expect(wrapper.find(Button)).toHaveLength(0);
});
it('first password changes - button is not visible', async () => {
await type(wrapper.find('input[type="password"]').first(), 'aaaaaa');
expect(wrapper.find('ErrorMessage').text()).toBe('Passwords do not match');
expect(wrapper.find(Button)).toHaveLength(0);
});
it('first password changes, then second changes too - button is visible', async () => {
await type(wrapper.find('input[type="password"]').first(), 'aaaaaa');
await type(wrapper.find('input[type="password"]').last(), 'aaaaaa');
expect(wrapper.find(Button)).toHaveLength(1);
});
it('second password changes, then first changes too - button is visible', async () => {
await type(wrapper.find('input[type="password"]').last(), 'aaaaaa');
await type(wrapper.find('input[type="password"]').first(), 'aaaaaa');
expect(wrapper.find(Button)).toHaveLength(1);
});
});
});

View File

@ -2,35 +2,54 @@
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import React, { useContext, useState } from 'react';
import { ActionContext, Address, Button, ButtonArea, Header, TextAreaWithLabel, VerticalSpace } from '../components';
import { createAccountSuri, validateSeed } from '../messaging';
import { Name, Password } from '../partials';
import React, { useCallback, useContext, useState } from 'react';
import styled from 'styled-components';
import { ActionContext, Address, Button, ButtonArea, Header, TextAreaWithLabel, ValidatedInput, VerticalSpace } from '../components';
import { createAccountSuri, validateSeed } from '../messaging';
import { Name, Password } from '../partials';
import { allOf, isNotShorterThan, Result } from '../validators';
type Props = {};
const validate = async (suri: string): Promise<Result<string>> => {
try {
await validateSeed(suri);
return Result.ok(suri);
} catch (err) {
return Result.error(err.message);
}
};
const isSeedValid = allOf(
isNotShorterThan(1, 'Seed is empty'),
validate
);
export default function Import (): React.ReactElement<Props> {
const onAction = useContext(ActionContext);
const [account, setAccount] = useState<null | { address: string; suri: string }>(null);
const [name, setName] = useState<string | null>(null);
const [password, setPassword] = useState<string | null>(null);
const _onChangeSeed = (suri: string): Promise<void> =>
validateSeed(suri)
.then(setAccount)
.catch((): void => setAccount(null));
const _onChangeSeed = useCallback(async (suri: string | null): Promise<void> => {
if (suri) {
setAccount(await validateSeed(suri));
} else {
setAccount(null);
}
}, []);
// FIXME Duplicated between here and Create.tsx
const _onCreate = (): void => {
const _onCreate = useCallback((): void => {
// this should always be the case
if (name && password && account) {
createAccountSuri(name, password, account.suri)
.then((): void => onAction('/'))
.catch((error: Error) => console.error(error));
}
};
}, [account, name, onAction, password]);
return (
<>
@ -38,12 +57,14 @@ export default function Import (): React.ReactElement<Props> {
showBackArrow
text='Import account'
/>
<SeedInput
isError={!account}
<ValidatedInput
// eslint-disable-next-line @typescript-eslint/no-use-before-define
component={SeedInput}
isFocused
label='existing 12 or 24-word mnemonic seed'
onChange={_onChangeSeed}
onValidatedChange={_onChangeSeed}
rowsCount={2}
validator={isSeedValid}
/>
{account && <Name onChange={setName} />}
{account && name && <Password onChange={setPassword} />}

View File

@ -0,0 +1,64 @@
// Copyright 2019 @polkadot/extension-ui authors & contributors
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import React, { useState, useEffect } from 'react';
import styled from 'styled-components';
import { Result, Validator } from '../validators';
interface BasicProps {
isError?: boolean;
value?: string;
onChange?: (value: string) => void;
}
type Props<T extends BasicProps> = T & {
className?: string;
validator: Validator<string>;
component: React.ComponentType<T>;
onValidatedChange: (value: string | null) => void;
}
function ValidatedInput<T extends object> ({ className, component: Input, onValidatedChange, validator, ...props }: Props<T>): React.ReactElement<Props<T>> {
const [value, setValue] = useState('');
const [wasMounted, setWasMounted] = useState(false);
const [validationResult, setValidationResult] = useState<Result<string>>(Result.ok(''));
useEffect(() => {
if (!wasMounted) {
setWasMounted(true);
return;
}
(async (): Promise<void> => {
const result = await validator(value);
setValidationResult(result);
onValidatedChange(Result.isOk(result) ? value : null);
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value, validator, onValidatedChange]);
return (
<div className={className}>
<Input {...props as T}
isError={Result.isError(validationResult)}
onChange={setValue}
value={value} />
{Result.isError(validationResult) && <ErrorMessage>{validationResult.error.errorDescription}</ErrorMessage>}
</div>
);
}
const ErrorMessage = styled.span`
display: block;
margin-top: -10px;
font-size: ${({ theme }): string => theme.labelFontSize};
line-height: ${({ theme }): string => theme.labelLineHeight};
color: ${({ theme }): string => theme.errorColor};
`;
ErrorMessage.displayName = 'ErrorMessage';
export default ValidatedInput;

View File

@ -27,6 +27,7 @@ export { default as Svg } from './Svg';
export { default as Table } from './Table';
export { default as TextAreaWithLabel } from './TextAreaWithLabel';
export { default as Title } from './Title';
export { default as ValidatedInput } from './ValidatedInput';
export { default as VerticalSpace } from './VerticalSpace';
export { default as View } from './View';
export { default as Warning } from './Warning';

View File

@ -3,8 +3,9 @@
// of the Apache-2.0 license. See the LICENSE file for details.
import React, { useCallback, useState } from 'react';
import { InputWithLabel } from '@polkadot/extension-ui/components';
import { validateDerivationPath } from '@polkadot/extension-ui/messaging';
import { InputWithLabel, ValidatedInput } from '../components';
import { Result } from '../validators';
interface Props {
onChange: (derivedAccount: { address: string; suri: string } | null) => void;
@ -14,33 +15,31 @@ interface Props {
function DerivationPath ({ onChange, parentAddress, parentPassword }: Props): React.ReactElement<Props> {
const [path, setPath] = useState<string>('');
const [isValid, setValid] = useState(false);
const _onChange = useCallback(
async (newPath: string) => {
setPath(newPath);
const isPathValid = useCallback(async (path: string): Promise<Result<string>> => {
try {
await validateDerivationPath(parentAddress, path, parentPassword);
try {
onChange(await validateDerivationPath(parentAddress, newPath, parentPassword));
setValid(true);
} catch (err) {
onChange(null);
setValid(false);
}
},
[parentAddress, onChange, parentPassword]
);
return Result.ok(path);
} catch (error) {
return Result.error('Invalid derivation path');
}
}, [parentAddress, parentPassword]);
const _onChange = useCallback(async (newPath: string | null) => {
newPath && setPath(newPath);
onChange(newPath ? await validateDerivationPath(parentAddress, newPath, parentPassword) : null);
}, [parentAddress, onChange, parentPassword]);
return (
<div>
<InputWithLabel
isError={!isValid}
label='Derivation path'
onChange={_onChange}
placeholder='//hard/soft'
value={path}
/>
</div>
<ValidatedInput
component={InputWithLabel}
label='Derivation path'
onValidatedChange={_onChange}
placeholder='//hard/soft'
validator={isPathValid}
value={path}
/>
);
}

View File

@ -2,50 +2,39 @@
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import React, { useContext, useEffect, useState } from 'react';
import React, { useContext } from 'react';
import { AccountContext, InputWithLabel } from '../components';
import { AccountContext, InputWithLabel, ValidatedInput } from '../components';
import { isNotShorterThan } from '../validators';
interface Props {
address?: string;
className?: string;
defaultValue?: string | null;
isFocused?: boolean;
label?: string;
onBlur?: () => void;
onChange: (name: string | null) => void;
}
const MIN_LENGTH = 3;
const isNameValid = isNotShorterThan(3, 'Account name is too short');
export default function Name ({ address, className, defaultValue, isFocused, label = 'A descriptive name for your account', onBlur, onChange }: Props): React.ReactElement<Props> {
export default function Name ({ address, className, isFocused, label = 'A descriptive name for your account', onBlur, onChange }: Props): React.ReactElement<Props> {
const accounts = useContext(AccountContext);
const [name, setName] = useState('');
const account = accounts.find((account): boolean => account.address === address);
const startValue = (account && account.name) || defaultValue;
const isError = !name && startValue
? false
: (name.length < MIN_LENGTH);
useEffect((): void => {
onChange(
name && (name.length >= MIN_LENGTH)
? name
: null
);
}, [name, onChange]);
const startValue = account && account.name;
return (
<InputWithLabel
<ValidatedInput
className={className}
component={InputWithLabel}
data-input-name
defaultValue={startValue}
isError={isError}
isFocused={isFocused}
label={label}
onBlur={onBlur}
onChange={setName}
onValidatedChange={onChange}
type='text'
validator={isNameValid}
/>
);
}

View File

@ -2,9 +2,10 @@
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import React, { useEffect, useState } from 'react';
import React, { useState, useEffect } from 'react';
import { InputWithLabel } from '../components';
import { InputWithLabel, ValidatedInput } from '../components';
import { allOf, isNotShorterThan, isSameAs, Validator } from '../validators';
interface Props {
isFocussed?: boolean;
@ -12,36 +13,39 @@ interface Props {
}
const MIN_LENGTH = 6;
const isFirstPasswordValid = isNotShorterThan(MIN_LENGTH, 'Password is too short');
const isSecondPasswordValid = (firstPassword: string): Validator<string> => allOf(
isNotShorterThan(MIN_LENGTH, 'Password is too short'),
isSameAs(firstPassword, 'Passwords do not match')
);
export default function Password ({ isFocussed, onChange }: Props): React.ReactElement<Props> {
const [pass1, setPass1] = useState('');
const [pass2, setPass2] = useState('');
const [pass1, setPass1] = useState<string | null>(null);
const [pass2, setPass2] = useState<string | null>(null);
useEffect((): void => {
onChange(
(pass1 && pass2 && (pass1.length >= MIN_LENGTH) && (pass1 === pass2))
? pass1
: null
);
onChange(pass1 && pass2 ? pass1 : null);
}, [onChange, pass1, pass2]);
return (
<>
<InputWithLabel
<ValidatedInput
component={InputWithLabel}
data-input-password
isError={pass1.length < MIN_LENGTH}
isFocused={isFocussed}
label='A new password for this account'
onChange={setPass1}
onValidatedChange={setPass1}
type='password'
validator={isFirstPasswordValid}
/>
{(pass1.length >= MIN_LENGTH) && (
<InputWithLabel
{pass1 && (
<ValidatedInput
component={InputWithLabel}
data-input-repeat-password
isError={pass1 !== pass2}
label='Repeat password for verification'
onChange={setPass2}
onValidatedChange={setPass2}
type='password'
validator={isSecondPasswordValid(pass1)}
/>
)}
</>

View File

@ -0,0 +1,50 @@
// Copyright 2019 @polkadot/extension-ui authors & contributors
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
interface InputError {
errorDescription: string;
}
export type Result<T> = { error: InputError } | { ok: T }
export const Result = {
error: <T>(errorDescription: string): Result<T> => ({ error: { errorDescription } }),
isError<T> (value: Result<T>): value is ({ error: InputError }) {
return Object.hasOwnProperty.call(value, 'error');
},
isOk<T> (value: Result<T>): value is ({ ok: T }) {
return Object.hasOwnProperty.call(value, 'ok');
},
ok: <T>(ok: T): Result<T> => ({ ok })
};
export declare type Validator<T> = (value: T) => Result<T> | Promise<Result<T>>;
export const allOf = <T>(...validators: Validator<T>[]): Validator<T> => async (value: T): Promise<Result<T>> => {
for (const validator of validators) {
const validationResult = await validator(value);
if (Result.isError(validationResult)) {
return validationResult;
}
}
return Result.ok(value);
};
export const isNotShorterThan = (minLength: number, errorText: string): Validator<string> =>
(value: string): Result<string> => {
if (value.length < minLength) {
return Result.error(errorText);
}
return Result.ok(value);
};
export const isSameAs = <T>(expectedValue: T, errorText: string): Validator<T> => (value: T): Result<T> => {
if (value !== expectedValue) {
return Result.error(errorText);
}
return Result.ok(value);
};