Display accounts first-and-foremost on all creations (#426)

* Display accounts first-and-foremost on all creations

* Fix tests
This commit is contained in:
Jaco Greeff 2020-09-03 22:36:49 +02:00 committed by GitHub
parent 04d0b2700f
commit ae5d076de9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 145 additions and 97 deletions

View File

@ -14,6 +14,7 @@ Changes:
- Derived accounts uses the parent genesisHash by default (attaches to same chain)
- Make import from seed, QR & JSON options available on first-start
- Adjust popup width, allowing full display of e.g. addresses
- Always display network selection on all accounts
- Handling signing rejections (any order) transparently
- Small overall UI and use adjustments
- Latest upstream polkadot-js dependencies

View File

@ -65,7 +65,7 @@ const WINDOW_OPTS = {
top: 150,
type: 'popup',
url: chrome.extension.getURL('index.html'),
width: 480
width: 560
};
function getId (): string {

View File

@ -5,7 +5,7 @@
import { RequestAuthorizeTab } from '@polkadot/extension-base/background/types';
import { ThemeProps } from '../../types';
import React, { useContext } from 'react';
import React, { useCallback, useContext } from 'react';
import styled from 'styled-components';
import { ActionBar, ActionContext, Button, Icon, Link, Warning } from '../../components';
@ -21,14 +21,20 @@ interface Props {
function Request ({ authId, className, isFirst, request: { origin }, url }: Props): React.ReactElement<Props> {
const onAction = useContext(ActionContext);
const _onApprove = (): Promise<void> =>
approveAuthRequest(authId)
const _onApprove = useCallback(
() => approveAuthRequest(authId)
.then(() => onAction())
.catch((error: Error) => console.error(error));
const _onReject = (): Promise<void> =>
rejectAuthRequest(authId)
.catch((error: Error) => console.error(error)),
[authId, onAction]
);
const _onReject = useCallback(
() => rejectAuthRequest(authId)
.then(() => onAction())
.catch((error: Error) => console.error(error));
.catch((error: Error) => console.error(error)),
[authId, onAction]
);
return (
<div className={className}>

View File

@ -4,7 +4,7 @@
import React, { useCallback, useState } from 'react';
import { Address, BackButton, ButtonArea, NextStepButton, VerticalSpace } from '../../components';
import { BackButton, ButtonArea, NextStepButton, VerticalSpace } from '../../components';
import { Name, Password } from '../../partials';
interface Props {
@ -14,7 +14,7 @@ interface Props {
onCreate: (name: string, password: string) => void | Promise<void | boolean>;
}
function AccountName ({ address, isBusy, onBackClick, onCreate }: Props): React.ReactElement<Props> {
function AccountName ({ isBusy, onBackClick, onCreate }: Props): React.ReactElement<Props> {
const [name, setName] = useState<string | null>(null);
const [password, setPassword] = useState<string | null>(null);
@ -29,13 +29,7 @@ function AccountName ({ address, isBusy, onBackClick, onCreate }: Props): React.
isFocused
onChange={setName}
/>
{name && <Password onChange={setPassword} />}
{name && password && (
<Address
address={address}
name={name}
/>
)}
<Password onChange={setPassword} />
<VerticalSpace />
<ButtonArea>
<BackButton onClick={onBackClick} />
@ -52,4 +46,4 @@ function AccountName ({ address, isBusy, onBackClick, onCreate }: Props): React.
);
}
export default AccountName;
export default React.memo(AccountName);

View File

@ -42,7 +42,7 @@ describe('Create Account', () => {
wrapper.update();
};
const enterName = (name: string): Promise<void> => type(wrapper.find('input'), name);
const enterName = (name: string): Promise<void> => type(wrapper.find('input').first(), 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);
@ -89,20 +89,20 @@ describe('Create Account', () => {
it('only account name input is visible at first', () => {
expect(wrapper.find(InputWithLabel).find('[data-input-name]').find(Input)).toHaveLength(1);
expect(wrapper.find(InputWithLabel).find('[data-input-password]')).toHaveLength(0);
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('[data-button-action="add new root"] button').prop('disabled')).toBe(true);
});
it('input should not be highlighted as error until first interaction', () => {
expect(wrapper.find(Input).prop('withError')).toBe(false);
expect(wrapper.find(InputWithLabel).find('[data-input-name]').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-name]').find(Input).prop('withError')).toBe(true);
expect(wrapper.find('ErrorMessage').first().text()).toBe('Account name 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('[data-button-action="add new root"] button').prop('disabled')).toBe(true);
});
@ -110,12 +110,12 @@ describe('Create Account', () => {
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);
expect(wrapper.find(InputWithLabel).find('[data-input-name]').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-name]').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('[data-button-action="add new root"] button').prop('disabled')).toBe(true);

View File

@ -33,12 +33,11 @@ function Mnemonic ({ onNextStep, seed }: Props): React.ReactElement<Props> {
return (
<>
<Warning>Please write down your wallet’s mnemonic seed and keep it in a safe place. <br />
Mnemonic seed is used to restore your wallet. Keep it carefully in case you lose your assets.</Warning>
<MnemonicSeed
onCopy={_onCopy}
seed={seed}
/>
<Warning>Please write down your wallet’s mnemonic seed and keep it in a safe place. The mnemonic can be used to restore your wallet. Keep it carefully to not lose your assets.</Warning>
<VerticalSpace />
<Checkbox
checked={isMnemonicSaved}
@ -57,4 +56,4 @@ function Mnemonic ({ onNextStep, seed }: Props): React.ReactElement<Props> {
);
}
export default Mnemonic;
export default React.memo(Mnemonic);

View File

@ -3,7 +3,7 @@
// of the Apache-2.0 license. See the LICENSE file for details.
import React, { useCallback, useContext, useEffect, useState } from 'react';
import { ActionContext, Loading } from '../../components';
import { ActionContext, Address, Loading } from '../../components';
import { createAccountSuri, createSeed } from '../../messaging';
import { HeaderWithSteps } from '../../partials';
import AccountName from './AccountName';
@ -27,6 +27,7 @@ export default function CreateAccount (): React.ReactElement {
// this should always be the case
if (name && password && account) {
setIsBusy(true);
createAccountSuri(name, password, account.seed)
.then(() => onAction('/'))
.catch((error: Error): void => {
@ -47,23 +48,31 @@ export default function CreateAccount (): React.ReactElement {
step={step}
text='Create an account:&nbsp;'
/>
<Loading>{account && (
step === 1
? (
<Mnemonic
onNextStep={_onNextStep}
seed={account.seed}
/>
)
: (
<AccountName
address={account.address}
isBusy={isBusy}
onBackClick={_onPreviousStep}
onCreate={_onCreate}
/>
)
)}</Loading>
<Loading>
<div>
<Address
address={account?.address}
name={name}
/>
</div>
{account && (
step === 1
? (
<Mnemonic
onNextStep={_onNextStep}
seed={account.seed}
/>
)
: (
<AccountName
address={account.address}
isBusy={isBusy}
onBackClick={_onPreviousStep}
onCreate={_onCreate}
/>
)
)}
</Loading>
</>
);
}

View File

@ -12,13 +12,14 @@ import { Address, Svg } from '../../components';
import useOutsideClick from '../../hooks/useOutsideClick';
interface Props {
allAddresses: string[];
allAddresses: [string, string | null][];
className?: string;
onSelect: (address: string) => void;
selectedAddress: string;
selectedGenesis: string | null;
}
function AddressDropdown ({ allAddresses, className, onSelect, selectedAddress }: Props): React.ReactElement<Props> {
function AddressDropdown ({ allAddresses, className, onSelect, selectedAddress, selectedGenesis }: Props): React.ReactElement<Props> {
const [isDropdownVisible, setDropdownVisible] = useState(false);
const ref = useRef<HTMLDivElement>(null);
@ -34,16 +35,22 @@ function AddressDropdown ({ allAddresses, className, onSelect, selectedAddress }
onClick={_toggleDropdown}
ref={ref}
>
<Address address={selectedAddress} />
<Address
address={selectedAddress}
genesisHash={selectedGenesis}
/>
</div>
<HiddenOptions visible={isDropdownVisible}>
{allAddresses.map((address) => (
{allAddresses.map(([address, genesisHash]) => (
<div
data-parent-option
key={address}
onClick={_selectParent(address)}
>
<Address address={address} />
<Address
address={address}
genesisHash={genesisHash}
/>
</div>
))}
</HiddenOptions>
@ -54,7 +61,7 @@ function AddressDropdown ({ allAddresses, className, onSelect, selectedAddress }
const HiddenOptions = styled.div<{ visible: boolean }>`
position: absolute;
visibility: ${({ visible }): string => (visible ? 'visible' : 'hidden')};
width: 430px;
width: 510px;
z-index: 100;
background: ${({ theme }: ThemeProps): string => theme.bodyColor};
max-height: ${({ visible }): string => (visible ? '200px' : '0')};
@ -77,7 +84,7 @@ export default styled(AddressDropdown)(({ theme }: ThemeProps) => `
& > div:first-child > ${Address}::after {
content: '';
position: absolute;
top: 50%;
top: 66%;
transform: translateY(-50%);
right: 11px;
width: 30px;

View File

@ -2,7 +2,7 @@
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import React, { useCallback, useContext, useEffect, useRef, useState } from 'react';
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import styled from 'styled-components';
import { assert } from '@polkadot/util';
@ -15,10 +15,11 @@ import DerivationPath from './DerivationPath';
interface Props {
isLocked?: boolean;
parentAddress: string;
parentGenesis: string | null;
onDerivationConfirmed: (derivation: { account: { address: string; suri: string }; parentPassword: string }) => void;
}
export function SelectParent ({ isLocked, onDerivationConfirmed, parentAddress }: Props): React.ReactElement<Props> {
export function SelectParent ({ isLocked, onDerivationConfirmed, parentAddress, parentGenesis }: Props): React.ReactElement<Props> {
const onAction = useContext(ActionContext);
const [isBusy, setIsBusy] = useState(false);
const { accounts, hierarchy } = useContext(AccountContext);
@ -30,6 +31,13 @@ export function SelectParent ({ isLocked, onDerivationConfirmed, parentAddress }
const passwordInputRef = useRef<HTMLDivElement>(null);
const allAddresses = useMemo(
() => hierarchy
.filter(({ isExternal }) => !isExternal)
.map(({ address, genesisHash }): [string, string | null] => [address, genesisHash || null]),
[hierarchy]
);
const _goCreate = useCallback(
() => onAction('/account/create'),
[onAction]
@ -91,13 +99,19 @@ export function SelectParent ({ isLocked, onDerivationConfirmed, parentAddress }
)}
<DisableableArea isDisabled={!shouldAccountBeDerived}>
{isLocked
? <Address address={parentAddress} />
? (
<Address
address={parentAddress}
genesisHash={parentGenesis}
/>
)
: (
<Label label='Choose Parent Account:'>
<AddressDropdown
allAddresses={hierarchy.filter(({ isExternal }) => !isExternal).map(({ address }) => address)}
allAddresses={allAddresses}
onSelect={_onParentChange}
selectedAddress={parentAddress}
selectedGenesis={parentGenesis}
/>
</Label>
)

View File

@ -2,7 +2,7 @@
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import React, { useCallback, useContext, useState } from 'react';
import React, { useCallback, useContext, useMemo, useState } from 'react';
import { useParams } from 'react-router';
import { AccountContext, ActionContext, Address, BackButton, ButtonArea, NextStepButton, VerticalSpace } from '../../components';
@ -37,22 +37,24 @@ function Derive ({ isLocked }: Props): React.ReactElement<Props> {
const [password, setPassword] = useState<string | null>(null);
const [parentPassword, setParentPassword] = useState<string | null>(null);
const parentGenesis = useMemo(
() => accounts.find((a) => a.address === parentAddress)?.genesisHash || null,
[accounts, parentAddress]
);
const _onCreate = useCallback(() => {
if (!account || !name || !password || !parentPassword) {
return;
}
const parentAccount = accounts.find((a) => a.address === parentAddress);
const genesisHash = parentAccount?.genesisHash || null;
setIsBusy(true);
deriveAccount(parentAddress, account.suri, parentPassword, name, password, genesisHash)
deriveAccount(parentAddress, account.suri, parentPassword, name, password, parentGenesis)
.then(() => onAction('/'))
.catch((error): void => {
setIsBusy(false);
console.error(error);
});
}, [account, accounts, name, password, onAction, parentAddress, parentPassword]);
}, [account, name, password, onAction, parentAddress, parentGenesis, parentPassword]);
const _onDerivationConfirmed = useCallback(({ account, parentPassword }: ConfirmState) => {
setAccount(account);
@ -74,23 +76,23 @@ function Derive ({ isLocked }: Props): React.ReactElement<Props> {
isLocked={isLocked}
onDerivationConfirmed={_onDerivationConfirmed}
parentAddress={parentAddress}
/>
)}
{account && (
<Name
isFocused
onChange={setName}
/>
)}
{account && name && <Password onChange={setPassword}/>}
{account && name && password && (
<Address
address={account.address}
name={name}
parentGenesis={parentGenesis}
/>
)}
{account && (
<>
<div>
<Address
address={account.address}
genesisHash={parentGenesis}
name={name}
/>
</div>
<Name
isFocused
onChange={setName}
/>
<Password onChange={setPassword} />
<VerticalSpace/>
<ButtonArea>
<BackButton onClick={_onBackClick}/>
@ -108,4 +110,4 @@ function Derive ({ isLocked }: Props): React.ReactElement<Props> {
);
}
export default Derive;
export default React.memo(Derive);

View File

@ -36,14 +36,16 @@ export default function ImportQr (): React.ReactElement {
)}
{account && (
<>
<div>
<Address
{...account}
name={name}
/>
</div>
<Name
isFocused
onChange={setName}
/>
<Address
{...account}
name={name}
/>
<VerticalSpace />
{name && (
<ButtonArea>

View File

@ -40,10 +40,14 @@ export default function Import (): React.ReactElement {
const _onChangeSeed = useCallback(
async (suri: string | null): Promise<void> => {
if (suri) {
setAccount(await validateSeed(suri));
} else {
setAccount(null);
try {
return setAccount(await validateSeed(suri));
} catch (error) {
console.error(error);
}
}
setAccount(null);
},
[]
);
@ -69,6 +73,12 @@ export default function Import (): React.ReactElement {
showBackArrow
text='Import account'
/>
<div>
<Address
address={account?.address}
name={name}
/>
</div>
<ValidatedInput
// eslint-disable-next-line @typescript-eslint/no-use-before-define
component={SeedInput}
@ -78,14 +88,14 @@ export default function Import (): React.ReactElement {
rowsCount={2}
validator={isSeedValid}
/>
{account && <Name onChange={setName} />}
{account && name && <Password onChange={setPassword} />}
{account && (
<>
<Name onChange={setName} />
<Password onChange={setPassword} />
</>
)}
{account && name && password && (
<>
<Address
address={account.address}
name={name}
/>
<VerticalSpace />
<ButtonArea>
<NextStepButton

View File

@ -96,10 +96,13 @@ export default function Upload (): React.ReactElement {
text='Restore from JSON'
/>
<div>
<Address
address={(isFileValid && address) || null}
name={(isFileValid && json?.meta.name as string) || null}
/>
<div>
<Address
address={(isFileValid && address) || null}
genesisHash={(isFileValid && json?.meta.genesisHash as string) || null}
name={(isFileValid && json?.meta.name as string) || null}
/>
</div>
<InputFileWithLabel
accept={acceptedFormats}
isError={!isFileValid}

View File

@ -48,6 +48,7 @@ export default function Request ({ account: { isExternal }, buttonText, isFirst,
useEffect((): void => {
setIsLocked(null);
!isExternal && isSignLocked(signId)
.then((isLocked) => setIsLocked(isLocked))
.catch((error: Error) => console.error(error));

View File

@ -40,7 +40,7 @@ function MnemonicSeed ({ className, onCopy, seed }: Props): React.ReactElement<P
}
export default styled(MnemonicSeed)(({ theme }: ThemeProps) => `
margin-top: 21px;
margin-bottom: 21px;
.buttonsRow {
display: flex;

View File

@ -5,7 +5,7 @@
<title>polkadot{.js}</title>
</head>
<body style="height: 600px; margin:0; overflow-x:hidden; text-align:center">
<div id="root" style="box-sizing:border-box; margin:0 auto; padding:0; text-align:left; width:570px; height: calc(100% - 2px); max-width: 100%;"></div>
<div id="root" style="box-sizing:border-box; margin:0 auto; padding:0; text-align:left; width:560px; height: calc(100% - 2px); max-width: 100%;"></div>
<script src='./extension.js'></script>
</body>
</html>