diff --git a/packages/extension-ui/src/Popup/CreateAccount/CreateAccount.test.tsx b/packages/extension-ui/src/Popup/CreateAccount/CreateAccount.test.tsx index c4c2a8b9..659dca96 100644 --- a/packages/extension-ui/src/Popup/CreateAccount/CreateAccount.test.tsx +++ b/packages/extension-ui/src/Popup/CreateAccount/CreateAccount.test.tsx @@ -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 => { + input.simulate('change', { target: { value } }); + await act(flushAllPromises); + wrapper.update(); + }; + + const enterName = (name: string): Promise => type(wrapper.find('input'), name); + const password = (password: string) => (): Promise => type(wrapper.find('input[type="password"]').first(), password); + const repeat = (password: string) => (): Promise => 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); + }); + }); }); diff --git a/packages/extension-ui/src/Popup/ImportSeed.tsx b/packages/extension-ui/src/Popup/ImportSeed.tsx index c1d31f4d..2b96e1e6 100644 --- a/packages/extension-ui/src/Popup/ImportSeed.tsx +++ b/packages/extension-ui/src/Popup/ImportSeed.tsx @@ -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> => { + 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 { const onAction = useContext(ActionContext); const [account, setAccount] = useState(null); const [name, setName] = useState(null); const [password, setPassword] = useState(null); - const _onChangeSeed = (suri: string): Promise => - validateSeed(suri) - .then(setAccount) - .catch((): void => setAccount(null)); + const _onChangeSeed = useCallback(async (suri: string | null): Promise => { + 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 { showBackArrow text='Import account' /> - {account && } {account && name && } diff --git a/packages/extension-ui/src/components/ValidatedInput.tsx b/packages/extension-ui/src/components/ValidatedInput.tsx new file mode 100644 index 00000000..23eda41a --- /dev/null +++ b/packages/extension-ui/src/components/ValidatedInput.tsx @@ -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 & { + className?: string; + validator: Validator; + component: React.ComponentType; + onValidatedChange: (value: string | null) => void; +} + +function ValidatedInput ({ className, component: Input, onValidatedChange, validator, ...props }: Props): React.ReactElement> { + const [value, setValue] = useState(''); + const [wasMounted, setWasMounted] = useState(false); + const [validationResult, setValidationResult] = useState>(Result.ok('')); + + useEffect(() => { + if (!wasMounted) { + setWasMounted(true); + + return; + } + + (async (): Promise => { + 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 ( +
+ + {Result.isError(validationResult) && {validationResult.error.errorDescription}} +
+ ); +} + +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; diff --git a/packages/extension-ui/src/components/index.ts b/packages/extension-ui/src/components/index.ts index 22ab0c17..8c4f00e2 100644 --- a/packages/extension-ui/src/components/index.ts +++ b/packages/extension-ui/src/components/index.ts @@ -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'; diff --git a/packages/extension-ui/src/partials/DerivationPath.tsx b/packages/extension-ui/src/partials/DerivationPath.tsx index 8daed255..0c269f7b 100644 --- a/packages/extension-ui/src/partials/DerivationPath.tsx +++ b/packages/extension-ui/src/partials/DerivationPath.tsx @@ -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 { const [path, setPath] = useState(''); - const [isValid, setValid] = useState(false); - const _onChange = useCallback( - async (newPath: string) => { - setPath(newPath); + const isPathValid = useCallback(async (path: string): Promise> => { + 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 ( -
- -
+ ); } diff --git a/packages/extension-ui/src/partials/Name.tsx b/packages/extension-ui/src/partials/Name.tsx index 26da7d97..bc7d124a 100644 --- a/packages/extension-ui/src/partials/Name.tsx +++ b/packages/extension-ui/src/partials/Name.tsx @@ -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 { +export default function Name ({ address, className, isFocused, label = 'A descriptive name for your account', onBlur, onChange }: Props): React.ReactElement { 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 ( - ); } diff --git a/packages/extension-ui/src/partials/Password.tsx b/packages/extension-ui/src/partials/Password.tsx index e3ae09c9..32dab72a 100644 --- a/packages/extension-ui/src/partials/Password.tsx +++ b/packages/extension-ui/src/partials/Password.tsx @@ -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 => allOf( + isNotShorterThan(MIN_LENGTH, 'Password is too short'), + isSameAs(firstPassword, 'Passwords do not match') +); export default function Password ({ isFocussed, onChange }: Props): React.ReactElement { - const [pass1, setPass1] = useState(''); - const [pass2, setPass2] = useState(''); + const [pass1, setPass1] = useState(null); + const [pass2, setPass2] = useState(null); useEffect((): void => { - onChange( - (pass1 && pass2 && (pass1.length >= MIN_LENGTH) && (pass1 === pass2)) - ? pass1 - : null - ); + onChange(pass1 && pass2 ? pass1 : null); }, [onChange, pass1, pass2]); return ( <> - - {(pass1.length >= MIN_LENGTH) && ( - )} diff --git a/packages/extension-ui/src/validators.ts b/packages/extension-ui/src/validators.ts new file mode 100644 index 00000000..0396612f --- /dev/null +++ b/packages/extension-ui/src/validators.ts @@ -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 = { error: InputError } | { ok: T } +export const Result = { + error: (errorDescription: string): Result => ({ error: { errorDescription } }), + isError (value: Result): value is ({ error: InputError }) { + return Object.hasOwnProperty.call(value, 'error'); + }, + isOk (value: Result): value is ({ ok: T }) { + return Object.hasOwnProperty.call(value, 'ok'); + }, + ok: (ok: T): Result => ({ ok }) +}; + +export declare type Validator = (value: T) => Result | Promise>; + +export const allOf = (...validators: Validator[]): Validator => async (value: T): Promise> => { + 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 => + (value: string): Result => { + if (value.length < minLength) { + return Result.error(errorText); + } + + return Result.ok(value); + }; + +export const isSameAs = (expectedValue: T, errorText: string): Validator => (value: T): Result => { + if (value !== expectedValue) { + return Result.error(errorText); + } + + return Result.ok(value); +};