From 53d3ebce37f2e93a5998cd8218623bf79b67bfbb Mon Sep 17 00:00:00 2001 From: Ivan Rukhavets Date: Fri, 3 Apr 2020 11:45:30 +0300 Subject: [PATCH] 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 --- README.md | 2 +- .../src/background/handlers/Extension.ts | 6 +- .../extension-base/src/background/types.ts | 14 ++++- .../src/Popup/Accounts/Account.tsx | 4 +- .../src/Popup/Accounts/AccountsTree.tsx | 27 +++++++++ .../extension-ui/src/Popup/Accounts/index.tsx | 16 ++--- .../src/Popup/Signing/Signing.test.tsx | 23 ++------ packages/extension-ui/src/Popup/index.tsx | 3 +- .../extension-ui/src/components/Address.tsx | 23 +++++--- .../extension-ui/src/components/contexts.tsx | 7 ++- .../extension-ui/src/components/themes.ts | 2 + packages/extension-ui/src/partials/Name.tsx | 2 +- .../src/utils/buildHierarchy.test.ts | 59 +++++++++++++++++++ .../extension-ui/src/utils/buildHierarchy.ts | 28 +++++++++ 14 files changed, 173 insertions(+), 43 deletions(-) create mode 100644 packages/extension-ui/src/Popup/Accounts/AccountsTree.tsx create mode 100644 packages/extension-ui/src/utils/buildHierarchy.test.ts create mode 100644 packages/extension-ui/src/utils/buildHierarchy.ts diff --git a/README.md b/README.md index 3998bde9..23f1cec9 100644 --- a/README.md +++ b/README.md @@ -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. -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: ``` /// diff --git a/packages/extension-base/src/background/handlers/Extension.ts b/packages/extension-base/src/background/handlers/Extension.ts index 5e340457..46859127 100644 --- a/packages/extension-base/src/background/handlers/Extension.ts +++ b/packages/extension-base/src/background/handlers/Extension.ts @@ -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); diff --git a/packages/extension-base/src/background/types.ts b/packages/extension-base/src/background/types.ts index f7aacf78..72ddfa03 100644 --- a/packages/extension-base/src/background/types.ts +++ b/packages/extension-base/src/background/types.ts @@ -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 = { [K in keyof T]: IsNull }[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 { diff --git a/packages/extension-ui/src/Popup/Accounts/Account.tsx b/packages/extension-ui/src/Popup/Accounts/Account.tsx index 603be714..718cfe28 100644 --- a/packages/extension-ui/src/Popup/Accounts/Account.tsx +++ b/packages/extension-ui/src/Popup/Accounts/Account.tsx @@ -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 { +function Account ({ address, className, isExternal, parentName }: Props): React.ReactElement { const onAction = useContext(ActionContext); const [isEditing, setEditing] = useState(false); const [editedName, setName] = useState(null); @@ -61,6 +62,7 @@ function Account ({ address, className, isExternal }: Props): React.ReactElement )} address={address} name={editedName} + parentName={parentName} > {isEditing && ( { + return ( + <> + + {account?.children?.map((child, index) => ( + + ))} + + ); +} + +export default AccountsTree; diff --git a/packages/extension-ui/src/Popup/Accounts/index.tsx b/packages/extension-ui/src/Popup/Accounts/index.tsx index 82bb8326..84a33b40 100644 --- a/packages/extension-ui/src/Popup/Accounts/index.tsx +++ b/packages/extension-ui/src/Popup/Accounts/index.tsx @@ -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 { - const accounts = useContext(AccountContext); + const { hierarchy } = useContext(AccountContext); const mediaAllowed = useContext(MediaContext); return ( <> - {(accounts.length === 0) + {(hierarchy.length === 0) ? : ( <> @@ -26,8 +26,8 @@ export default function Accounts (): React.ReactElement { text={'Accounts'} /> - {accounts.map((json, index): React.ReactNode => ( - ( + diff --git a/packages/extension-ui/src/Popup/Signing/Signing.test.tsx b/packages/extension-ui/src/Popup/Signing/Signing.test.tsx index 36da829f..3d5028c2 100644 --- a/packages/extension-ui/src/Popup/Signing/Signing.test.tsx +++ b/packages/extension-ui/src/Popup/Signing/Signing.test.tsx @@ -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 => { wrapper = mount( - - - - - + + + ); await act(flushAllPromises); diff --git a/packages/extension-ui/src/Popup/index.tsx b/packages/extension-ui/src/Popup/index.tsx index eddfc1bd..f7e72561 100644 --- a/packages/extension-ui/src/Popup/index.tsx +++ b/packages/extension-ui/src/Popup/index.tsx @@ -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 ( {accounts && authRequests && metaRequests && signRequests && ( - + diff --git a/packages/extension-ui/src/components/Address.tsx b/packages/extension-ui/src/components/Address.tsx index 171284dc..2ae56a5f 100644 --- a/packages/extension-ui/src/components/Address.tsx +++ b/packages/extension-ui/src/components/Address.tsx @@ -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 { - const accounts = useContext(AccountContext); +function Address ({ actions, address, children, className, genesisHash, name, parentName }: Props): React.ReactElement { + const { accounts } = useContext(AccountContext); const chain = useMetadata(genesisHash); const [{ account, formatted, prefix }, setRecoded] = useState({ account: null, formatted: null, prefix: 42 }); const [showActionsMenu, setShowActionsMenu] = useState(false); @@ -111,9 +112,8 @@ function Address ({ actions, address, children, className, genesisHash, name }: {name || (account && account.name) || ''} {formatted || ''} - {chain?.genesisHash && ( - {chain.name} - )} + {chain?.genesisHash && {chain.name}} + {parentName && ↳ {parentName}} ({ `; 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' : ''}; `; diff --git a/packages/extension-ui/src/components/contexts.tsx b/packages/extension-ui/src/components/contexts.tsx index 428f00ed..caef1ab8 100644 --- a/packages/extension-ui/src/components/contexts.tsx +++ b/packages/extension-ui/src/components/contexts.tsx @@ -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([]); +const AccountContext = React.createContext({ + accounts: [], + hierarchy: [] +}); const ActionContext = React.createContext<(to?: string) => void>(noop); const AuthorizeReqContext = React.createContext([]); const MediaContext = React.createContext(false); diff --git a/packages/extension-ui/src/components/themes.ts b/packages/extension-ui/src/components/themes.ts index cd934a91..7e4feb9a 100644 --- a/packages/extension-ui/src/components/themes.ts +++ b/packages/extension-ui/src/components/themes.ts @@ -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', diff --git a/packages/extension-ui/src/partials/Name.tsx b/packages/extension-ui/src/partials/Name.tsx index bc7d124a..8d869011 100644 --- a/packages/extension-ui/src/partials/Name.tsx +++ b/packages/extension-ui/src/partials/Name.tsx @@ -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 { - const accounts = useContext(AccountContext); + const { accounts } = useContext(AccountContext); const account = accounts.find((account): boolean => account.address === address); const startValue = account && account.name; diff --git a/packages/extension-ui/src/utils/buildHierarchy.test.ts b/packages/extension-ui/src/utils/buildHierarchy.test.ts new file mode 100644 index 00000000..37f3e8a9 --- /dev/null +++ b/packages/extension-ui/src/utils/buildHierarchy.test.ts @@ -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')] + }] + }]); + }); +}); diff --git a/packages/extension-ui/src/utils/buildHierarchy.ts b/packages/extension-ui/src/utils/buildHierarchy.ts new file mode 100644 index 00000000..4dfd9e6b --- /dev/null +++ b/packages/extension-ui/src/utils/buildHierarchy.ts @@ -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));