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 10:45:30 +02:00
committed by GitHub
parent 5e13f5e57f
commit 53d3ebce37
14 changed files with 173 additions and 43 deletions
@@ -303,7 +303,11 @@ export default class Extension {
}
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);
@@ -5,7 +5,7 @@
import { InjectedAccount, MetadataDef, ProviderList, ProviderMeta, InjectedMetadataKnown } from '@polkadot/extension-inject/types';
import { JsonRpcResponse } from '@polkadot/rpc-provider/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 { 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 interface AccountJson {
export interface AccountJson extends KeyringPair$Meta {
address: string;
genesisHash?: string | null;
isExternal?: boolean;
name?: string;
parentAddress?: string;
}
export type AccountWithChildren = AccountJson & {
children?: AccountWithChildren[];
}
export type AccountsContext = {
accounts: AccountJson[];
hierarchy: AccountWithChildren[];
}
export interface AuthorizeRequest {
@@ -13,10 +13,11 @@ import { Name } from '../../partials';
interface Props extends AccountJson {
address: string;
parentName?: 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 [isEditing, setEditing] = useState(false);
const [editedName, setName] = useState<string | null>(null);
@@ -61,6 +62,7 @@ function Account ({ address, className, isExternal }: Props): React.ReactElement
)}
address={address}
name={editedName}
parentName={parentName}
>
{isEditing && (
<Name
@@ -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;
@@ -3,21 +3,21 @@
// of the Apache-2.0 license. See the LICENSE file for details.
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 QrImage from '../../assets/qr.svg';
import AccountsTree from './AccountsTree';
import { AccountContext, Button, Header, MediaContext, AddAccount, ButtonArea, Svg, ButtonWithSubtitle } from '../../components';
type Props = {};
export default function Accounts (): React.ReactElement<Props> {
const accounts = useContext(AccountContext);
const { hierarchy } = useContext(AccountContext);
const mediaAllowed = useContext(MediaContext);
return (
<>
{(accounts.length === 0)
{(hierarchy.length === 0)
? <AddAccount />
: (
<>
@@ -26,8 +26,8 @@ export default function Accounts (): React.ReactElement<Props> {
text={'Accounts'}
/>
<AccountsArea>
{accounts.map((json, index): React.ReactNode => (
<Account
{hierarchy.map((json, index): React.ReactNode => (
<AccountsTree
{...json}
key={`${index}:${json.address}`}
/>
@@ -8,9 +8,9 @@ import Adapter from 'enzyme-adapter-react-16';
import { configure, mount, ReactWrapper } from 'enzyme';
import { act } from 'react-dom/test-utils';
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 { 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 Signing from '.';
import TransactionIndex from './TransactionIndex';
@@ -25,17 +25,6 @@ describe.skip('Signing requests', () => {
let wrapper: ReactWrapper;
let onActionStub: jest.Mock;
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();
@@ -54,11 +43,9 @@ describe.skip('Signing requests', () => {
const mountComponent = async (): Promise<void> => {
wrapper = mount(
<ActionContext.Provider value={onActionStub}>
<AccountContext.Provider value={accounts}>
<ThemeProvider theme={themes.dark}>
<MockRequestsProvider />
</ThemeProvider>
</AccountContext.Provider>
<ThemeProvider theme={themes.dark}>
<MockRequestsProvider />
</ThemeProvider>
</ActionContext.Provider>
);
await act(flushAllPromises);
+2 -1
View File
@@ -13,6 +13,7 @@ import { Loading } from '../components';
import { AccountContext, ActionContext, AuthorizeReqContext, MediaContext, MetadataReqContext, SigningReqContext } from '../components/contexts';
import ToastProvider from '../components/Toast/ToastProvider';
import { subscribeAccounts, subscribeAuthorizeRequests, subscribeMetadataRequests, subscribeSigningRequests } from '../messaging';
import { buildHierarchy } from '../utils/buildHierarchy';
import Accounts from './Accounts';
import Authorize from './Authorize';
import CreateAccount from './CreateAccount';
@@ -96,7 +97,7 @@ export default function Popup (): React.ReactElement<{}> {
return (
<Loading>{accounts && authRequests && metaRequests && signRequests && (
<ActionContext.Provider value={_onAction}>
<AccountContext.Provider value={accounts}>
<AccountContext.Provider value={{ accounts, hierarchy: buildHierarchy(accounts) }}>
<AuthorizeReqContext.Provider value={authRequests}>
<MediaContext.Provider value={cameraOn && mediaAllowed}>
<MetadataReqContext.Provider value={metaRequests}>
@@ -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 { AccountJson } from '@polkadot/extension-base/background/types';
import { AccountJson, AccountWithChildren } from '@polkadot/extension-base/background/types';
import { Chain } from '@polkadot/extension-chains/types';
import React, { useCallback, useContext, useEffect, useRef, useState } from 'react';
@@ -29,6 +29,7 @@ interface Props {
children?: React.ReactNode;
genesisHash?: string | null;
actions?: React.ReactNode;
parentName?: string;
}
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
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
const publicKey = decodeAddress(address);
@@ -65,8 +66,8 @@ function recodeAddress (address: string, accounts: AccountJson[], chain: Chain |
const ACCOUNTS_SCREEN_HEIGHT = 500;
function Address ({ actions, address, children, className, genesisHash, name }: Props): React.ReactElement<Props> {
const accounts = useContext(AccountContext);
function Address ({ actions, address, children, className, genesisHash, name, parentName }: Props): React.ReactElement<Props> {
const { accounts } = useContext(AccountContext);
const chain = useMetadata(genesisHash);
const [{ account, formatted, prefix }, setRecoded] = useState<Recoded>({ account: null, formatted: null, prefix: 42 });
const [showActionsMenu, setShowActionsMenu] = useState(false);
@@ -111,9 +112,8 @@ function Address ({ actions, address, children, className, genesisHash, name }:
<Name>{name || (account && account.name) || '<unknown>'}</Name>
<CopyAddress>
<FullAddress>{formatted || '<unknown>'}</FullAddress>
{chain?.genesisHash && (
<Banner>{chain.name}</Banner>
)}
{chain?.genesisHash && <ChainBanner>{chain.name}</ChainBanner>}
{parentName && <ParentBanner>↳ {parentName}</ParentBanner>}
<CopyToClipboard text={(formatted && formatted) || ''} >
<Svg
onClick={_onCopy}
@@ -241,7 +241,6 @@ const ActiveActionsIcon = styled(Svg).attrs(() => ({
`;
const Banner = styled.div`
background: ${({ theme }): string => theme.primaryColor};
border-radius: 0 0 8px 8px;
color: white;
font-size: 12px;
@@ -252,6 +251,14 @@ const Banner = styled.div`
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 }>`
${({ isMoved }): string => isMoved ? 'bottom: 50px' : ''};
`;
@@ -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 { 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 { AvailableThemes } from '.';
@@ -10,7 +10,10 @@ import { AvailableThemes } from '.';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
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 AuthorizeReqContext = React.createContext<AuthorizeRequest[]>([]);
const MediaContext = React.createContext<boolean>(false);
@@ -34,6 +34,7 @@ const darkTheme = {
labelFontSize: '13px',
labelLineHeight: '18px',
lineHeight: '26px',
parentLabelColor: '#4A7463',
popupBackground: '#38393F',
primaryColor: '#FF7D01',
readonlyInputBackground: '#1A1B20',
@@ -59,6 +60,7 @@ const lightTheme: Theme = {
inputBackground: '#FFFFFF',
inputBorderColor: '#DDE1EB',
labelColor: '#333333',
parentLabelColor: '#215B4F',
popupBackground: '#FFFFFF',
readonlyInputBackground: '#FFF',
subTextColor: '#454545',
+1 -1
View File
@@ -19,7 +19,7 @@ interface Props {
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> {
const accounts = useContext(AccountContext);
const { accounts } = useContext(AccountContext);
const account = accounts.find((account): boolean => account.address === address);
const startValue = account && account.name;
@@ -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')]
}]
}]);
});
});
@@ -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));