Show labels of account parent (#306)

* Show derived accounts indentation

* Add parent labels

* Update branch

* styling

* CR requests

* Slightly reword derive documentation

* Remove derivation path from account metadata
This commit is contained in:
Ivan Rukhavets 2020-04-03 11:45:30 +03:00 committed by GitHub
parent 5e13f5e57f
commit 53d3ebce37
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 173 additions and 43 deletions

View File

@ -151,7 +151,7 @@ That is, hard-derivation paths are prefixed with `//`, soft paths with `/`, and
The extension will still ask you to enter a password for this account. As before, this password only encrypts the private key on disk. It is not required to be the same password as the one that encrypts the mnemonic phrase. The extension will still ask you to enter a password for this account. As before, this password only encrypts the private key on disk. It is not required to be the same password as the one that encrypts the mnemonic phrase.
Accounts can also be derived from existing accounts – `Derive New Account` option in account's dropdown menu should be selected. After providing the password of the derived account, along with name and password of the new account, enter derivation path in the following format: Accounts can also be derived from existing accounts – `Derive New Account` option in account's dropdown menu should be selected. After providing the password of the parent account, along with name and password of the derived account, enter derivation path in the following format:
``` ```
//<hard>/<soft> //<hard>/<soft>

View File

@ -303,7 +303,11 @@ export default class Extension {
} }
private derivationCreate ({ genesisHash, name, parentAddress, parentPassword, password, suri }: RequestDeriveCreate): boolean { private derivationCreate ({ genesisHash, name, parentAddress, parentPassword, password, suri }: RequestDeriveCreate): boolean {
const childPair = this.derive(parentAddress, suri, parentPassword, { genesisHash, name, parentAddress }); const childPair = this.derive(parentAddress, suri, parentPassword, {
genesisHash,
name,
parentAddress
});
keyring.addPair(childPair, password); keyring.addPair(childPair, password);

View File

@ -5,7 +5,7 @@
import { InjectedAccount, MetadataDef, ProviderList, ProviderMeta, InjectedMetadataKnown } from '@polkadot/extension-inject/types'; import { InjectedAccount, MetadataDef, ProviderList, ProviderMeta, InjectedMetadataKnown } from '@polkadot/extension-inject/types';
import { JsonRpcResponse } from '@polkadot/rpc-provider/types'; import { JsonRpcResponse } from '@polkadot/rpc-provider/types';
import { KeypairType } from '@polkadot/util-crypto/types'; import { KeypairType } from '@polkadot/util-crypto/types';
import { KeyringPair } from '@polkadot/keyring/types'; import { KeyringPair, KeyringPair$Meta } from '@polkadot/keyring/types';
import { SignerPayloadJSON, SignerPayloadRaw } from '@polkadot/types/types'; import { SignerPayloadJSON, SignerPayloadRaw } from '@polkadot/types/types';
import { TypeRegistry } from '@polkadot/types'; import { TypeRegistry } from '@polkadot/types';
@ -23,11 +23,21 @@ type NullKeys<T> = { [K in keyof T]: IsNull<T, K> }[keyof T];
export type SeedLengths = 12 | 24; export type SeedLengths = 12 | 24;
export interface AccountJson { export interface AccountJson extends KeyringPair$Meta {
address: string; address: string;
genesisHash?: string | null; genesisHash?: string | null;
isExternal?: boolean; isExternal?: boolean;
name?: string; name?: string;
parentAddress?: string;
}
export type AccountWithChildren = AccountJson & {
children?: AccountWithChildren[];
}
export type AccountsContext = {
accounts: AccountJson[];
hierarchy: AccountWithChildren[];
} }
export interface AuthorizeRequest { export interface AuthorizeRequest {

View File

@ -13,10 +13,11 @@ import { Name } from '../../partials';
interface Props extends AccountJson { interface Props extends AccountJson {
address: string; address: string;
parentName?: string;
className?: string; className?: string;
} }
function Account ({ address, className, isExternal }: Props): React.ReactElement<Props> { function Account ({ address, className, isExternal, parentName }: Props): React.ReactElement<Props> {
const onAction = useContext(ActionContext); const onAction = useContext(ActionContext);
const [isEditing, setEditing] = useState(false); const [isEditing, setEditing] = useState(false);
const [editedName, setName] = useState<string | null>(null); const [editedName, setName] = useState<string | null>(null);
@ -61,6 +62,7 @@ function Account ({ address, className, isExternal }: Props): React.ReactElement
)} )}
address={address} address={address}
name={editedName} name={editedName}
parentName={parentName}
> >
{isEditing && ( {isEditing && (
<Name <Name

View File

@ -0,0 +1,27 @@
import React from 'react';
import { AccountWithChildren } from '@polkadot/extension-base/background/types';
import Account from './Account';
interface Props extends AccountWithChildren {
parentName?: string;
}
function AccountsTree ({ parentName, ...account }: Props): React.ReactElement<Props> {
return (
<>
<Account
{...account}
parentName={parentName}
/>
{account?.children?.map((child, index) => (
<AccountsTree
key={`${index}:${child.address}`}
{...child}
parentName={account.name}
/>
))}
</>
);
}
export default AccountsTree;

View File

@ -3,21 +3,21 @@
// of the Apache-2.0 license. See the LICENSE file for details. // of the Apache-2.0 license. See the LICENSE file for details.
import React, { useContext } from 'react'; import React, { useContext } from 'react';
import QrImage from '../../assets/qr.svg';
import { AccountContext, Button, Header, MediaContext, AddAccount, ButtonArea, Svg, ButtonWithSubtitle } from '../../components';
import Account from './Account';
import styled from 'styled-components'; import styled from 'styled-components';
import QrImage from '../../assets/qr.svg';
import AccountsTree from './AccountsTree';
import { AccountContext, Button, Header, MediaContext, AddAccount, ButtonArea, Svg, ButtonWithSubtitle } from '../../components';
type Props = {}; type Props = {};
export default function Accounts (): React.ReactElement<Props> { export default function Accounts (): React.ReactElement<Props> {
const accounts = useContext(AccountContext); const { hierarchy } = useContext(AccountContext);
const mediaAllowed = useContext(MediaContext); const mediaAllowed = useContext(MediaContext);
return ( return (
<> <>
{(accounts.length === 0) {(hierarchy.length === 0)
? <AddAccount /> ? <AddAccount />
: ( : (
<> <>
@ -26,8 +26,8 @@ export default function Accounts (): React.ReactElement<Props> {
text={'Accounts'} text={'Accounts'}
/> />
<AccountsArea> <AccountsArea>
{accounts.map((json, index): React.ReactNode => ( {hierarchy.map((json, index): React.ReactNode => (
<Account <AccountsTree
{...json} {...json}
key={`${index}:${json.address}`} key={`${index}:${json.address}`}
/> />

View File

@ -8,9 +8,9 @@ import Adapter from 'enzyme-adapter-react-16';
import { configure, mount, ReactWrapper } from 'enzyme'; import { configure, mount, ReactWrapper } from 'enzyme';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import * as messaging from '@polkadot/extension-ui/messaging'; import * as messaging from '@polkadot/extension-ui/messaging';
import { AccountJson, SigningRequest } from '@polkadot/extension-base/background/types'; import { SigningRequest } from '@polkadot/extension-base/background/types';
import { flushAllPromises } from '@polkadot/extension-ui/testHelpers'; import { flushAllPromises } from '@polkadot/extension-ui/testHelpers';
import { AccountContext, ActionContext, Address, Button, Input, SigningReqContext, themes } from '@polkadot/extension-ui/components'; import { ActionContext, Address, Button, Input, SigningReqContext, themes } from '@polkadot/extension-ui/components';
import { ThemeProvider } from 'styled-components'; import { ThemeProvider } from 'styled-components';
import Signing from '.'; import Signing from '.';
import TransactionIndex from './TransactionIndex'; import TransactionIndex from './TransactionIndex';
@ -25,17 +25,6 @@ describe.skip('Signing requests', () => {
let wrapper: ReactWrapper; let wrapper: ReactWrapper;
let onActionStub: jest.Mock; let onActionStub: jest.Mock;
let signRequests: SigningRequest[] = []; let signRequests: SigningRequest[] = [];
const accounts: AccountJson[] = [{
address: '5D4bqjQRPgdMBK8bNvhX4tSuCtSGZS7rZjD5XH5SoKcFeKn5',
genesisHash: '0xdcd1346701ca8396496e52aa2785b1748deb6db09551b72159dcb3e08991025b',
isExternal: false,
name: 'acc1'
}, {
address: '5E9nq1yGJJFiP8C75ryD9J2R62q2cesz6NumLnuXRgmuN5DG',
genesisHash: '0xdcd1346701ca8396496e52aa2785b1748deb6db09551b72159dcb3e08991025b',
isExternal: false,
name: 'acc2'
}];
const emitter = new EventEmitter(); const emitter = new EventEmitter();
@ -54,11 +43,9 @@ describe.skip('Signing requests', () => {
const mountComponent = async (): Promise<void> => { const mountComponent = async (): Promise<void> => {
wrapper = mount( wrapper = mount(
<ActionContext.Provider value={onActionStub}> <ActionContext.Provider value={onActionStub}>
<AccountContext.Provider value={accounts}>
<ThemeProvider theme={themes.dark}> <ThemeProvider theme={themes.dark}>
<MockRequestsProvider /> <MockRequestsProvider />
</ThemeProvider> </ThemeProvider>
</AccountContext.Provider>
</ActionContext.Provider> </ActionContext.Provider>
); );
await act(flushAllPromises); await act(flushAllPromises);

View File

@ -13,6 +13,7 @@ import { Loading } from '../components';
import { AccountContext, ActionContext, AuthorizeReqContext, MediaContext, MetadataReqContext, SigningReqContext } from '../components/contexts'; import { AccountContext, ActionContext, AuthorizeReqContext, MediaContext, MetadataReqContext, SigningReqContext } from '../components/contexts';
import ToastProvider from '../components/Toast/ToastProvider'; import ToastProvider from '../components/Toast/ToastProvider';
import { subscribeAccounts, subscribeAuthorizeRequests, subscribeMetadataRequests, subscribeSigningRequests } from '../messaging'; import { subscribeAccounts, subscribeAuthorizeRequests, subscribeMetadataRequests, subscribeSigningRequests } from '../messaging';
import { buildHierarchy } from '../utils/buildHierarchy';
import Accounts from './Accounts'; import Accounts from './Accounts';
import Authorize from './Authorize'; import Authorize from './Authorize';
import CreateAccount from './CreateAccount'; import CreateAccount from './CreateAccount';
@ -96,7 +97,7 @@ export default function Popup (): React.ReactElement<{}> {
return ( return (
<Loading>{accounts && authRequests && metaRequests && signRequests && ( <Loading>{accounts && authRequests && metaRequests && signRequests && (
<ActionContext.Provider value={_onAction}> <ActionContext.Provider value={_onAction}>
<AccountContext.Provider value={accounts}> <AccountContext.Provider value={{ accounts, hierarchy: buildHierarchy(accounts) }}>
<AuthorizeReqContext.Provider value={authRequests}> <AuthorizeReqContext.Provider value={authRequests}>
<MediaContext.Provider value={cameraOn && mediaAllowed}> <MediaContext.Provider value={cameraOn && mediaAllowed}>
<MetadataReqContext.Provider value={metaRequests}> <MetadataReqContext.Provider value={metaRequests}>

View File

@ -2,7 +2,7 @@
// This software may be modified and distributed under the terms // This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details. // of the Apache-2.0 license. See the LICENSE file for details.
import { AccountJson } from '@polkadot/extension-base/background/types'; import { AccountJson, AccountWithChildren } from '@polkadot/extension-base/background/types';
import { Chain } from '@polkadot/extension-chains/types'; import { Chain } from '@polkadot/extension-chains/types';
import React, { useCallback, useContext, useEffect, useRef, useState } from 'react'; import React, { useCallback, useContext, useEffect, useRef, useState } from 'react';
@ -29,6 +29,7 @@ interface Props {
children?: React.ReactNode; children?: React.ReactNode;
genesisHash?: string | null; genesisHash?: string | null;
actions?: React.ReactNode; actions?: React.ReactNode;
parentName?: string;
} }
interface Recoded { interface Recoded {
@ -47,7 +48,7 @@ function findAccount (accounts: AccountJson[], publicKey: Uint8Array): AccountJs
} }
// recodes an supplied address using the prefix/genesisHash, include the actual saved account & chain // recodes an supplied address using the prefix/genesisHash, include the actual saved account & chain
function recodeAddress (address: string, accounts: AccountJson[], chain: Chain | null): Recoded { function recodeAddress (address: string, accounts: AccountWithChildren[], chain: Chain | null): Recoded {
// decode and create a shortcut for the encoded address // decode and create a shortcut for the encoded address
const publicKey = decodeAddress(address); const publicKey = decodeAddress(address);
@ -65,8 +66,8 @@ function recodeAddress (address: string, accounts: AccountJson[], chain: Chain |
const ACCOUNTS_SCREEN_HEIGHT = 500; const ACCOUNTS_SCREEN_HEIGHT = 500;
function Address ({ actions, address, children, className, genesisHash, name }: Props): React.ReactElement<Props> { function Address ({ actions, address, children, className, genesisHash, name, parentName }: Props): React.ReactElement<Props> {
const accounts = useContext(AccountContext); const { accounts } = useContext(AccountContext);
const chain = useMetadata(genesisHash); const chain = useMetadata(genesisHash);
const [{ account, formatted, prefix }, setRecoded] = useState<Recoded>({ account: null, formatted: null, prefix: 42 }); const [{ account, formatted, prefix }, setRecoded] = useState<Recoded>({ account: null, formatted: null, prefix: 42 });
const [showActionsMenu, setShowActionsMenu] = useState(false); const [showActionsMenu, setShowActionsMenu] = useState(false);
@ -111,9 +112,8 @@ function Address ({ actions, address, children, className, genesisHash, name }:
<Name>{name || (account && account.name) || '<unknown>'}</Name> <Name>{name || (account && account.name) || '<unknown>'}</Name>
<CopyAddress> <CopyAddress>
<FullAddress>{formatted || '<unknown>'}</FullAddress> <FullAddress>{formatted || '<unknown>'}</FullAddress>
{chain?.genesisHash && ( {chain?.genesisHash && <ChainBanner>{chain.name}</ChainBanner>}
<Banner>{chain.name}</Banner> {parentName && <ParentBanner>↳ {parentName}</ParentBanner>}
)}
<CopyToClipboard text={(formatted && formatted) || ''} > <CopyToClipboard text={(formatted && formatted) || ''} >
<Svg <Svg
onClick={_onCopy} onClick={_onCopy}
@ -241,7 +241,6 @@ const ActiveActionsIcon = styled(Svg).attrs(() => ({
`; `;
const Banner = styled.div` const Banner = styled.div`
background: ${({ theme }): string => theme.primaryColor};
border-radius: 0 0 8px 8px; border-radius: 0 0 8px 8px;
color: white; color: white;
font-size: 12px; font-size: 12px;
@ -252,6 +251,14 @@ const Banner = styled.div`
top: 0; top: 0;
`; `;
const ChainBanner = styled(Banner)`
background: ${({ theme }): string => theme.primaryColor};
`;
const ParentBanner = styled(Banner)`
background: ${({ theme }): string => theme.parentLabelColor};
`;
const MovableMenu = styled(Menu) <{ isMoved: boolean }>` const MovableMenu = styled(Menu) <{ isMoved: boolean }>`
${({ isMoved }): string => isMoved ? 'bottom: 50px' : ''}; ${({ isMoved }): string => isMoved ? 'bottom: 50px' : ''};
`; `;

View File

@ -2,7 +2,7 @@
// This software may be modified and distributed under the terms // This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details. // of the Apache-2.0 license. See the LICENSE file for details.
import { AccountJson, AuthorizeRequest, MetadataRequest, SigningRequest } from '@polkadot/extension-base/background/types'; import { AccountsContext, AuthorizeRequest, MetadataRequest, SigningRequest } from '@polkadot/extension-base/background/types';
import React from 'react'; import React from 'react';
import { AvailableThemes } from '.'; import { AvailableThemes } from '.';
@ -10,7 +10,10 @@ import { AvailableThemes } from '.';
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const noop = (): void => undefined; const noop = (): void => undefined;
const AccountContext = React.createContext<AccountJson[]>([]); const AccountContext = React.createContext<AccountsContext>({
accounts: [],
hierarchy: []
});
const ActionContext = React.createContext<(to?: string) => void>(noop); const ActionContext = React.createContext<(to?: string) => void>(noop);
const AuthorizeReqContext = React.createContext<AuthorizeRequest[]>([]); const AuthorizeReqContext = React.createContext<AuthorizeRequest[]>([]);
const MediaContext = React.createContext<boolean>(false); const MediaContext = React.createContext<boolean>(false);

View File

@ -34,6 +34,7 @@ const darkTheme = {
labelFontSize: '13px', labelFontSize: '13px',
labelLineHeight: '18px', labelLineHeight: '18px',
lineHeight: '26px', lineHeight: '26px',
parentLabelColor: '#4A7463',
popupBackground: '#38393F', popupBackground: '#38393F',
primaryColor: '#FF7D01', primaryColor: '#FF7D01',
readonlyInputBackground: '#1A1B20', readonlyInputBackground: '#1A1B20',
@ -59,6 +60,7 @@ const lightTheme: Theme = {
inputBackground: '#FFFFFF', inputBackground: '#FFFFFF',
inputBorderColor: '#DDE1EB', inputBorderColor: '#DDE1EB',
labelColor: '#333333', labelColor: '#333333',
parentLabelColor: '#215B4F',
popupBackground: '#FFFFFF', popupBackground: '#FFFFFF',
readonlyInputBackground: '#FFF', readonlyInputBackground: '#FFF',
subTextColor: '#454545', subTextColor: '#454545',

View File

@ -19,7 +19,7 @@ interface Props {
const isNameValid = isNotShorterThan(3, 'Account name is too short'); const isNameValid = isNotShorterThan(3, 'Account name is too short');
export default function Name ({ address, className, 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 { accounts } = useContext(AccountContext);
const account = accounts.find((account): boolean => account.address === address); const account = accounts.find((account): boolean => account.address === address);
const startValue = account && account.name; const startValue = account && account.name;

View File

@ -0,0 +1,59 @@
// Copyright 2019-2020 @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 { AccountJson, AccountWithChildren } from '@polkadot/extension-base/background/types';
import { buildHierarchy } from './buildHierarchy';
const testHierarchy = (accounts: AccountJson[], expected: AccountWithChildren[]): void => {
expect(buildHierarchy(accounts)).toEqual(expected);
};
describe('Use Account Hierarchy', () => {
const acc = (address: string, parentAddress?: string, derivationPath?: string): {
address: string;
derivationPath?: string;
parentAddress?: string;
} => ({
address,
derivationPath,
parentAddress
});
test('for empty account list, returns empty list', () => {
testHierarchy([], []);
});
test('returns one account', () => {
testHierarchy([acc('a')], [acc('a')]);
});
test('puts child account into children field of parent: single child', () => {
testHierarchy([acc('a'), acc('b', 'a')], [
{ address: 'a', children: [acc('b', 'a')] }
]);
});
test('puts child account into children field of parent: more children', () => {
testHierarchy([acc('a'), acc('b', 'a'), acc('c', 'a')], [
{ address: 'a', children: [acc('b', 'a'), acc('c', 'a')] }
]);
});
test('puts child account into children field of parent: 2 roots', () => {
testHierarchy([acc('a'), acc('b', 'a'), acc('c', 'a'), acc('d')], [
{ address: 'a', children: [acc('b', 'a'), acc('c', 'a')] },
acc('d')
]);
});
test('handles grandchildren', () => {
testHierarchy([acc('a'), acc('b', 'a'), acc('c', 'b')], [{
address: 'a',
children: [{
...acc('b', 'a'),
children: [acc('c', 'b')]
}]
}]);
});
});

View File

@ -0,0 +1,28 @@
// Copyright 2019-2020 @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 { AccountJson, AccountWithChildren } from '@polkadot/extension-base/background/types';
const isRoot = (account: AccountJson): boolean => !account.parentAddress;
const isChild = (parent: AccountJson) => (account: AccountJson): boolean => account.parentAddress === parent.address;
export const accountWithChildren = (allAccounts: AccountJson[]) => (account: AccountJson): AccountWithChildren => {
const children = allAccounts
.filter(isChild(account))
.map(accountWithChildren(allAccounts));
if (children.length === 0) {
return account;
}
return {
...account,
children
};
};
export const buildHierarchy = (accounts: AccountJson[]): AccountWithChildren[] =>
accounts
.filter(isRoot)
.map(accountWithChildren(accounts));