Display child when parent has been removed (#349)

* Display child when parent has been removed

* Update packages/extension-ui/src/Popup/index.tsx

* Small styling updates
This commit is contained in:
Jaco Greeff 2020-05-31 10:51:29 +02:00 committed by GitHub
parent dceb530b30
commit db0b7e0eb3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 133 additions and 103 deletions

View File

@ -1,11 +1,13 @@
# CHANGELOG # CHANGELOG
## 0.26.0-beta.11 ## 0.26.0-beta.x
- Add the ability to import JSON keystore files (Thanks to https://github.com/shawntabrizi) - Add the ability to import JSON keystore files (Thanks to https://github.com/shawntabrizi)
- updated to derivation documentation (Thanks to https://github.com/EthWorks) - updated to derivation documentation (Thanks to https://github.com/EthWorks)
- Adjust allowed mnemonic seed strengths, 12, 15, 18, 21 & 24 all allowed - Adjust allowed mnemonic seed strengths, 12, 15, 18, 21 & 24 all allowed
- Remove duplication with Default/Substrate prefixes in dropdown (equivalent, only generic displayed) - Remove duplication with Default/Substrate prefixes in dropdown (equivalent, only generic displayed)
- Display child accounts when no parent has been found (orphans)
- Display derived suri alongside parent account names
## 0.25.1 May 14, 2020 ## 0.25.1 May 14, 2020

View File

@ -344,7 +344,8 @@ export default class Extension {
const childPair = this.derive(parentAddress, suri, parentPassword, { const childPair = this.derive(parentAddress, suri, parentPassword, {
genesisHash, genesisHash,
name, name,
parentAddress parentAddress,
suri
}); });
keyring.addPair(childPair, password); keyring.addPair(childPair, password);

View File

@ -29,6 +29,7 @@ export interface AccountJson extends KeyringPair$Meta {
isExternal?: boolean; isExternal?: boolean;
name?: string; name?: string;
parentAddress?: string; parentAddress?: string;
suri?: string;
whenCreated?: number; whenCreated?: number;
} }

View File

@ -33,19 +33,19 @@ describe('Account component', () => {
wrapper = mountAccountComponent({ isExternal: false }); wrapper = mountAccountComponent({ isExternal: false });
wrapper.find('Details').simulate('click'); wrapper.find('Details').simulate('click');
expect(wrapper.find('MenuItem').length).toBe(4); expect(wrapper.find('a.menuItem').length).toBe(4);
expect(wrapper.find('MenuItem').at(0).text()).toBe('Rename'); expect(wrapper.find('a.menuItem').at(0).text()).toBe('Rename');
expect(wrapper.find('MenuItem').at(1).text()).toBe('Derive New Account'); expect(wrapper.find('a.menuItem').at(1).text()).toBe('Derive New Account');
expect(wrapper.find('MenuItem').at(2).text()).toBe('Export Account'); expect(wrapper.find('a.menuItem').at(2).text()).toBe('Export Account');
expect(wrapper.find('MenuItem').at(3).text()).toBe('Forget Account'); expect(wrapper.find('a.menuItem').at(3).text()).toBe('Forget Account');
}); });
it('does not show Export option if account is external', () => { it('does not show Export option if account is external', () => {
wrapper = mountAccountComponent({ isExternal: true }); wrapper = mountAccountComponent({ isExternal: true });
wrapper.find('Details').simulate('click'); wrapper.find('Details').simulate('click');
expect(wrapper.find('MenuItem').length).toBe(2); expect(wrapper.find('a.menuItem').length).toBe(2);
expect(wrapper.find('MenuItem').at(0).text()).toBe('Rename'); expect(wrapper.find('a.menuItem').at(0).text()).toBe('Rename');
expect(wrapper.find('MenuItem').at(1).text()).toBe('Forget Account'); expect(wrapper.find('a.menuItem').at(1).text()).toBe('Forget Account');
}); });
}); });

View File

