diff --git a/packages/extension-base/src/background/types.ts b/packages/extension-base/src/background/types.ts
index 72ddfa03..a93c04ca 100644
--- a/packages/extension-base/src/background/types.ts
+++ b/packages/extension-base/src/background/types.ts
@@ -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 {
diff --git a/packages/extension-ui/src/Popup/Accounts/Account.tsx b/packages/extension-ui/src/Popup/Accounts/Account.tsx
index 718cfe28..5a2220b5 100644
--- a/packages/extension-ui/src/Popup/Accounts/Account.tsx
+++ b/packages/extension-ui/src/Popup/Accounts/Account.tsx
@@ -41,7 +41,7 @@ function Account ({ address, className, isExternal, parentName }: Props): React.
{!isExternal && (
-
+
)}
{!isExternal && (
diff --git a/packages/extension-ui/src/Popup/Accounts/index.tsx b/packages/extension-ui/src/Popup/Accounts/index.tsx
index 712ca33d..a1908d0f 100644
--- a/packages/extension-ui/src/Popup/Accounts/index.tsx
+++ b/packages/extension-ui/src/Popup/Accounts/index.tsx
@@ -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 {
- const { hierarchy } = useContext(AccountContext);
+ const { hierarchy, master } = useContext(AccountContext);
const mediaAllowed = useContext(MediaContext);
return (
@@ -39,9 +39,9 @@ export default function Accounts (): React.ReactElement {
}
{
/>
{mediaAllowed && (
-
+
)}
diff --git a/packages/extension-ui/src/Popup/CreateAccount/AccountName.tsx b/packages/extension-ui/src/Popup/CreateAccount/AccountName.tsx
index 6a95f95a..2d0d6522 100644
--- a/packages/extension-ui/src/Popup/CreateAccount/AccountName.tsx
+++ b/packages/extension-ui/src/Popup/CreateAccount/AccountName.tsx
@@ -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;
address: string;
+ onBackClick: () => void;
+ onCreate: (name: string, password: string) => void | Promise;
}
-function AccountName ({ address, onCreate }: Props): React.ReactElement {
+function AccountName ({ address, onBackClick, onCreate }: Props): React.ReactElement {
const [name, setName] = useState(null);
const [password, setPassword] = useState(null);
+ const _onCreate = useCallback(() => name && password && onCreate(name, password), [name, password, onCreate]);
+
return (
<>
{
/>
{name && }
{name && password && (
- <>
-
-
-
-
-
- >
+
)}
+
+
+
+
+ Add the account with the generated seed
+
+
>
);
}
diff --git a/packages/extension-ui/src/Popup/CreateAccount/CreateAccount.test.tsx b/packages/extension-ui/src/Popup/CreateAccount/CreateAccount.test.tsx
index 8863378c..53d71e9d 100644
--- a/packages/extension-ui/src/Popup/CreateAccount/CreateAccount.test.tsx
+++ b/packages/extension-ui/src/Popup/CreateAccount/CreateAccount.test.tsx
@@ -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);
});
});
});
diff --git a/packages/extension-ui/src/Popup/CreateAccount/Mnemonic.tsx b/packages/extension-ui/src/Popup/CreateAccount/Mnemonic.tsx
index 32279712..22852b6c 100644
--- a/packages/extension-ui/src/Popup/CreateAccount/Mnemonic.tsx
+++ b/packages/extension-ui/src/Popup/CreateAccount/Mnemonic.tsx
@@ -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 {
onChange={setIsMnemonicSaved}
/>
-
+
>
);
diff --git a/packages/extension-ui/src/Popup/CreateAccount/index.tsx b/packages/extension-ui/src/Popup/CreateAccount/index.tsx
index 1812929f..eba5e2ce 100644
--- a/packages/extension-ui/src/Popup/CreateAccount/index.tsx
+++ b/packages/extension-ui/src/Popup/CreateAccount/index.tsx
@@ -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 (
<>
-
-
-
- {step}
- /2
-
-
-
-
+
{account && (step === 1
? (
)
@@ -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;
-`;
diff --git a/packages/extension-ui/src/Popup/Derive/AddressDropdown.tsx b/packages/extension-ui/src/Popup/Derive/AddressDropdown.tsx
new file mode 100644
index 00000000..7cd964eb
--- /dev/null
+++ b/packages/extension-ui/src/Popup/Derive/AddressDropdown.tsx
@@ -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 {
+ const [isDropdownVisible, setDropdownVisible] = useState(false);
+ const ref = useRef(null);
+
+ const hideDropdown = useCallback(() => setDropdownVisible(false), []);
+ const toggleDropdown = useCallback(() => setDropdownVisible(!isDropdownVisible), [isDropdownVisible]);
+ const selectParent = (newParent: string) => (): void => onSelect(newParent);
+
+ useOutsideClick(ref, hideDropdown);
+
+ return (
+
+
+
+ {allAddresses.map((address) => (
+
+ ))}
+
+
+ );
+}
+
+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;
+ }
+`;
diff --git a/packages/extension-ui/src/Popup/Derive/Derive.test.tsx b/packages/extension-ui/src/Popup/Derive/Derive.test.tsx
new file mode 100644
index 00000000..9316b330
--- /dev/null
+++ b/packages/extension-ui/src/Popup/Derive/Derive.test.tsx
@@ -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(
+
+
+
+
+
+
+
+
+
+
+
+ );
+
+ 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 => {
+ 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');
+ });
+ });
+});
diff --git a/packages/extension-ui/src/Popup/Derive/SelectParent.tsx b/packages/extension-ui/src/Popup/Derive/SelectParent.tsx
new file mode 100644
index 00000000..59b825a9
--- /dev/null
+++ b/packages/extension-ui/src/Popup/Derive/SelectParent.tsx
@@ -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 {
+ const onAction = useContext(ActionContext);
+ const { accounts, hierarchy } = useContext(AccountContext);
+ const [account, setAccount] = useState(null);
+ const [parentPassword, setParentPassword] = useState('');
+ const [isProperParentPassword, setIsProperParentPassword] = useState(false);
+ const [shouldAccountBeDerived, setShouldAccountBeDerived] = useState(true);
+
+ const passwordInputRef = useRef(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 && (
+
+ )}
+
+ {isLocked ? (
+
+ ) : (
+
+ )}
+
+
+
+ {isProperParentPassword && (
+
+ )}
+
+
+
+ {shouldAccountBeDerived
+ ? (
+
+ Create a derived account
+
+ )
+ : (
+ onAction('/account/create')}
+ >
+ Create account from new seed
+
+ )
+ }
+
+ >
+ );
+}
+
+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'};
+`;
diff --git a/packages/extension-ui/src/Popup/Derive/index.tsx b/packages/extension-ui/src/Popup/Derive/index.tsx
index 331ad5e2..d5a0dbe4 100644
--- a/packages/extension-ui/src/Popup/Derive/index.tsx
+++ b/packages/extension-ui/src/Popup/Derive/index.tsx
@@ -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 {
+function Derive ({ isLocked }: Props): React.ReactElement {
const onAction = useContext(ActionContext);
+ const { address: parentAddress } = useParams<{ address: string }>();
const [account, setAccount] = useState(null);
const [name, setName] = useState(null);
const [password, setPassword] = useState(null);
const [parentPassword, setParentPassword] = useState(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 (
<>
-
- {!derivationConfirmed && (
-
+ {!account && (
+
)}
- {!derivationConfirmed && (
- setDerivationConfirmed(true)}
- >
- I want to derive from this account
-
- )}
- {isProperParentPassword && derivationConfirmed && (
+ {account && (
)}
- {isProperParentPassword && derivationConfirmed && parentPassword && name && }
- {isProperParentPassword && derivationConfirmed && account && name && }
- {isProperParentPassword && derivationConfirmed && account && name && password && (
+ {account && name && }
+ {account && name && password && (
+
+ )}
+ {account && (
<>
-
-
+
+
+ Create derived account
+
>
)}
@@ -92,4 +88,4 @@ function Derive ({ match: { params: { address: parentAddress } } }: Props): Reac
);
}
-export default withRouter(Derive);
+export default Derive;
diff --git a/packages/extension-ui/src/Popup/ImportQr.tsx b/packages/extension-ui/src/Popup/ImportQr.tsx
index 26538a4e..b57dc84f 100644
--- a/packages/extension-ui/src/Popup/ImportQr.tsx
+++ b/packages/extension-ui/src/Popup/ImportQr.tsx
@@ -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 {
{name && (
-
+
)}
>
diff --git a/packages/extension-ui/src/Popup/ImportSeed.tsx b/packages/extension-ui/src/Popup/ImportSeed.tsx
index a2fe9acf..421f5107 100644
--- a/packages/extension-ui/src/Popup/ImportSeed.tsx
+++ b/packages/extension-ui/src/Popup/ImportSeed.tsx
@@ -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 {
/>
-
+
+ Add the account with the supplied seed
+
>
)}
@@ -93,7 +97,6 @@ const HeaderWithSmallerMargin = styled(Header)`
const SeedInput = styled(TextAreaWithLabel)`
margin-bottom: 16px;
-
textarea {
height: unset;
}
diff --git a/packages/extension-ui/src/Popup/index.tsx b/packages/extension-ui/src/Popup/index.tsx
index f7e72561..a373e7ce 100644
--- a/packages/extension-ui/src/Popup/index.tsx
+++ b/packages/extension-ui/src/Popup/index.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 { 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 {
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);
const [authRequests, setAuthRequests] = useState(null);
@@ -97,7 +109,7 @@ export default function Popup (): React.ReactElement<{}> {
return (
{accounts && authRequests && metaRequests && signRequests && (
-
+
@@ -109,6 +121,7 @@ export default function Popup (): React.ReactElement<{}> {
+
+
+
diff --git a/packages/extension-ui/src/assets/lock.svg b/packages/extension-ui/src/assets/lock.svg
new file mode 100644
index 00000000..0bb675f5
--- /dev/null
+++ b/packages/extension-ui/src/assets/lock.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/extension-ui/src/assets/unlock.svg b/packages/extension-ui/src/assets/unlock.svg
new file mode 100644
index 00000000..89b8fa2b
--- /dev/null
+++ b/packages/extension-ui/src/assets/unlock.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/extension-ui/src/components/Address.tsx b/packages/extension-ui/src/components/Address.tsx
index 2ae56a5f..e04fcc5f 100644
--- a/packages/extension-ui/src/components/Address.tsx
+++ b/packages/extension-ui/src/components/Address.tsx
@@ -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) || '';
+
return (
- {name || (account && account.name) || ''}
+ {parentName ? (
+ <>
+
+
+ {parentName}
+
+ {displayedName}
+ >
+ ) : (
+ {displayedName}
+ )}
- {formatted || ''}
- {chain?.genesisHash && {chain.name}}
- {parentName && ↳ {parentName}}
+ {formatted || ''}
+ {chain?.genesisHash && {chain.name}}
@@ -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 }>`
diff --git a/packages/extension-ui/src/components/BackButton.tsx b/packages/extension-ui/src/components/BackButton.tsx
new file mode 100644
index 00000000..428be844
--- /dev/null
+++ b/packages/extension-ui/src/components/BackButton.tsx
@@ -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 {
+ return (
+
+
+
+ );
+}
+
+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};
+`;
diff --git a/packages/extension-ui/src/components/Checkbox.tsx b/packages/extension-ui/src/components/Checkbox.tsx
index 9332f345..8a5209a8 100644
--- a/packages/extension-ui/src/components/Checkbox.tsx
+++ b/packages/extension-ui/src/components/Checkbox.tsx
@@ -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};
diff --git a/packages/extension-ui/src/components/Dropdown.tsx b/packages/extension-ui/src/components/Dropdown.tsx
index 9bc307e8..aa9de039 100644
--- a/packages/extension-ui/src/components/Dropdown.tsx
+++ b/packages/extension-ui/src/components/Dropdown.tsx
@@ -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;
diff --git a/packages/extension-ui/src/components/Identicon.tsx b/packages/extension-ui/src/components/Identicon.tsx
index afaeaee0..9e331917 100644
--- a/packages/extension-ui/src/components/Identicon.tsx
+++ b/packages/extension-ui/src/components/Identicon.tsx
@@ -11,13 +11,15 @@ interface Props {
className?: string;
prefix?: number;
value?: string | null;
+ onCopy?: () => void;
}
-function Identicon ({ className, iconTheme, prefix, value }: Props): React.ReactElement {
+function Identicon ({ className, iconTheme, onCopy, prefix, value }: Props): React.ReactElement {
return (
{
+function InputWithLabel ({ className, defaultValue, disabled, isError, isFocused, isReadOnly, label, onBlur, onChange, placeholder, type = 'text', value }: Props): React.ReactElement {
const _onChange = useCallback(
({ target: { value } }: React.ChangeEvent): 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}
diff --git a/packages/extension-ui/src/components/NextStepButton.tsx b/packages/extension-ui/src/components/NextStepButton.tsx
new file mode 100644
index 00000000..09cab00c
--- /dev/null
+++ b/packages/extension-ui/src/components/NextStepButton.tsx
@@ -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;
+
+export default function NextStepButton ({ children, ...props }: Props): React.ReactElement {
+ return (
+
+ );
+}
+
+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};
+`;
diff --git a/packages/extension-ui/src/components/ValidatedInput.tsx b/packages/extension-ui/src/components/ValidatedInput.tsx
index 8967e8a9..3aef0ecb 100644
--- a/packages/extension-ui/src/components/ValidatedInput.tsx
+++ b/packages/extension-ui/src/components/ValidatedInput.tsx
@@ -18,18 +18,17 @@ type Props = T & {
validator: Validator;
component: React.ComponentType;
onValidatedChange: (value: string | null) => void;
+ defaultValue?: string;
}
-function ValidatedInput ({ className, component: Input, onValidatedChange, validator, ...props }: Props): React.ReactElement> {
- const [value, setValue] = useState('');
+function ValidatedInput ({ className, component: Input, defaultValue, onValidatedChange, validator, ...props }: Props): React.ReactElement> {
+ const [value, setValue] = useState(defaultValue || '');
const [wasMounted, setWasMounted] = useState(false);
const [validationResult, setValidationResult] = useState>(Result.ok(''));
useEffect(() => {
if (!wasMounted) {
setWasMounted(true);
-
- return;
}
(async (): Promise => {
diff --git a/packages/extension-ui/src/components/contexts.tsx b/packages/extension-ui/src/components/contexts.tsx
index caef1ab8..825f0014 100644
--- a/packages/extension-ui/src/components/contexts.tsx
+++ b/packages/extension-ui/src/components/contexts.tsx
@@ -12,7 +12,8 @@ const noop = (): void => undefined;
const AccountContext = React.createContext({
accounts: [],
- hierarchy: []
+ hierarchy: [],
+ master: undefined
});
const ActionContext = React.createContext<(to?: string) => void>(noop);
const AuthorizeReqContext = React.createContext([]);
diff --git a/packages/extension-ui/src/components/index.ts b/packages/extension-ui/src/components/index.ts
index aaa1df8d..fc0281fa 100644
--- a/packages/extension-ui/src/components/index.ts
+++ b/packages/extension-ui/src/components/index.ts
@@ -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';
diff --git a/packages/extension-ui/src/components/themes.ts b/packages/extension-ui/src/components/themes.ts
index 7e4feb9a..5540f2c1 100644
--- a/packages/extension-ui/src/components/themes.ts
+++ b/packages/extension-ui/src/components/themes.ts
@@ -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',
diff --git a/packages/extension-ui/src/partials/DerivationPath.tsx b/packages/extension-ui/src/partials/DerivationPath.tsx
index dc680bd6..db9938f7 100644
--- a/packages/extension-ui/src/partials/DerivationPath.tsx
+++ b/packages/extension-ui/src/partials/DerivationPath.tsx
@@ -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 {
- const [path, setPath] = useState('');
+function DerivationPath ({ className, defaultPath, onChange, parentAddress, parentPassword }: Props): React.ReactElement {
+ const [path, setPath] = useState(defaultPath);
+ const [isDisabled, setIsDisabled] = useState(false);
- const isPathValid = useCallback(async (path: string): Promise> => {
+ const isPathValid = useCallback(async (newPath: string): Promise> => {
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 (
-
+
+
+
+
+
+
+ {!isDisabled ? : }
+
+
+
);
}
+const PathField = styled.div`
+ display: flex;
+ flex-direction: row;
+`;
+
+interface UnlockableComponentProps {
+ isUnlocked: boolean;
+}
+
+const PathInput = styled.div`
+ 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;
diff --git a/packages/extension-ui/src/Popup/Derive/Step.tsx b/packages/extension-ui/src/partials/HeaderWithSteps.tsx
similarity index 53%
rename from packages/extension-ui/src/Popup/Derive/Step.tsx
rename to packages/extension-ui/src/partials/HeaderWithSteps.tsx
index e96c93e6..21c40ea5 100644
--- a/packages/extension-ui/src/Popup/Derive/Step.tsx
+++ b/packages/extension-ui/src/partials/HeaderWithSteps.tsx
@@ -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 {
+export default function HeaderWithSteps ({ step, text }: Props): React.ReactElement {
+ const onAction = useContext(ActionContext);
+
+ const _onCancel = useCallback(() => {
+ onAction('/');
+ }, [onAction]);
+
return (
-
- Derive new account
+
+
+
{step}
/2
- >
- }
- />
+
+
+
+
);
}
+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;
diff --git a/packages/extension-ui/src/partials/index.ts b/packages/extension-ui/src/partials/index.ts
index 65e699d9..a0602e3b 100644
--- a/packages/extension-ui/src/partials/index.ts
+++ b/packages/extension-ui/src/partials/index.ts
@@ -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';
diff --git a/packages/extension-ui/src/utils/buildHierarchy.test.ts b/packages/extension-ui/src/utils/buildHierarchy.test.ts
index 37f3e8a9..e3979a65 100644
--- a/packages/extension-ui/src/utils/buildHierarchy.test.ts
+++ b/packages/extension-ui/src/utils/buildHierarchy.test.ts
@@ -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')]
+ );
+ });
});
diff --git a/packages/extension-ui/src/utils/buildHierarchy.ts b/packages/extension-ui/src/utils/buildHierarchy.ts
index 4dfd9e6b..193f37d2 100644
--- a/packages/extension-ui/src/utils/buildHierarchy.ts
+++ b/packages/extension-ui/src/utils/buildHierarchy.ts
@@ -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);
diff --git a/packages/extension-ui/src/utils/nextDerivationPath.test.ts b/packages/extension-ui/src/utils/nextDerivationPath.test.ts
new file mode 100644
index 00000000..87a1c158
--- /dev/null
+++ b/packages/extension-ui/src/utils/nextDerivationPath.test.ts
@@ -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');
+ });
+});
diff --git a/packages/extension-ui/src/utils/nextDerivationPath.ts b/packages/extension-ui/src/utils/nextDerivationPath.ts
new file mode 100644
index 00000000..72d51f5c
--- /dev/null
+++ b/packages/extension-ui/src/utils/nextDerivationPath.ts
@@ -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}`;
+};