Create new account as a child of a master account by default (#318)
* derive accounts for master account * Add master field to account context (#75) * add master field to account context * add tests for buildHierarchy function * Fix selection master account * Add path numeration for derived accounts (#76) * add path numeration for derived accounts * Fix PR notices. Add util for Path generation. * Correct test Co-authored-by: Ivan Rukhavets <ivanruch@gmail.com> Co-authored-by: VladStarostenko <vlad@DESKTOP-V12SRE8.localdomain> * Add arrows to buttons (#77) * Add parent label (#80) * refactor parent label * refactor parent label * refactor parent label Co-authored-by: Ivan Rukhavets <ivanruch@gmail.com> * Implement new create account flow (#78) * Add copied snackbar (#83) * refactor parent label * Add "Copied" snackbar * Add "Copied" snackbar * Hide suri input behind a spoiler (#84) * Disallow changing parent account when 'Derive' option is clicked (#82) * Add lock to path input (#85) Co-authored-by: VladStarostenko <38181661+VladStarostenko@users.noreply.github.com> Co-authored-by: VladStarostenko <vlad@DESKTOP-V12SRE8.localdomain>
This commit is contained in:
parent
05d3f784ca
commit
ed36ed24bb
@ -29,6 +29,7 @@ export interface AccountJson extends KeyringPair$Meta {
|
||||
isExternal?: boolean;
|
||||
name?: string;
|
||||
parentAddress?: string;
|
||||
whenCreated?: number;
|
||||
}
|
||||
|
||||
export type AccountWithChildren = AccountJson & {
|
||||
@ -38,6 +39,7 @@ export type AccountWithChildren = AccountJson & {
|
||||
export type AccountsContext = {
|
||||
accounts: AccountJson[];
|
||||
hierarchy: AccountWithChildren[];
|
||||
master?: AccountJson;
|
||||
}
|
||||
|
||||
export interface AuthorizeRequest {
|
||||
|
||||
@ -41,7 +41,7 @@ function Account ({ address, className, isExternal, parentName }: Props): React.
|
||||
<MenuGroup>
|
||||
<MenuItem onClick={_toggleEdit}>Rename</MenuItem>
|
||||
{!isExternal && (
|
||||
<MenuItem to={`/account/derive/${address}`}>Derive New Account</MenuItem>
|
||||
<MenuItem to={`/account/derive/${address}/locked`}>Derive New Account</MenuItem>
|
||||
)}
|
||||
</MenuGroup>
|
||||
{!isExternal && (
|
||||
|
||||
@ -5,15 +5,15 @@
|
||||
import React, { useContext } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import QrImage from '../../assets/qr.svg';
|
||||
import { AccountContext, Button, ButtonArea, ButtonWithSubtitle, MediaContext, Svg } from '../../components';
|
||||
import { AddAccount, Header } from '../../partials';
|
||||
import AccountsTree from './AccountsTree';
|
||||
import qrIcon from '../../assets/qr.svg';
|
||||
|
||||
type Props = {};
|
||||
|
||||
export default function Accounts (): React.ReactElement<Props> {
|
||||
const { hierarchy } = useContext(AccountContext);
|
||||
const { hierarchy, master } = useContext(AccountContext);
|
||||
const mediaAllowed = useContext(MediaContext);
|
||||
|
||||
return (
|
||||
@ -39,9 +39,9 @@ export default function Accounts (): React.ReactElement<Props> {
|
||||
}
|
||||
<ButtonArea>
|
||||
<ButtonWithSubtitle
|
||||
subTitle='With new seed'
|
||||
subTitle={master ? 'Derive from master' : 'With new seed'}
|
||||
title='Create New Account'
|
||||
to='/account/create'
|
||||
to={master ? `/account/derive/${master.address}` : '/account/create'}
|
||||
/>
|
||||
<ButtonWithSubtitle
|
||||
subTitle='I have a pre-existing seed'
|
||||
@ -50,7 +50,7 @@ export default function Accounts (): React.ReactElement<Props> {
|
||||
/>
|
||||
{mediaAllowed && (
|
||||
<QrButton to='/account/import-qr'>
|
||||
<Svg src={QrImage} />
|
||||
<Svg src={qrIcon} />
|
||||
</QrButton>
|
||||
)}
|
||||
</ButtonArea>
|
||||
|
||||
@ -2,19 +2,22 @@
|
||||
// This software may be modified and distributed under the terms
|
||||
// of the Apache-2.0 license. See the LICENSE file for details.
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Address, Button, ButtonArea, VerticalSpace } from '@polkadot/extension-ui/components';
|
||||
import { Name, Password } from '@polkadot/extension-ui/partials';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { Address, BackButton, ButtonArea, NextStepButton, VerticalSpace } from '../..//components';
|
||||
import { Name, Password } from '../../partials';
|
||||
|
||||
interface Props {
|
||||
onCreate: (name: string, password: string) => void | Promise<void | boolean>;
|
||||
address: string;
|
||||
onBackClick: () => void;
|
||||
onCreate: (name: string, password: string) => void | Promise<void | boolean>;
|
||||
}
|
||||
|
||||
function AccountName ({ address, onCreate }: Props): React.ReactElement<Props> {
|
||||
function AccountName ({ address, onBackClick, onCreate }: Props): React.ReactElement<Props> {
|
||||
const [name, setName] = useState<string | null>(null);
|
||||
const [password, setPassword] = useState<string | null>(null);
|
||||
|
||||
const _onCreate = useCallback(() => name && password && onCreate(name, password), [name, password, onCreate]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Name
|
||||
@ -23,21 +26,22 @@ function AccountName ({ address, onCreate }: Props): React.ReactElement<Props> {
|
||||
/>
|
||||
{name && <Password onChange={setPassword} />}
|
||||
{name && password && (
|
||||
<>
|
||||
<Address
|
||||
address={address}
|
||||
name={name}
|
||||
/>
|
||||
<VerticalSpace />
|
||||
<ButtonArea>
|
||||
<Button
|
||||
onClick={(): void | Promise<void | boolean> => onCreate(name, password)}
|
||||
>
|
||||
Add the account with the generated seed
|
||||
</Button>
|
||||
</ButtonArea>
|
||||
</>
|
||||
<Address
|
||||
address={address}
|
||||
name={name}
|
||||
/>
|
||||
)}
|
||||
<VerticalSpace />
|
||||
<ButtonArea>
|
||||
<BackButton onClick={onBackClick} />
|
||||
<NextStepButton
|
||||
data-button-action='add new root'
|
||||
isDisabled={!password || !name}
|
||||
onClick={_onCreate}
|
||||
>
|
||||
Add the account with the generated seed
|
||||
</NextStepButton>
|
||||
</ButtonArea>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -75,7 +75,7 @@ describe('Create Account', () => {
|
||||
it('clicking on Next activates phase 2', () => {
|
||||
check(wrapper.find('input[type="checkbox"]'));
|
||||
wrapper.find('button').simulate('click');
|
||||
expect(wrapper.find(Header).text()).toBe('Create an account 2/2Back');
|
||||
expect(wrapper.find(Header).text()).toBe('Create an account: 2/2Cancel');
|
||||
});
|
||||
});
|
||||
|
||||
@ -90,7 +90,7 @@ describe('Create Account', () => {
|
||||
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-repeat-password]')).toHaveLength(0);
|
||||
expect(wrapper.find(Button)).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', () => {
|
||||
@ -103,7 +103,7 @@ describe('Create Account', () => {
|
||||
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-repeat-password]')).toHaveLength(0);
|
||||
expect(wrapper.find(Button)).toHaveLength(0);
|
||||
expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true);
|
||||
});
|
||||
|
||||
it('input should keep showing error when something has been typed but then erased', async () => {
|
||||
@ -117,7 +117,7 @@ describe('Create Account', () => {
|
||||
expect(wrapper.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(Button)).toHaveLength(0);
|
||||
expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true);
|
||||
});
|
||||
|
||||
it('password shorter than 6 characters should be not valid', async () => {
|
||||
@ -126,21 +126,21 @@ describe('Create Account', () => {
|
||||
expect(wrapper.find('ErrorMessage').text()).toBe('Password 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(Button)).toHaveLength(0);
|
||||
expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true);
|
||||
});
|
||||
|
||||
it('submit button is not visible until both passwords are equal', async () => {
|
||||
await enterName('abc').then(password('abcdef')).then(repeat('abcdeg'));
|
||||
expect(wrapper.find('ErrorMessage').text()).toBe('Passwords do not match');
|
||||
expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]').find(Input).prop('withError')).toBe(true);
|
||||
expect(wrapper.find(Button)).toHaveLength(0);
|
||||
expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true);
|
||||
});
|
||||
|
||||
it('submit button is visible when both passwords are equal', async () => {
|
||||
await enterName('abc').then(password('abcdef')).then(repeat('abcdef'));
|
||||
expect(wrapper.find('ErrorMessage')).toHaveLength(0);
|
||||
expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]').find(Input).prop('withError')).toBe(false);
|
||||
expect(wrapper.find(Button)).toHaveLength(1);
|
||||
expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(false);
|
||||
});
|
||||
|
||||
it('saves account with provided name and password', async () => {
|
||||
@ -161,28 +161,28 @@ describe('Create Account', () => {
|
||||
await enterName('abc').then(password('abcdef')).then(repeat('abcdef'));
|
||||
});
|
||||
|
||||
it('first password input is cleared - second one and button get hidden', async () => {
|
||||
it('first password input is cleared - second one disappears and button get disabled', async () => {
|
||||
await type(wrapper.find('input[type="password"]').first(), '');
|
||||
expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]')).toHaveLength(0);
|
||||
expect(wrapper.find(Button)).toHaveLength(0);
|
||||
expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true);
|
||||
});
|
||||
|
||||
it('first password changes - button is not visible', async () => {
|
||||
it('first password changes - button is not disabled', async () => {
|
||||
await type(wrapper.find('input[type="password"]').first(), 'aaaaaa');
|
||||
expect(wrapper.find('ErrorMessage').text()).toBe('Passwords do not match');
|
||||
expect(wrapper.find(Button)).toHaveLength(0);
|
||||
expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true);
|
||||
});
|
||||
|
||||
it('first password changes, then second changes too - button is visible', async () => {
|
||||
await type(wrapper.find('input[type="password"]').first(), 'aaaaaa');
|
||||
await type(wrapper.find('input[type="password"]').last(), 'aaaaaa');
|
||||
expect(wrapper.find(Button)).toHaveLength(1);
|
||||
expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(false);
|
||||
});
|
||||
|
||||
it('second password changes, then first changes too - button is visible', async () => {
|
||||
await type(wrapper.find('input[type="password"]').last(), 'aaaaaa');
|
||||
await type(wrapper.find('input[type="password"]').first(), 'aaaaaa');
|
||||
expect(wrapper.find(Button)).toHaveLength(1);
|
||||
expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
// of the Apache-2.0 license. See the LICENSE file for details.
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { Button, ButtonArea, Checkbox, MnemonicSeed, VerticalSpace, Warning } from '../../components';
|
||||
import { ButtonArea, Checkbox, MnemonicSeed, NextStepButton, VerticalSpace, Warning } from '../../components';
|
||||
import useToast from '../../hooks/useToast';
|
||||
|
||||
interface Props {
|
||||
@ -45,12 +45,12 @@ function Mnemonic ({ onNextStep, seed }: Props): React.ReactElement<Props> {
|
||||
onChange={setIsMnemonicSaved}
|
||||
/>
|
||||
<ButtonArea>
|
||||
<Button
|
||||
<NextStepButton
|
||||
isDisabled={!isMnemonicSaved}
|
||||
onClick={onNextStep}
|
||||
>
|
||||
Next step
|
||||
</Button>
|
||||
</NextStepButton>
|
||||
</ButtonArea>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -3,11 +3,9 @@
|
||||
// of the Apache-2.0 license. See the LICENSE file for details.
|
||||
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { ActionContext, Loading, ActionText } from '../../components';
|
||||
import { ActionContext, Loading } from '../../components';
|
||||
import { createAccountSuri, createSeed } from '../../messaging';
|
||||
import { Header } from '../../partials';
|
||||
import { HeaderWithSteps } from '../../partials';
|
||||
import AccountName from './AccountName';
|
||||
import Mnemonic from './Mnemonic';
|
||||
|
||||
@ -33,29 +31,14 @@ export default function CreateAccount (): React.ReactElement {
|
||||
};
|
||||
|
||||
const _onNextStep = (): void => setStep(step + 1);
|
||||
|
||||
const _onCancel = (): void => {
|
||||
if (step === 2) {
|
||||
setStep(step - 1);
|
||||
} else {
|
||||
onAction('/');
|
||||
}
|
||||
};
|
||||
const _onPreviousStep = (): void => setStep(step - 1);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header text={'Create an account '}>
|
||||
<CreationSteps>
|
||||
<div>
|
||||
<CurrentStep>{step}</CurrentStep>
|
||||
<TotalSteps>/2</TotalSteps>
|
||||
</div>
|
||||
<ActionText
|
||||
onClick={_onCancel}
|
||||
text={step === 1 ? 'Cancel' : 'Back'}
|
||||
/>
|
||||
</CreationSteps>
|
||||
</Header>
|
||||
<HeaderWithSteps
|
||||
step={step}
|
||||
text='Create an account: '
|
||||
/>
|
||||
<Loading>{account && (step === 1
|
||||
? (
|
||||
<Mnemonic
|
||||
@ -66,6 +49,7 @@ export default function CreateAccount (): React.ReactElement {
|
||||
: (
|
||||
<AccountName
|
||||
address={account.address}
|
||||
onBackClick={_onPreviousStep}
|
||||
onCreate={_onCreate}
|
||||
/>
|
||||
)
|
||||
@ -73,27 +57,3 @@ export default function CreateAccount (): React.ReactElement {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const CreationSteps = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-grow: 1;
|
||||
padding-right: 24px;
|
||||
margin-top: 3px;
|
||||
`;
|
||||
|
||||
const CurrentStep = styled.span`
|
||||
font-size: ${({ theme }): string => theme.labelFontSize};
|
||||
line-height: ${({ theme }): string => theme.labelLineHeight};
|
||||
color: ${({ theme }): string => theme.primaryColor};
|
||||
font-weight: 800;
|
||||
margin-left: 10px;
|
||||
`;
|
||||
|
||||
const TotalSteps = styled.span`
|
||||
font-size: ${({ theme }): string => theme.labelFontSize};
|
||||
line-height: ${({ theme }): string => theme.labelLineHeight};
|
||||
color: ${({ theme }): string => theme.textColor};
|
||||
font-weight: 800;
|
||||
`;
|
||||
|
||||
93
packages/extension-ui/src/Popup/Derive/AddressDropdown.tsx
Normal file
93
packages/extension-ui/src/Popup/Derive/AddressDropdown.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
// 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 React, { useCallback, useRef, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { Address, Svg } from '../../components';
|
||||
import arrow from '../../assets/arrow-down.svg';
|
||||
import useOutsideClick from '../../hooks/useOutsideClick';
|
||||
|
||||
interface Props {
|
||||
allAddresses: string[];
|
||||
className?: string;
|
||||
onSelect: (address: string) => void;
|
||||
selectedAddress: string;
|
||||
}
|
||||
|
||||
function AddressDropdown ({ allAddresses, className, onSelect, selectedAddress }: Props): React.ReactElement<Props> {
|
||||
const [isDropdownVisible, setDropdownVisible] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const hideDropdown = useCallback(() => setDropdownVisible(false), []);
|
||||
const toggleDropdown = useCallback(() => setDropdownVisible(!isDropdownVisible), [isDropdownVisible]);
|
||||
const selectParent = (newParent: string) => (): void => onSelect(newParent);
|
||||
|
||||
useOutsideClick(ref, hideDropdown);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div
|
||||
onClick={toggleDropdown}
|
||||
ref={ref}
|
||||
>
|
||||
<Address address={selectedAddress} />
|
||||
</div>
|
||||
<HiddenOptions visible={isDropdownVisible}>
|
||||
{allAddresses.map((address) => (
|
||||
<div
|
||||
data-parent-option
|
||||
key={address}
|
||||
onClick={selectParent(address)}
|
||||
>
|
||||
<Address address={address} />
|
||||
</div>
|
||||
))}
|
||||
</HiddenOptions>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const HiddenOptions = styled.div<{ visible: boolean }>`
|
||||
position: absolute;
|
||||
visibility: ${({ visible }): string => (visible ? 'visible' : 'hidden')};
|
||||
width: 430px;
|
||||
z-index: 100;
|
||||
background: ${({ theme }): string => theme.bodyColor};
|
||||
max-height: ${({ visible }): string => (visible ? '200px' : '0')};
|
||||
overflow: scroll;
|
||||
padding: 5px;
|
||||
border: 1px solid ${({ theme }): string => theme.boxBorderColor};
|
||||
box-sizing: border-box;
|
||||
border-radius: 4px;
|
||||
margin-top: -8px;
|
||||
|
||||
& > div {
|
||||
cursor: pointer;
|
||||
}
|
||||
`;
|
||||
|
||||
export default styled(AddressDropdown)`
|
||||
margin-bottom: 16px;
|
||||
cursor: pointer;
|
||||
|
||||
& > div:first-child > ${Address}::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
right: 11px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background: url(${arrow}) center no-repeat;
|
||||
background-color: ${({ theme }): string => theme.inputBackground};
|
||||
pointer-events: none;
|
||||
border-radius: 4px;
|
||||
border: 1px solid ${({ theme }): string => theme.boxBorderColor};
|
||||
}
|
||||
|
||||
${Address} ${Svg} {
|
||||
visibility: hidden;
|
||||
}
|
||||
`;
|
||||
186
packages/extension-ui/src/Popup/Derive/Derive.test.tsx
Normal file
186
packages/extension-ui/src/Popup/Derive/Derive.test.tsx
Normal file
@ -0,0 +1,186 @@
|
||||
// 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 '../../../../../__mocks__/chrome';
|
||||
|
||||
import React from 'react';
|
||||
import { MemoryRouter, Route } from 'react-router';
|
||||
import { configure, mount, ReactWrapper } from 'enzyme';
|
||||
import Adapter from 'enzyme-adapter-react-16';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { ThemeProvider } from 'styled-components';
|
||||
import * as messaging from '@polkadot/extension-ui/messaging';
|
||||
|
||||
import { AccountContext, ActionContext, themes } from '../../components';
|
||||
import { buildHierarchy } from '../../utils/buildHierarchy';
|
||||
import { flushAllPromises } from '../../testHelpers';
|
||||
import AddressDropdown from './AddressDropdown';
|
||||
import Derive from '.';
|
||||
|
||||
configure({ adapter: new Adapter() });
|
||||
|
||||
const accounts = [
|
||||
{ address: '5FjgD3Ns2UpnHJPVeRViMhCttuemaRXEqaD8V5z4vxcsUByA', name: 'A' },
|
||||
{ address: '5GYmFzQCuC5u3tQNiMZNbFGakrz3Jq31NmMg4D2QAkSoQ2g5', name: 'B' },
|
||||
{ address: '5D2TPhGEy2FhznvzaNYW9AkuMBbg3cyRemnPsBvBY4ZhkZXA', name: 'BB', parentAddress: '5GYmFzQCuC5u3tQNiMZNbFGakrz3Jq31NmMg4D2QAkSoQ2g5' },
|
||||
{ address: '5GhGENSJBWQZ8d8mARKgqEkiAxiW3hHeznQDW2iG4XzNieb6', isExternal: true, name: 'C' },
|
||||
{ address: '5EeaoDj4VDk8V6yQngKBaCD5MpJUCHrhYjVhBjgMHXoYon1s', isExternal: false, name: 'D' },
|
||||
{ address: '5HRKYp5anSNGtqC7cq9ftiaq4y8Mk7uHk7keaXUrQwZqDWLJ', name: 'DD', parentAddress: '5EeaoDj4VDk8V6yQngKBaCD5MpJUCHrhYjVhBjgMHXoYon1s' }
|
||||
];
|
||||
|
||||
describe('Derive', () => {
|
||||
const mountComponent = async (locked = false): Promise<{
|
||||
wrapper: ReactWrapper;
|
||||
onActionStub: jest.Mock;
|
||||
}> => {
|
||||
const onActionStub = jest.fn();
|
||||
|
||||
const wrapper = mount(
|
||||
<MemoryRouter initialEntries={ [`/account/derive/${accounts[1].address}`] }>
|
||||
<ActionContext.Provider value={onActionStub}>
|
||||
<AccountContext.Provider value={{
|
||||
accounts: accounts,
|
||||
hierarchy: buildHierarchy(accounts)
|
||||
}}>
|
||||
<ThemeProvider theme={themes.dark}>
|
||||
<Route path='/account/derive/:address'>
|
||||
<Derive isLocked={locked}/>
|
||||
</Route>
|
||||
</ThemeProvider>
|
||||
</AccountContext.Provider>
|
||||
</ActionContext.Provider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await act(flushAllPromises);
|
||||
|
||||
return { onActionStub, wrapper };
|
||||
};
|
||||
|
||||
const uncheck = (input: ReactWrapper): unknown => input.simulate('change', { target: { checked: false } });
|
||||
|
||||
const type = async (input: ReactWrapper, value: string): Promise<void> => {
|
||||
input.simulate('change', { target: { value } });
|
||||
await act(flushAllPromises);
|
||||
input.update();
|
||||
};
|
||||
|
||||
describe('Parent selection screen', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
jest.spyOn(messaging, 'validateAccount').mockImplementation(async (_, pass: string) => pass === 'pass');
|
||||
|
||||
it('"Derive" checkbox is selected by default', async () => {
|
||||
const { wrapper } = await mountComponent();
|
||||
const checkbox = wrapper.find('input[type="checkbox"]');
|
||||
|
||||
expect(checkbox.prop('checked')).toBe(true);
|
||||
});
|
||||
|
||||
it('"Create new root" button becomes visible after checkbox is unchecked', async () => {
|
||||
const { wrapper } = await mountComponent();
|
||||
|
||||
uncheck(wrapper.find('input[type="checkbox"]'));
|
||||
|
||||
expect(wrapper.exists('[data-button-action="create root account"]')).toBe(true);
|
||||
expect(wrapper.exists('[data-button-action="create derived account"]')).toBe(false);
|
||||
});
|
||||
|
||||
it('"Create new root" button redirects to /account/create', async () => {
|
||||
const { onActionStub, wrapper } = await mountComponent();
|
||||
|
||||
uncheck(wrapper.find('input[type="checkbox"]'));
|
||||
wrapper.find('[data-button-action="create root account"] button').simulate('click');
|
||||
|
||||
expect(onActionStub).toBeCalledWith('/account/create');
|
||||
});
|
||||
|
||||
it('"Create derived account" is visible when checkbox is checked', async () => {
|
||||
const { wrapper } = await mountComponent();
|
||||
|
||||
expect(wrapper.exists('[data-button-action="create derived account"]')).toBe(true);
|
||||
expect(wrapper.exists('[data-button-action="create root account"]')).toBe(false);
|
||||
});
|
||||
|
||||
it('"Create derived account" is disabled when password is incorrect', async () => {
|
||||
const { wrapper } = await mountComponent();
|
||||
|
||||
const button = wrapper.find('[data-button-action="create derived account"] button');
|
||||
|
||||
expect(button.prop('disabled')).toBe(true);
|
||||
});
|
||||
|
||||
it('"Create derived account" is enabled when password is correct', async () => {
|
||||
const { wrapper } = await mountComponent();
|
||||
|
||||
await type(wrapper.find('input[type="password"]'), 'pass');
|
||||
|
||||
const button = wrapper.find('[data-button-action="create derived account"] button');
|
||||
|
||||
expect(button.prop('disabled')).toBe(false);
|
||||
});
|
||||
|
||||
it('derivation path input is not present until password is correct', async () => {
|
||||
const { wrapper } = await mountComponent();
|
||||
|
||||
expect(wrapper.exists('[data-input-suri]')).toBe(false);
|
||||
await type(wrapper.find('input[type="password"]'), 'pass');
|
||||
expect(wrapper.exists('[data-input-suri]')).toBe(true);
|
||||
await type(wrapper.find('input[type="password"]'), 'pass2');
|
||||
expect(wrapper.exists('[data-input-suri]')).toBe(false);
|
||||
});
|
||||
|
||||
it('"Create derived account" is disabled when suri is incorrect', async () => {
|
||||
const { wrapper } = await mountComponent();
|
||||
|
||||
await type(wrapper.find('input[type="password"]'), 'pass');
|
||||
await type(wrapper.find('[data-input-suri] input'), '//');
|
||||
|
||||
const button = wrapper.find('[data-button-action="create derived account"] button');
|
||||
|
||||
expect(button.prop('disabled')).toBe(false);
|
||||
});
|
||||
|
||||
it('takes selected address from URL as parent account', async () => {
|
||||
const { wrapper } = await mountComponent();
|
||||
|
||||
expect(wrapper.find('[data-field="name"]').first().text()).toBe('B');
|
||||
});
|
||||
|
||||
it('selects internal root accounts as other options', async () => {
|
||||
const { wrapper } = await mountComponent();
|
||||
|
||||
const options = wrapper.find('[data-parent-option] [data-field="name"] div').map((el) => el.text());
|
||||
|
||||
expect(options).toEqual(['A', 'B', 'D']);
|
||||
});
|
||||
|
||||
it('redirects to derive from next account when other option is selected', async () => {
|
||||
const { onActionStub, wrapper } = await mountComponent();
|
||||
|
||||
wrapper.find('[data-parent-option]').first().simulate('click');
|
||||
|
||||
expect(onActionStub).toBeCalledWith(`/account/derive/${accounts[0].address}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Locked parent selection', () => {
|
||||
it('checkbox does not exist', async () => {
|
||||
const { wrapper } = await mountComponent(true);
|
||||
|
||||
expect(wrapper.exists('[type="checkbox"]')).toBe(false);
|
||||
});
|
||||
|
||||
it('address dropdown does not exist', async () => {
|
||||
const { wrapper } = await mountComponent(true);
|
||||
|
||||
expect(wrapper.exists(AddressDropdown)).toBe(false);
|
||||
});
|
||||
|
||||
it('parent is taken from URL', async () => {
|
||||
const { wrapper } = await mountComponent(true);
|
||||
|
||||
expect(wrapper.find('[data-field="name"]').first().text()).toBe('B');
|
||||
});
|
||||
});
|
||||
});
|
||||
124
packages/extension-ui/src/Popup/Derive/SelectParent.tsx
Normal file
124
packages/extension-ui/src/Popup/Derive/SelectParent.tsx
Normal file
@ -0,0 +1,124 @@
|
||||
// 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 React, { useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { AccountContext, ActionContext, Address, ButtonArea, Checkbox, InputWithLabel, Label, NextStepButton, VerticalSpace } from '../../components';
|
||||
import { validateAccount } from '../../messaging';
|
||||
import { DerivationPath } from '../../partials';
|
||||
import { nextDerivationPath } from '../../utils/nextDerivationPath';
|
||||
import AddressDropdown from './AddressDropdown';
|
||||
|
||||
interface Props {
|
||||
isLocked?: boolean;
|
||||
parentAddress: string;
|
||||
onDerivationConfirmed: (derivation: { account: { address: string; suri: string }; parentPassword: string }) => void;
|
||||
}
|
||||
|
||||
export function SelectParent ({ isLocked, onDerivationConfirmed, parentAddress }: Props): React.ReactElement<Props> {
|
||||
const onAction = useContext(ActionContext);
|
||||
const { accounts, hierarchy } = useContext(AccountContext);
|
||||
const [account, setAccount] = useState<null | { address: string; suri: string }>(null);
|
||||
const [parentPassword, setParentPassword] = useState<string>('');
|
||||
const [isProperParentPassword, setIsProperParentPassword] = useState(false);
|
||||
const [shouldAccountBeDerived, setShouldAccountBeDerived] = useState(true);
|
||||
|
||||
const passwordInputRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const _onParentPasswordEnter = useCallback(async (enteredPassword: string) => {
|
||||
setParentPassword(enteredPassword);
|
||||
setIsProperParentPassword(await validateAccount(parentAddress, enteredPassword));
|
||||
}, [parentAddress]);
|
||||
|
||||
const _onParentChange = useCallback((address: string) => onAction(`/account/derive/${address}`), [onAction]);
|
||||
|
||||
const _onSubmit = useCallback(() => {
|
||||
parentPassword && account && onDerivationConfirmed({ account, parentPassword });
|
||||
}, [account, parentPassword, onDerivationConfirmed]);
|
||||
|
||||
useEffect(() => {
|
||||
setParentPassword('');
|
||||
setIsProperParentPassword(false);
|
||||
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
passwordInputRef.current?.querySelector('input')?.focus();
|
||||
}, [_onParentPasswordEnter]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isLocked && (
|
||||
<CheckboxWithSmallerMargins
|
||||
checked={shouldAccountBeDerived}
|
||||
label='Derive'
|
||||
onChange={setShouldAccountBeDerived}
|
||||
/>
|
||||
)}
|
||||
<DisableableArea isDisabled={!shouldAccountBeDerived}>
|
||||
{isLocked ? (
|
||||
<Address address={parentAddress} />
|
||||
) : (
|
||||
<Label label='Choose Parent Account:'>
|
||||
<AddressDropdown
|
||||
allAddresses={hierarchy.filter(({ isExternal }) => !isExternal).map(({ address }) => address)}
|
||||
onSelect={_onParentChange}
|
||||
selectedAddress={parentAddress}
|
||||
/>
|
||||
</Label>
|
||||
)}
|
||||
<div ref={passwordInputRef}>
|
||||
<InputWithLabel
|
||||
data-export-password
|
||||
isError={!isProperParentPassword}
|
||||
isFocused
|
||||
label='enter the password for the account you want to derive from'
|
||||
onChange={_onParentPasswordEnter}
|
||||
type='password'
|
||||
value={parentPassword}
|
||||
/>
|
||||
</div>
|
||||
{isProperParentPassword && (
|
||||
<DerivationPath
|
||||
defaultPath={nextDerivationPath(accounts, parentAddress)}
|
||||
onChange={setAccount}
|
||||
parentAddress={parentAddress}
|
||||
parentPassword={parentPassword}
|
||||
/>
|
||||
)}
|
||||
</DisableableArea>
|
||||
<VerticalSpace/>
|
||||
<ButtonArea>
|
||||
{shouldAccountBeDerived
|
||||
? (
|
||||
<NextStepButton
|
||||
data-button-action='create derived account'
|
||||
isDisabled={!isProperParentPassword || account === null}
|
||||
onClick={_onSubmit}
|
||||
>
|
||||
Create a derived account
|
||||
</NextStepButton>
|
||||
)
|
||||
: (
|
||||
<NextStepButton
|
||||
data-button-action='create root account'
|
||||
onClick={(): void => onAction('/account/create')}
|
||||
>
|
||||
Create account from new seed
|
||||
</NextStepButton>
|
||||
)
|
||||
}
|
||||
</ButtonArea>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const CheckboxWithSmallerMargins = styled(Checkbox)`
|
||||
margin-top: 0;
|
||||
margin-bottom: 10px;
|
||||
`;
|
||||
|
||||
const DisableableArea = styled.div<{ isDisabled: boolean }>`
|
||||
opacity: ${({ isDisabled }): string => isDisabled ? '0.2' : '1'};
|
||||
${({ isDisabled }): string | false => isDisabled && 'pointer-events: none'};
|
||||
`;
|
||||
@ -3,30 +3,24 @@
|
||||
// of the Apache-2.0 license. See the LICENSE file for details.
|
||||
|
||||
import React, { useCallback, useContext, useState } from 'react';
|
||||
import { RouteComponentProps, withRouter } from 'react-router';
|
||||
import styled from 'styled-components';
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import { ActionContext, Address, Button, ButtonArea, InputWithLabel, VerticalSpace } from '../../components';
|
||||
import { deriveAccount, validateAccount } from '../../messaging';
|
||||
import { DerivationPath, Name, Password } from '../../partials';
|
||||
import Step from './Step';
|
||||
import { ActionContext, Address, BackButton, ButtonArea, NextStepButton, VerticalSpace } from '../../components';
|
||||
import { deriveAccount } from '../../messaging';
|
||||
import { HeaderWithSteps, Name, Password } from '../../partials';
|
||||
import { SelectParent } from './SelectParent';
|
||||
|
||||
type Props = RouteComponentProps<{ address: string }>;
|
||||
interface Props {
|
||||
isLocked?: boolean;
|
||||
}
|
||||
|
||||
const DeriveButton = styled(Button)`
|
||||
margin-left: 24px;
|
||||
margin-right: 24px;
|
||||
width: auto;
|
||||
`;
|
||||
|
||||
function Derive ({ match: { params: { address: parentAddress } } }: Props): React.ReactElement<Props> {
|
||||
function Derive ({ isLocked }: Props): React.ReactElement<Props> {
|
||||
const onAction = useContext(ActionContext);
|
||||
const { address: parentAddress } = useParams<{ address: string }>();
|
||||
const [account, setAccount] = useState<null | { address: string; suri: string }>(null);
|
||||
const [name, setName] = useState<string | null>(null);
|
||||
const [password, setPassword] = useState<string | null>(null);
|
||||
const [parentPassword, setParentPassword] = useState<string | null>(null);
|
||||
const [isProperParentPassword, setIsProperParentPassword] = useState(false);
|
||||
const [derivationConfirmed, setDerivationConfirmed] = useState(false);
|
||||
|
||||
const _onCreate = useCallback(async () => {
|
||||
if (!account || !name || !password || !parentPassword) {
|
||||
@ -38,53 +32,55 @@ function Derive ({ match: { params: { address: parentAddress } } }: Props): Reac
|
||||
onAction('/');
|
||||
}, [account, name, password, onAction, parentAddress, parentPassword]);
|
||||
|
||||
const _onParentPasswordEnter = useCallback(async (enteredPassword: string) => {
|
||||
setParentPassword(enteredPassword);
|
||||
setIsProperParentPassword(await validateAccount(parentAddress, enteredPassword));
|
||||
}, [parentAddress]);
|
||||
const _onDerivationConfirmed = useCallback(({ account, parentPassword }: {
|
||||
account: { address: string; suri: string };
|
||||
parentPassword: string;
|
||||
}) => {
|
||||
setAccount(account);
|
||||
setParentPassword(parentPassword);
|
||||
}, []);
|
||||
|
||||
const _onBackClick = useCallback(() => {
|
||||
setAccount(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Step step={derivationConfirmed ? 2 : 1} />
|
||||
{!derivationConfirmed && (
|
||||
<InputWithLabel
|
||||
data-export-password
|
||||
isError={!isProperParentPassword}
|
||||
isFocused
|
||||
label='enter the password for the account you want to derive from'
|
||||
onChange={_onParentPasswordEnter}
|
||||
type='password'
|
||||
<HeaderWithSteps
|
||||
step={account ? 2 : 1}
|
||||
text='Add new account: '
|
||||
/>
|
||||
{!account && (
|
||||
<SelectParent
|
||||
isLocked={isLocked}
|
||||
onDerivationConfirmed={_onDerivationConfirmed}
|
||||
parentAddress={parentAddress}
|
||||
/>
|
||||
)}
|
||||
{!derivationConfirmed && (
|
||||
<DeriveButton
|
||||
isDisabled={!isProperParentPassword}
|
||||
onClick={(): void => setDerivationConfirmed(true)}
|
||||
>
|
||||
I want to derive from this account
|
||||
</DeriveButton>
|
||||
)}
|
||||
{isProperParentPassword && derivationConfirmed && (
|
||||
{account && (
|
||||
<Name
|
||||
isFocused
|
||||
onChange={setName}
|
||||
/>
|
||||
)}
|
||||
{isProperParentPassword && derivationConfirmed && parentPassword && name && <DerivationPath
|
||||
onChange={setAccount}
|
||||
parentAddress={parentAddress}
|
||||
parentPassword={parentPassword}
|
||||
/>}
|
||||
{isProperParentPassword && derivationConfirmed && account && name && <Password onChange={setPassword}/>}
|
||||
{isProperParentPassword && derivationConfirmed && account && name && password && (
|
||||
{account && name && <Password onChange={setPassword}/>}
|
||||
{account && name && password && (
|
||||
<Address
|
||||
address={account.address}
|
||||
name={name}
|
||||
/>
|
||||
)}
|
||||
{account && (
|
||||
<>
|
||||
<Address
|
||||
address={account.address}
|
||||
name={name}
|
||||
/>
|
||||
<VerticalSpace/>
|
||||
<ButtonArea>
|
||||
<Button onClick={_onCreate}>Create derived account</Button>
|
||||
<BackButton onClick={_onBackClick}/>
|
||||
<NextStepButton
|
||||
isDisabled={!password}
|
||||
onClick={_onCreate}
|
||||
>
|
||||
Create derived account
|
||||
</NextStepButton>
|
||||
</ButtonArea>
|
||||
</>
|
||||
)}
|
||||
@ -92,4 +88,4 @@ function Derive ({ match: { params: { address: parentAddress } } }: Props): Reac
|
||||
);
|
||||
}
|
||||
|
||||
export default withRouter(Derive);
|
||||
export default Derive;
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
import React, { useContext, useState } from 'react';
|
||||
import { QrScanAddress } from '@polkadot/react-qr';
|
||||
|
||||
import { ActionContext, Address, Button, ButtonArea, VerticalSpace } from '../components';
|
||||
import { ActionContext, Address, NextStepButton, ButtonArea, VerticalSpace } from '../components';
|
||||
import { createAccountExternal } from '../messaging';
|
||||
import { Header, Name } from '../partials';
|
||||
|
||||
@ -49,9 +49,11 @@ export default function ImportQr (): React.ReactElement<Props> {
|
||||
<VerticalSpace />
|
||||
{name && (
|
||||
<ButtonArea>
|
||||
<Button onClick={_onCreate}>
|
||||
<NextStepButton
|
||||
onClick={_onCreate}
|
||||
>
|
||||
Add the account with identified address
|
||||
</Button>
|
||||
</NextStepButton>
|
||||
</ButtonArea>
|
||||
)}
|
||||
</>
|
||||
|
||||
@ -5,10 +5,10 @@
|
||||
import React, { useCallback, useContext, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { ActionContext, Address, Button, ButtonArea, TextAreaWithLabel, ValidatedInput, VerticalSpace } from '../components';
|
||||
import { ActionContext, Address, ButtonArea, NextStepButton, TextAreaWithLabel, ValidatedInput, VerticalSpace } from '../components';
|
||||
import { allOf, isNotShorterThan, Result } from '../validators';
|
||||
import { createAccountSuri, validateSeed } from '../messaging';
|
||||
import { Header, Name, Password } from '../partials';
|
||||
import { allOf, isNotShorterThan, Result } from '../validators';
|
||||
|
||||
type Props = {};
|
||||
|
||||
@ -79,7 +79,11 @@ export default function Import (): React.ReactElement<Props> {
|
||||
/>
|
||||
<VerticalSpace />
|
||||
<ButtonArea>
|
||||
<Button onClick={_onCreate}>Add the account with the supplied seed</Button>
|
||||
<NextStepButton
|
||||
onClick={_onCreate}
|
||||
>
|
||||
Add the account with the supplied seed
|
||||
</NextStepButton>
|
||||
</ButtonArea>
|
||||
</>
|
||||
)}
|
||||
@ -93,7 +97,6 @@ const HeaderWithSmallerMargin = styled(Header)`
|
||||
|
||||
const SeedInput = styled(TextAreaWithLabel)`
|
||||
margin-bottom: 16px;
|
||||
|
||||
textarea {
|
||||
height: unset;
|
||||
}
|
||||
|
||||
@ -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 { AccountJson, AccountsContext, AuthorizeRequest, MetadataRequest, SigningRequest } from '@polkadot/extension-base/background/types';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Route, Switch } from 'react-router';
|
||||
@ -50,6 +50,18 @@ async function requestMediaAccess (cameraOn: boolean): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
function initAccountContext (accounts: AccountJson[]): AccountsContext {
|
||||
const hierarchy = buildHierarchy(accounts);
|
||||
const isNotExternal = (account: AccountJson): boolean => !account.isExternal;
|
||||
const master = hierarchy.find(isNotExternal);
|
||||
|
||||
return {
|
||||
accounts,
|
||||
hierarchy,
|
||||
master
|
||||
};
|
||||
}
|
||||
|
||||
export default function Popup (): React.ReactElement<{}> {
|
||||
const [accounts, setAccounts] = useState<null | AccountJson[]>(null);
|
||||
const [authRequests, setAuthRequests] = useState<null | AuthorizeRequest[]>(null);
|
||||
@ -97,7 +109,7 @@ export default function Popup (): React.ReactElement<{}> {
|
||||
return (
|
||||
<Loading>{accounts && authRequests && metaRequests && signRequests && (
|
||||
<ActionContext.Provider value={_onAction}>
|
||||
<AccountContext.Provider value={{ accounts, hierarchy: buildHierarchy(accounts) }}>
|
||||
<AccountContext.Provider value={initAccountContext(accounts)}>
|
||||
<AuthorizeReqContext.Provider value={authRequests}>
|
||||
<MediaContext.Provider value={cameraOn && mediaAllowed}>
|
||||
<MetadataReqContext.Provider value={metaRequests}>
|
||||
@ -109,6 +121,7 @@ export default function Popup (): React.ReactElement<{}> {
|
||||
<Route path='/account/export/:address'><Export /></Route>
|
||||
<Route path='/account/import-qr'><ImportQr /></Route>
|
||||
<Route path='/account/import-seed'><ImportSeed /></Route>
|
||||
<Route path='/account/derive/:address/locked'><Derive isLocked /></Route>
|
||||
<Route path='/account/derive/:address'><Derive /></Route>
|
||||
<Route
|
||||
exact
|
||||
|
||||
3
packages/extension-ui/src/assets/arrowParentLabel.svg
Normal file
3
packages/extension-ui/src/assets/arrowParentLabel.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="10" height="12" viewBox="0 0 10 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 7.33333L6 11.3333L5.05333 10.3867L7.44667 8H0V0H1.33333V6.66667H7.44667L5.05333 4.28L6 3.33333L10 7.33333Z" fill="#B3B3B5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 243 B |
3
packages/extension-ui/src/assets/lock.svg
Normal file
3
packages/extension-ui/src/assets/lock.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="12" height="16" viewBox="0 0 12 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.5001 7.09999H9.6001V4.39999C9.6001 2.41999 7.9801 0.799988 6.0001 0.799988C4.0201 0.799988 2.4001 2.41999 2.4001 4.39999V7.09999H1.5001C1.0501 7.09999 0.600098 7.54999 0.600098 7.99999V14.3C0.600098 14.75 1.0501 15.2 1.5001 15.2H10.5001C10.9501 15.2 11.4001 14.75 11.4001 14.3V7.99999C11.4001 7.54999 10.9501 7.09999 10.5001 7.09999ZM6.9001 13.4H5.1001L5.4601 11.42C5.0101 11.24 4.6501 10.7 4.6501 10.25C4.6501 9.52999 5.2801 8.89999 6.0001 8.89999C6.7201 8.89999 7.3501 9.52999 7.3501 10.25C7.3501 10.79 7.0801 11.24 6.5401 11.42L6.9001 13.4ZM7.8001 7.09999H4.2001V4.39999C4.2001 3.40999 5.0101 2.59999 6.0001 2.59999C6.9901 2.59999 7.8001 3.40999 7.8001 4.39999V7.09999Z" fill="#8D8D8D"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 809 B |
3
packages/extension-ui/src/assets/unlock.svg
Normal file
3
packages/extension-ui/src/assets/unlock.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="12" height="16" viewBox="0 0 12 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.6001 7.10005H10.5001C10.9501 7.10005 11.4001 7.55005 11.4001 8.00005V14.3C11.4001 14.75 10.9501 15.2 10.5001 15.2H1.5001C1.0501 15.2 0.600098 14.75 0.600098 14.3V8.00005C0.600098 7.55005 1.0501 7.10005 1.5001 7.10005H2.4001V4.40005C2.4001 2.42005 4.0201 0.800049 6.0001 0.800049C7.67121 0.800049 9.08589 1.95403 9.48661 3.50446L7.77075 4.07642C7.61719 3.23946 6.87969 2.60005 6.0001 2.60005C5.0101 2.60005 4.2001 3.41005 4.2001 4.40005V7.10005H7.8001V7.1H9.6001V7.10005ZM5.4601 11.42L5.1001 13.4H6.9001L6.5401 11.42C7.0801 11.24 7.3501 10.79 7.3501 10.25C7.3501 9.53005 6.7201 8.90005 6.0001 8.90005C5.2801 8.90005 4.6501 9.53005 4.6501 10.25C4.6501 10.7 5.0101 11.24 5.4601 11.42Z" fill="#8D8D8D"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 857 B |
@ -11,13 +11,14 @@ import styled from 'styled-components';
|
||||
import settings from '@polkadot/ui-settings';
|
||||
import { decodeAddress, encodeAddress } from '@polkadot/util-crypto';
|
||||
|
||||
import copy from '../assets/copy.svg';
|
||||
import details from '../assets/details.svg';
|
||||
import parentArrow from '../assets/arrowParentLabel.svg';
|
||||
|
||||
import { AccountContext } from './contexts';
|
||||
import Identicon from './Identicon';
|
||||
import Svg from './Svg';
|
||||
|
||||
import Menu from './Menu';
|
||||
import CopyImg from '../assets/copy.svg';
|
||||
import DetailsImg from '../assets/details.svg';
|
||||
import Svg from './Svg';
|
||||
import useOutsideClick from '../hooks/useOutsideClick';
|
||||
import useMetadata from '../hooks/useMetadata';
|
||||
import useToast from '../hooks/useToast';
|
||||
@ -29,7 +30,7 @@ interface Props {
|
||||
children?: React.ReactNode;
|
||||
genesisHash?: string | null;
|
||||
actions?: React.ReactNode;
|
||||
parentName?: string;
|
||||
parentName?: string | null;
|
||||
}
|
||||
|
||||
interface Recoded {
|
||||
@ -99,25 +100,37 @@ function Address ({ actions, address, children, className, genesisHash, name, pa
|
||||
const _onClick = useCallback((): void => setShowActionsMenu(!showActionsMenu), [showActionsMenu]);
|
||||
const _onCopy = useCallback((): void => show('Copied'), [show]);
|
||||
|
||||
const displayedName = name || (account && account.name) || '<unknown>';
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div>
|
||||
<AccountInfoRow>
|
||||
<Identicon
|
||||
iconTheme={theme}
|
||||
onCopy={_onCopy}
|
||||
prefix={prefix}
|
||||
value={formatted || address}
|
||||
/>
|
||||
<Info>
|
||||
<Name>{name || (account && account.name) || '<unknown>'}</Name>
|
||||
{parentName ? (
|
||||
<>
|
||||
<Banner>
|
||||
<ArrowLabel/>
|
||||
<ParentName data-field='parent'>{parentName}</ParentName>
|
||||
</Banner>
|
||||
<DisplacedName>{displayedName}</DisplacedName>
|
||||
</>
|
||||
) : (
|
||||
<Name data-field='name'>{displayedName}</Name>
|
||||
)}
|
||||
<CopyAddress>
|
||||
<FullAddress>{formatted || '<unknown>'}</FullAddress>
|
||||
{chain?.genesisHash && <ChainBanner>{chain.name}</ChainBanner>}
|
||||
{parentName && <ParentBanner>↳ {parentName}</ParentBanner>}
|
||||
<FullAddress data-field='address'>{formatted || '<unknown>'}</FullAddress>
|
||||
{chain?.genesisHash && <ChainBanner data-field='chain'>{chain.name}</ChainBanner>}
|
||||
<CopyToClipboard text={(formatted && formatted) || ''} >
|
||||
<Svg
|
||||
onClick={_onCopy}
|
||||
src={CopyImg}
|
||||
src={copy}
|
||||
/>
|
||||
</CopyToClipboard>
|
||||
</CopyAddress>
|
||||
@ -186,6 +199,10 @@ const Name = styled.div`
|
||||
line-height: 22px;
|
||||
`;
|
||||
|
||||
const DisplacedName = styled(Name)`
|
||||
padding-top: 10px;
|
||||
`;
|
||||
|
||||
const FullAddress = styled.div`
|
||||
width: 270px;
|
||||
overflow: hidden;
|
||||
@ -229,13 +246,13 @@ const Settings = styled.div`
|
||||
Settings.displayName = 'Details';
|
||||
|
||||
const ActionsIcon = styled(Svg).attrs(() => ({
|
||||
src: DetailsImg
|
||||
src: details
|
||||
}))`
|
||||
background: ${({ theme }): string => theme.accountDotsIconColor};
|
||||
`;
|
||||
|
||||
const ActiveActionsIcon = styled(Svg).attrs(() => ({
|
||||
src: DetailsImg
|
||||
src: details
|
||||
}))`
|
||||
background: ${({ theme }): string => theme.primaryColor};
|
||||
`;
|
||||
@ -245,18 +262,35 @@ const Banner = styled.div`
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
padding: 0.1rem 0.5rem;
|
||||
position: absolute;
|
||||
right: 40px;
|
||||
top: 0;
|
||||
`;
|
||||
|
||||
const ChainBanner = styled(Banner)`
|
||||
padding: 0.1rem 0.5rem;
|
||||
right: 40px;
|
||||
background: ${({ theme }): string => theme.primaryColor};
|
||||
`;
|
||||
|
||||
const ParentBanner = styled(Banner)`
|
||||
background: ${({ theme }): string => theme.parentLabelColor};
|
||||
const ParentName = styled.div`
|
||||
padding: 0.25rem 0 0 0.8rem;
|
||||
font-weight: 600;
|
||||
font-size: 10px;
|
||||
line-height: 14px;
|
||||
color: ${({ theme }): string => theme.labelColor};
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
width: 270px;
|
||||
`;
|
||||
|
||||
const ArrowLabel = styled(Svg).attrs(() => ({
|
||||
src: parentArrow
|
||||
}))`
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
background: ${({ theme }): string => theme.labelColor};
|
||||
`;
|
||||
|
||||
const MovableMenu = styled(Menu) <{ isMoved: boolean }>`
|
||||
|
||||
38
packages/extension-ui/src/components/BackButton.tsx
Normal file
38
packages/extension-ui/src/components/BackButton.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
// 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 React from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import arrowLeft from '../assets/arrowLeft.svg';
|
||||
import Button from './Button';
|
||||
import Svg from './Svg';
|
||||
|
||||
interface Props {
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export default function BackButton ({ onClick }: Props): React.ReactElement<Props> {
|
||||
return (
|
||||
<SmallButton onClick={onClick}>
|
||||
<ArrowLeft />
|
||||
</SmallButton>
|
||||
);
|
||||
}
|
||||
|
||||
const SmallButton = styled(Button)`
|
||||
margin-right: 11px;
|
||||
width: 42px;
|
||||
background: ${({ theme }): string => theme.backButtonBackground};
|
||||
`;
|
||||
|
||||
const ArrowLeft = styled(Svg).attrs(() => ({
|
||||
src: arrowLeft
|
||||
}))`
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
display: block;
|
||||
margin: auto;
|
||||
background: ${({ theme }): string => theme.backButtonTextColor};
|
||||
`;
|
||||
@ -30,13 +30,15 @@ function Checkbox ({ checked, className, label, onChange }: Props): React.ReactE
|
||||
}
|
||||
|
||||
export default styled(Checkbox)`
|
||||
margin: ${({ theme }): string => theme.boxMargin};
|
||||
|
||||
label {
|
||||
display: block;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
margin: ${({ theme }): string => theme.boxMargin};
|
||||
padding-left: 24px;
|
||||
padding-top: 1px;
|
||||
color: ${({ theme }): string => theme.subTextColor};
|
||||
font-size: ${({ theme }): string => theme.fontSize};
|
||||
line-height: ${({ theme }): string => theme.lineHeight};
|
||||
|
||||
@ -4,8 +4,8 @@
|
||||
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import arrow from '../assets/arrow-down.svg';
|
||||
|
||||
import arrow from '../assets/arrow-down.svg';
|
||||
import Label from './Label';
|
||||
|
||||
interface DropdownOption {
|
||||
@ -90,7 +90,7 @@ export default styled(Dropdown)`
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
right: 5px;
|
||||
right: 12px;
|
||||
width: 8px;
|
||||
height: 6px;
|
||||
background: url(${arrow}) center no-repeat;
|
||||
|
||||
@ -11,13 +11,15 @@ interface Props {
|
||||
className?: string;
|
||||
prefix?: number;
|
||||
value?: string | null;
|
||||
onCopy?: () => void;
|
||||
}
|
||||
|
||||
function Identicon ({ className, iconTheme, prefix, value }: Props): React.ReactElement<Props> {
|
||||
function Identicon ({ className, iconTheme, onCopy, prefix, value }: Props): React.ReactElement<Props> {
|
||||
return (
|
||||
<div className={className}>
|
||||
<Icon
|
||||
className='icon'
|
||||
onCopy={onCopy}
|
||||
prefix={prefix}
|
||||
size={64}
|
||||
theme={iconTheme}
|
||||
|
||||
@ -11,6 +11,7 @@ import { Input } from './TextInputs';
|
||||
interface Props {
|
||||
className?: string;
|
||||
defaultValue?: string | null;
|
||||
disabled?: boolean;
|
||||
isError?: boolean;
|
||||
isFocused?: boolean;
|
||||
isReadOnly?: boolean;
|
||||
@ -22,7 +23,7 @@ interface Props {
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
function InputWithLabel ({ className, defaultValue, isError, isFocused, isReadOnly, label, onBlur, onChange, placeholder, type = 'text', value }: Props): React.ReactElement<Props> {
|
||||
function InputWithLabel ({ className, defaultValue, disabled, isError, isFocused, isReadOnly, label, onBlur, onChange, placeholder, type = 'text', value }: Props): React.ReactElement<Props> {
|
||||
const _onChange = useCallback(
|
||||
({ target: { value } }: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
onChange && onChange(value);
|
||||
@ -40,6 +41,7 @@ function InputWithLabel ({ className, defaultValue, isError, isFocused, isReadOn
|
||||
autoCorrect='off'
|
||||
autoFocus={isFocused}
|
||||
defaultValue={defaultValue || undefined}
|
||||
disabled={disabled}
|
||||
onBlur={onBlur}
|
||||
onChange={_onChange}
|
||||
placeholder={placeholder}
|
||||
|
||||
32
packages/extension-ui/src/components/NextStepButton.tsx
Normal file
32
packages/extension-ui/src/components/NextStepButton.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
// 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 React from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import arrowLeft from '../assets/arrowLeft.svg';
|
||||
import Svg from './Svg';
|
||||
import Button from './Button';
|
||||
|
||||
type Props = React.ComponentProps<typeof Button>;
|
||||
|
||||
export default function NextStepButton ({ children, ...props }: Props): React.ReactElement<Props> {
|
||||
return (
|
||||
<Button {...props}>
|
||||
{children}
|
||||
<ArrowRight />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const ArrowRight = styled(Svg).attrs(() => ({
|
||||
src: arrowLeft
|
||||
}))`
|
||||
float: right;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin: 4px 1px 0 0;
|
||||
transform: rotate(180deg);
|
||||
background: ${({ theme }): string => theme.buttonTextColor};
|
||||
`;
|
||||
@ -18,18 +18,17 @@ type Props<T extends BasicProps> = T & {
|
||||
validator: Validator<string>;
|
||||
component: React.ComponentType<T>;
|
||||
onValidatedChange: (value: string | null) => void;
|
||||
defaultValue?: string;
|
||||
}
|
||||
|
||||
function ValidatedInput<T extends object> ({ className, component: Input, onValidatedChange, validator, ...props }: Props<T>): React.ReactElement<Props<T>> {
|
||||
const [value, setValue] = useState('');
|
||||
function ValidatedInput<T extends object> ({ className, component: Input, defaultValue, onValidatedChange, validator, ...props }: Props<T>): React.ReactElement<Props<T>> {
|
||||
const [value, setValue] = useState(defaultValue || '');
|
||||
const [wasMounted, setWasMounted] = useState(false);
|
||||
const [validationResult, setValidationResult] = useState<Result<string>>(Result.ok(''));
|
||||
|
||||
useEffect(() => {
|
||||
if (!wasMounted) {
|
||||
setWasMounted(true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
(async (): Promise<void> => {
|
||||
|
||||
@ -12,7 +12,8 @@ const noop = (): void => undefined;
|
||||
|
||||
const AccountContext = React.createContext<AccountsContext>({
|
||||
accounts: [],
|
||||
hierarchy: []
|
||||
hierarchy: [],
|
||||
master: undefined
|
||||
});
|
||||
const ActionContext = React.createContext<(to?: string) => void>(noop);
|
||||
const AuthorizeReqContext = React.createContext<AuthorizeRequest[]>([]);
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
export { default as ActionBar } from './ActionBar';
|
||||
export { default as ActionText } from './ActionText';
|
||||
export { default as Address } from './Address';
|
||||
export { default as BackButton } from './BackButton';
|
||||
export { default as Box } from './Box';
|
||||
export { default as Button } from './Button';
|
||||
export { default as ButtonArea } from './ButtonArea';
|
||||
@ -15,11 +16,13 @@ export { default as Fonts } from './Fonts';
|
||||
export { default as Icon } from './Icon';
|
||||
export { default as Identicon } from './Identicon';
|
||||
export { default as InputWithLabel } from './InputWithLabel';
|
||||
export { default as Label } from './Label';
|
||||
export { default as Link } from './Link';
|
||||
export { default as List } from './List';
|
||||
export { default as Loading } from './Loading';
|
||||
export { default as Menu } from './Menu';
|
||||
export { default as MnemonicSeed } from './MnemonicSeed';
|
||||
export { default as NextStepButton } from './NextStepButton';
|
||||
export { default as Svg } from './Svg';
|
||||
export { default as Switch } from './Switch';
|
||||
export { default as Table } from './Table';
|
||||
|
||||
@ -6,10 +6,12 @@ const darkTheme = {
|
||||
accountBackground: '#1A1B20',
|
||||
accountDotsIconColor: '#8E8E8E',
|
||||
addAccountImageBackground: '#1A1B20',
|
||||
backButtonBackground: '#3A3B41',
|
||||
backButtonTextColor: '#FFFFFF',
|
||||
background: '#26272C',
|
||||
bodyColor: '#20222A',
|
||||
borderRadius: '4px',
|
||||
boxBorderColor: '#43444B',
|
||||
boxBorderColor: '#303030',
|
||||
boxMargin: '0.75rem 0',
|
||||
boxPadding: '0 0.25rem',
|
||||
boxShadow: 'rgba(0, 0, 0, 0.86)',
|
||||
@ -47,6 +49,8 @@ const lightTheme: Theme = {
|
||||
...darkTheme,
|
||||
accountBackground: '#FFFFFF',
|
||||
addAccountImageBackground: '#FFF',
|
||||
backButtonBackground: '#D7D7D7',
|
||||
backButtonTextColor: '#454545',
|
||||
background: '#FAFAFA',
|
||||
bodyColor: '#FFFFFF',
|
||||
boxBorderColor: '#DADFEA',
|
||||
|
||||
@ -3,45 +3,115 @@
|
||||
// of the Apache-2.0 license. See the LICENSE file for details.
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { InputWithLabel, ValidatedInput } from '../components';
|
||||
import { InputWithLabel, Svg, ValidatedInput, Button } from '../components';
|
||||
import { validateDerivationPath } from '../messaging';
|
||||
import { Result } from '../validators';
|
||||
import lockIcon from '../assets/lock.svg';
|
||||
import unlockIcon from '../assets/unlock.svg';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
onChange: (derivedAccount: { address: string; suri: string } | null) => void;
|
||||
parentAddress: string;
|
||||
parentPassword: string;
|
||||
defaultPath: string;
|
||||
}
|
||||
|
||||
function DerivationPath ({ onChange, parentAddress, parentPassword }: Props): React.ReactElement<Props> {
|
||||
const [path, setPath] = useState<string>('');
|
||||
function DerivationPath ({ className, defaultPath, onChange, parentAddress, parentPassword }: Props): React.ReactElement<Props> {
|
||||
const [path, setPath] = useState<string>(defaultPath);
|
||||
const [isDisabled, setIsDisabled] = useState(false);
|
||||
|
||||
const isPathValid = useCallback(async (path: string): Promise<Result<string>> => {
|
||||
const isPathValid = useCallback(async (newPath: string): Promise<Result<string>> => {
|
||||
try {
|
||||
await validateDerivationPath(parentAddress, path, parentPassword);
|
||||
await validateDerivationPath(parentAddress, newPath, parentPassword);
|
||||
|
||||
return Result.ok(path);
|
||||
return Result.ok(newPath);
|
||||
} catch (error) {
|
||||
return Result.error('Invalid derivation path');
|
||||
return Result.error(newPath === path ? '' : 'Invalid derivation path');
|
||||
}
|
||||
}, [parentAddress, parentPassword]);
|
||||
}, [path, parentAddress, parentPassword]);
|
||||
|
||||
const _onExpand = useCallback(() => setIsDisabled(!isDisabled), [isDisabled]);
|
||||
|
||||
const _onChange = useCallback(async (newPath: string | null) => {
|
||||
newPath && setPath(newPath);
|
||||
onChange(newPath ? await validateDerivationPath(parentAddress, newPath, parentPassword) : null);
|
||||
newPath !== null && setPath(newPath);
|
||||
onChange(newPath === null ? null : await validateDerivationPath(parentAddress, newPath, parentPassword));
|
||||
}, [parentAddress, onChange, parentPassword]);
|
||||
|
||||
return (
|
||||
<ValidatedInput
|
||||
component={InputWithLabel}
|
||||
label='Derivation path'
|
||||
onValidatedChange={_onChange}
|
||||
placeholder='//hard/soft'
|
||||
validator={isPathValid}
|
||||
value={path}
|
||||
/>
|
||||
<div className={className}>
|
||||
<PathField>
|
||||
<PathInput isUnlocked={isDisabled}>
|
||||
<ValidatedInput
|
||||
component={InputWithLabel}
|
||||
data-input-suri
|
||||
defaultValue={defaultPath}
|
||||
disabled={!isDisabled}
|
||||
label='Derivation Path'
|
||||
onValidatedChange={_onChange}
|
||||
placeholder='//hard/soft'
|
||||
validator={isPathValid}
|
||||
value={path}
|
||||
/>
|
||||
</PathInput>
|
||||
<LockButton onClick={_onExpand}>
|
||||
{!isDisabled ? <LockIcon/> : <UnlockIcon/>}
|
||||
</LockButton>
|
||||
</PathField>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const PathField = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
`;
|
||||
|
||||
interface UnlockableComponentProps {
|
||||
isUnlocked: boolean;
|
||||
}
|
||||
|
||||
const PathInput = styled.div<UnlockableComponentProps>`
|
||||
width: 100%;
|
||||
|
||||
& input {
|
||||
opacity: ${({ isUnlocked }): string => isUnlocked ? '100' : '30'}%;
|
||||
}
|
||||
`;
|
||||
|
||||
const LockIcon = styled(Svg).attrs(() => ({
|
||||
src: lockIcon
|
||||
}))`
|
||||
width: 11px;
|
||||
height: 14px;
|
||||
background: ${({ theme }): string => theme.iconNeutralColor};
|
||||
`;
|
||||
|
||||
const UnlockIcon = styled(Svg).attrs(() => ({
|
||||
src: unlockIcon
|
||||
}))`
|
||||
width: 11px;
|
||||
height: 14px;
|
||||
background: ${({ theme }): string => theme.iconNeutralColor};
|
||||
`;
|
||||
|
||||
const LockButton = styled(Button)`
|
||||
width: 11px;
|
||||
height: 14px;
|
||||
padding: 3px;
|
||||
margin: 36px 2px 0 10px;
|
||||
background: none;
|
||||
&:not(:disabled):hover {
|
||||
background: none;
|
||||
}
|
||||
&:active, &:focus {
|
||||
outline: none;
|
||||
}
|
||||
&::-moz-focus-inner {
|
||||
border: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
export default DerivationPath;
|
||||
|
||||
@ -2,38 +2,54 @@
|
||||
// This software may be modified and distributed under the terms
|
||||
// of the Apache-2.0 license. See the LICENSE file for details.
|
||||
|
||||
import React from 'react';
|
||||
import React, { useCallback, useContext } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { Header } from '../../partials';
|
||||
import { ActionContext, ActionText } from '../components';
|
||||
import Header from './Header';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
step: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
function Step ({ className, step }: Props): React.ReactElement<Props> {
|
||||
export default function HeaderWithSteps ({ step, text }: Props): React.ReactElement<Props> {
|
||||
const onAction = useContext(ActionContext);
|
||||
|
||||
const _onCancel = useCallback(() => {
|
||||
onAction('/');
|
||||
}, [onAction]);
|
||||
|
||||
return (
|
||||
<Header
|
||||
className={className}
|
||||
showBackArrow
|
||||
text={
|
||||
<>
|
||||
<span>Derive new account</span>
|
||||
<Header text={text}>
|
||||
<CreationSteps>
|
||||
<div>
|
||||
<CurrentStep>{step}</CurrentStep>
|
||||
<TotalSteps>/2</TotalSteps>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<ActionText
|
||||
onClick={_onCancel}
|
||||
text='Cancel'
|
||||
/>
|
||||
</CreationSteps>
|
||||
</Header>
|
||||
);
|
||||
}
|
||||
|
||||
const CreationSteps = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-grow: 1;
|
||||
padding-right: 24px;
|
||||
margin-top: 3px;
|
||||
`;
|
||||
|
||||
const CurrentStep = styled.span`
|
||||
font-size: ${({ theme }): string => theme.labelFontSize};
|
||||
line-height: ${({ theme }): string => theme.labelLineHeight};
|
||||
color: ${({ theme }): string => theme.primaryColor};
|
||||
font-weight: 600;
|
||||
margin-left: 10px;
|
||||
`;
|
||||
|
||||
const TotalSteps = styled.span`
|
||||
@ -42,5 +58,3 @@ const TotalSteps = styled.span`
|
||||
color: ${({ theme }): string => theme.textColor};
|
||||
font-weight: 600;
|
||||
`;
|
||||
|
||||
export default Step;
|
||||
@ -9,3 +9,4 @@ export { default as Header } from './Header';
|
||||
export { default as Name } from './Name';
|
||||
export { default as Password } from './Password';
|
||||
export { default as Settings } from './Settings';
|
||||
export { default as HeaderWithSteps } from './HeaderWithSteps';
|
||||
|
||||
@ -10,14 +10,14 @@ const testHierarchy = (accounts: AccountJson[], expected: AccountWithChildren[])
|
||||
};
|
||||
|
||||
describe('Use Account Hierarchy', () => {
|
||||
const acc = (address: string, parentAddress?: string, derivationPath?: string): {
|
||||
const acc = (address: string, parentAddress?: string, whenCreated?: number): {
|
||||
address: string;
|
||||
derivationPath?: string;
|
||||
whenCreated?: number;
|
||||
parentAddress?: string;
|
||||
} => ({
|
||||
address,
|
||||
derivationPath,
|
||||
parentAddress
|
||||
parentAddress,
|
||||
whenCreated
|
||||
});
|
||||
|
||||
test('for empty account list, returns empty list', () => {
|
||||
@ -56,4 +56,18 @@ describe('Use Account Hierarchy', () => {
|
||||
}]
|
||||
}]);
|
||||
});
|
||||
|
||||
test('sorts accounts by creation time', () => {
|
||||
testHierarchy(
|
||||
[acc('a', undefined, 2), acc('b', 'a', 4), acc('c', 'a', 3), acc('d', undefined, 1)],
|
||||
[acc('d', undefined, 1), { address: 'a', children: [acc('c', 'a', 3), acc('b', 'a', 4)], whenCreated: 2 }]
|
||||
);
|
||||
});
|
||||
|
||||
test('if creation time is missing, puts account at the back of a list', () => {
|
||||
testHierarchy(
|
||||
[acc('a'), acc('b', undefined, 2), acc('c', undefined, 1)],
|
||||
[acc('c', undefined, 1), acc('b', undefined, 2), acc('a')]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -6,11 +6,13 @@ import { AccountJson, AccountWithChildren } from '@polkadot/extension-base/backg
|
||||
|
||||
const isRoot = (account: AccountJson): boolean => !account.parentAddress;
|
||||
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 => {
|
||||
const children = allAccounts
|
||||
.filter(isChild(account))
|
||||
.map(accountWithChildren(allAccounts));
|
||||
.map(accountWithChildren(allAccounts))
|
||||
.sort(compareByCreationTime);
|
||||
|
||||
if (children.length === 0) {
|
||||
return account;
|
||||
@ -25,4 +27,5 @@ export const accountWithChildren = (allAccounts: AccountJson[]) => (account: Acc
|
||||
export const buildHierarchy = (accounts: AccountJson[]): AccountWithChildren[] =>
|
||||
accounts
|
||||
.filter(isRoot)
|
||||
.map(accountWithChildren(accounts));
|
||||
.map(accountWithChildren(accounts))
|
||||
.sort(compareByCreationTime);
|
||||
|
||||
31
packages/extension-ui/src/utils/nextDerivationPath.test.ts
Normal file
31
packages/extension-ui/src/utils/nextDerivationPath.test.ts
Normal file
@ -0,0 +1,31 @@
|
||||
// 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 { nextDerivationPath } from './nextDerivationPath';
|
||||
|
||||
describe('Generate Derivation Path', () => {
|
||||
const acc = (address: string, parentAddress?: string): {
|
||||
address: string;
|
||||
parentAddress?: string;
|
||||
} => ({
|
||||
address,
|
||||
parentAddress
|
||||
});
|
||||
|
||||
test('generates path for first masters child', () => {
|
||||
expect(nextDerivationPath([acc('a')], 'a')).toEqual('//0');
|
||||
});
|
||||
|
||||
test('generates path for third masters child', () => {
|
||||
expect(nextDerivationPath([acc('a'), acc('b', 'a'), acc('c', 'a')], 'a')).toEqual('//2');
|
||||
});
|
||||
|
||||
test('generates path for masters child when another root exists', () => {
|
||||
expect(nextDerivationPath([acc('a'), acc('b', 'a'), acc('c', 'a'), acc('d')], 'a')).toEqual('//2');
|
||||
});
|
||||
|
||||
test('generates path for masters grandchild', () => {
|
||||
expect(nextDerivationPath([acc('a'), acc('b', 'a'), acc('c', 'b'), acc('d', 'b')], 'b')).toEqual('//2');
|
||||
});
|
||||
});
|
||||
15
packages/extension-ui/src/utils/nextDerivationPath.ts
Normal file
15
packages/extension-ui/src/utils/nextDerivationPath.ts
Normal file
@ -0,0 +1,15 @@
|
||||
// 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 } from '@polkadot/extension-base/background/types';
|
||||
|
||||
function childrenCount (accounts: AccountJson[], parentAddress: string): number {
|
||||
return accounts.filter((account) => account.parentAddress === parentAddress).length;
|
||||
}
|
||||
|
||||
export const nextDerivationPath = (accounts: AccountJson[], parentAddress: string): string => {
|
||||
const siblingsCount = childrenCount(accounts, parentAddress);
|
||||
|
||||
return `//${siblingsCount}`;
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user