@ -5,7 +5,7 @@
import { AccountJson } from '@polkadot/extension-base/background/types'; import { AccountJson } from '@polkadot/extension-base/background/types';
import { ThemeProps } from '../../types'; import { ThemeProps } from '../../types';
import React, { useCallback, useContext, useState } from 'react'; import React, { useCallback, useContext, useMemo, useState } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { ActionContext, Address, Link } from '../../components'; import { ActionContext, Address, Link } from '../../components';
@ -13,12 +13,11 @@ import { editAccount } from '../../messaging';
import { Name } from '../../partials'; import { Name } from '../../partials';
interface Props extends AccountJson { interface Props extends AccountJson {
address: string;
parentName?: string;
className?: string; className?: string;
parentName?: string;
} }
function Account ({ address, className, isExternal, parentName }: Props): React.ReactElement<Props> { function Account ({ address, className, isExternal, parentName, suri }: 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);
@ -27,48 +26,63 @@ function Account ({ address, className, isExternal, parentName }: Props): React.
const _saveChanges = useCallback((): void => { const _saveChanges = useCallback((): void => {
if (editedName && editedName !== name) { if (editedName && editedName !== name) {
editAccount(address, editedName) editAccount(address, editedName)
.then((): void => onAction()) .then(() => onAction())
.catch((error: Error) => console.error(error)); .catch(console.error);
} }
_toggleEdit(); _toggleEdit();
}, [editedName, address, _toggleEdit, onAction]); }, [editedName, address, _toggleEdit, onAction]);
const _actions = useMemo(() => (
<>
<Link
className='menuItem'
onClick={_toggleEdit}
>
Rename
</Link>
{!isExternal && (
<Link
className='menuItem'
to={`/account/derive/${address}/locked`}
>
Derive New Account
</Link>
)}
<div className='divider' />
{!isExternal && (
<Link
className='menuItem'
isDanger
to={`/account/export/${address}`}
>
Export Account
</Link>
)}
<Link
className='menuItem'
isDanger
to={`/account/forget/${address}`}
>
Forget Account
</Link>
</>
), [_toggleEdit, address, isExternal]);
return ( return (
<div className={className}> <div className={className}>
<Address <Address
actions={( actions={_actions}
<>
<MenuGroup>
<MenuItem onClick={_toggleEdit}>Rename</MenuItem>
{!isExternal && (
<MenuItem to={`/account/derive/${address}/locked`}>Derive New Account</MenuItem>
)}
</MenuGroup>
{!isExternal && (
<MenuItem
isDanger
to={`/account/export/${address}`}
>
Export Account
</MenuItem>
)}
<MenuItem
isDanger
to={`/account/forget/${address}`}
>
Forget Account
</MenuItem>
</>
)}
address={address} address={address}
className='address'
name={editedName} name={editedName}
parentName={parentName} parentName={parentName}
suri={suri}
> >
{isEditing && ( {isEditing && (
<Name <Name
address={address} address={address}
className='edit-name' className='editName'
isFocused isFocused
label={' '} label={' '}
onBlur={_saveChanges} onBlur={_saveChanges}
@ -80,33 +94,31 @@ function Account ({ address, className, isExternal, parentName }: Props): React.
); );
} }
const MenuGroup = styled.div` export default styled(Account)(({ theme }: ThemeProps) => `
padding-bottom: 16px; .address {
margin-bottom: 16px;
border-bottom: 1px solid ${({ theme }: ThemeProps): string => theme.boxBorderColor};
`;
const MenuItem = styled(Link)`
padding: 4px 16px;
display: block;
border-radius: 8px;
font-weight: 600;
font-size: 15px;
line-height: 20px;
`;
MenuItem.displayName = 'MenuItem';
export default styled(Account)`
${Address} {
margin-bottom: 8px; margin-bottom: 8px;
} }
.edit-name { .divider {
padding-top: 16px;
margin-bottom: 16px;
border-bottom: 1px solid ${theme.inputBorderColor};
}
.editName {
position: absolute; position: absolute;
flex: 1; flex: 1;
left: 80px; left: 80px;
top: 6px; top: 6px;
width: 315px; width: 315px;
} }
`;
.menuItem {
border-radius: 8px;
display: block;
font-weight: 600;
font-size: 15px;
line-height: 20px;
padding: 4px 16px;
}
`);

View File

@ -10,12 +10,13 @@ interface Props extends AccountWithChildren {
parentName?: string; parentName?: string;
} }
function AccountsTree ({ parentName, ...account }: Props): React.ReactElement<Props> { export default function AccountsTree ({ parentName, suri, ...account }: Props): React.ReactElement<Props> {
return ( return (
<> <>
<Account <Account
{...account} {...account}
parentName={parentName} parentName={parentName}
suri={suri}
/> />
{account?.children?.map((child, index) => ( {account?.children?.map((child, index) => (
<AccountsTree <AccountsTree
@ -27,5 +28,3 @@ function AccountsTree ({ parentName, ...account }: Props): React.ReactElement<Pr
</> </>
); );
} }
export default AccountsTree;

View File

@ -14,28 +14,38 @@ interface Props {
isLocked?: boolean; isLocked?: boolean;
} }
interface AddressState {
address: string;
}
interface PathState extends AddressState {
suri: string;
}
interface ConfirmState {
account: PathState;
parentPassword: string;
}
function Derive ({ isLocked }: Props): React.ReactElement<Props> { function Derive ({ isLocked }: Props): React.ReactElement<Props> {
const onAction = useContext(ActionContext); const onAction = useContext(ActionContext);
const { address: parentAddress } = useParams<{ address: string }>(); const { address: parentAddress } = useParams<AddressState>();
const [account, setAccount] = useState<null | { address: string; suri: string }>(null); const [account, setAccount] = useState<null | PathState>(null);
const [name, setName] = useState<string | null>(null); const [name, setName] = useState<string | null>(null);
const [password, setPassword] = useState<string | null>(null); const [password, setPassword] = useState<string | null>(null);
const [parentPassword, setParentPassword] = useState<string | null>(null); const [parentPassword, setParentPassword] = useState<string | null>(null);
const _onCreate = useCallback(async () => { const _onCreate = useCallback(() => {
if (!account || !name || !password || !parentPassword) { if (!account || !name || !password || !parentPassword) {
return; return;
} }
await deriveAccount(parentAddress, account.suri, parentPassword, name, password); deriveAccount(parentAddress, account.suri, parentPassword, name, password)
.then(() => onAction('/'))
onAction('/'); .catch(console.error);
}, [account, name, password, onAction, parentAddress, parentPassword]); }, [account, name, password, onAction, parentAddress, parentPassword]);
const _onDerivationConfirmed = useCallback(({ account, parentPassword }: { const _onDerivationConfirmed = useCallback(({ account, parentPassword }: ConfirmState) => {
account: { address: string; suri: string };
parentPassword: string;
}) => {
setAccount(account); setAccount(account);
setParentPassword(parentPassword); setParentPassword(parentPassword);
}, []); }, []);

View File

@ -53,8 +53,7 @@ async function requestMediaAccess (cameraOn: boolean): Promise<boolean> {
function initAccountContext (accounts: AccountJson[]): AccountsContext { function initAccountContext (accounts: AccountJson[]): AccountsContext {
const hierarchy = buildHierarchy(accounts); const hierarchy = buildHierarchy(accounts);
const isNotExternal = (account: AccountJson): boolean => !account.isExternal; const master = hierarchy.find((account) => !account.isExternal);
const master = hierarchy.find(isNotExternal);
return { return {
accounts, accounts,

View File

@ -33,6 +33,7 @@ interface Props {
genesisHash?: string | null; genesisHash?: string | null;
actions?: React.ReactNode; actions?: React.ReactNode;
parentName?: string | null; parentName?: string | null;
suri?: string;
} }
interface Recoded { interface Recoded {
@ -69,7 +70,7 @@ function recodeAddress (address: string, accounts: AccountWithChildren[], chain:
const ACCOUNTS_SCREEN_HEIGHT = 500; const ACCOUNTS_SCREEN_HEIGHT = 500;
function Address ({ actions, address, children, className, genesisHash, name, parentName }: Props): React.ReactElement<Props> { function Address ({ actions, address, children, className, genesisHash, name, parentName, suri }: 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 });
@ -119,7 +120,7 @@ function Address ({ actions, address, children, className, genesisHash, name, pa
<> <>
<Banner> <Banner>
<ArrowLabel/> <ArrowLabel/>
<ParentName data-field='parent'>{parentName}</ParentName> <ParentName data-field='parent'>{parentName}{suri || ''}</ParentName>
</Banner> </Banner>
<DisplacedName>{displayedName}</DisplacedName> <DisplacedName>{displayedName}</DisplacedName>
</> </>

View File

@ -38,20 +38,20 @@ function Link ({ children, className, onClick, to }: Props): React.ReactElement<
); );
} }
export default styled(Link)` export default styled(Link)(({ isDanger, theme }: Props) => `
display: flex;
align-items: center; align-items: center;
color: ${({ isDanger, theme }: Props): string => isDanger ? theme.textColorDanger : theme.textColor}; color: ${isDanger ? theme.textColorDanger : theme.textColor};
display: flex;
opacity: 0.9; opacity: 0.9;
text-decoration: none; text-decoration: none;
vertical-align: middle; vertical-align: middle;
&:hover { &:hover {
color: ${({ isDanger, theme }: Props): string => isDanger ? theme.textColorDanger : theme.textColor}; color: ${isDanger ? theme.textColorDanger : theme.textColor};
opacity: 1.0; opacity: 1.0;
} }
&:visited { &:visited {
color: ${({ isDanger, theme }: Props): string => isDanger ? theme.textColorDanger : theme.textColor}; color: ${isDanger ? theme.textColorDanger : theme.textColor};
} }
`; `);

View File

@ -4,28 +4,33 @@
import { AccountJson, AccountWithChildren } from '@polkadot/extension-base/background/types'; import { AccountJson, AccountWithChildren } from '@polkadot/extension-base/background/types';
const isRoot = (account: AccountJson): boolean => !account.parentAddress; type ChildFilter = (account: AccountJson) => AccountWithChildren;
const isChild = (parent: AccountJson) => (account: AccountJson): boolean => account.parentAddress === parent.address;
const compareByCreationTime = (a: AccountJson, b: AccountJson): number => (a.whenCreated || Infinity) - (b.whenCreated || Infinity);
export const accountWithChildren = (allAccounts: AccountJson[]) => (account: AccountJson): AccountWithChildren => { function compareByCreationTime (a: AccountJson, b: AccountJson): number {
const children = allAccounts return (a.whenCreated || Infinity) - (b.whenCreated || Infinity);
.filter(isChild(account)) }
.map(accountWithChildren(allAccounts))
.sort(compareByCreationTime);
if (children.length === 0) { export function accountWithChildren (accounts: AccountJson[]): ChildFilter {
return account; return (account: AccountJson): AccountWithChildren => {
} const children = accounts
.filter(({ parentAddress }) => account.address === parentAddress)
.map(accountWithChildren(accounts))
.sort(compareByCreationTime);
return { return children.length === 0
...account, ? account
children : { children, ...account };
}; };
}; }
export const buildHierarchy = (accounts: AccountJson[]): AccountWithChildren[] => export function buildHierarchy (accounts: AccountJson[]): AccountWithChildren[] {
accounts return accounts
.filter(isRoot) .filter(({ parentAddress }) =>
// it is a parent
!parentAddress ||
// we don't have a parent for this one
!accounts.some(({ address }) => parentAddress === address)
)
.map(accountWithChildren(accounts)) .map(accountWithChildren(accounts))
.sort(compareByCreationTime); .sort(compareByCreationTime);
}