From ed36ed24bb99846a6f837a5349cbf6974ca3d39f Mon Sep 17 00:00:00 2001 From: Ivan Rukhavets Date: Mon, 11 May 2020 13:27:00 +0300 Subject: [PATCH] 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 Co-authored-by: VladStarostenko * Add arrows to buttons (#77) * Add parent label (#80) * refactor parent label * refactor parent label * refactor parent label Co-authored-by: Ivan Rukhavets * 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 --- .../extension-base/src/background/types.ts | 2 + .../src/Popup/Accounts/Account.tsx | 2 +- .../extension-ui/src/Popup/Accounts/index.tsx | 10 +- .../src/Popup/CreateAccount/AccountName.tsx | 42 ++-- .../CreateAccount/CreateAccount.test.tsx | 26 +-- .../src/Popup/CreateAccount/Mnemonic.tsx | 6 +- .../src/Popup/CreateAccount/index.tsx | 56 +----- .../src/Popup/Derive/AddressDropdown.tsx | 93 +++++++++ .../src/Popup/Derive/Derive.test.tsx | 186 ++++++++++++++++++ .../src/Popup/Derive/SelectParent.tsx | 124 ++++++++++++ .../extension-ui/src/Popup/Derive/index.tsx | 98 +++++---- packages/extension-ui/src/Popup/ImportQr.tsx | 8 +- .../extension-ui/src/Popup/ImportSeed.tsx | 11 +- packages/extension-ui/src/Popup/index.tsx | 17 +- .../src/assets/arrowParentLabel.svg | 3 + packages/extension-ui/src/assets/lock.svg | 3 + packages/extension-ui/src/assets/unlock.svg | 3 + .../extension-ui/src/components/Address.tsx | 66 +++++-- .../src/components/BackButton.tsx | 38 ++++ .../extension-ui/src/components/Checkbox.tsx | 4 +- .../extension-ui/src/components/Dropdown.tsx | 4 +- .../extension-ui/src/components/Identicon.tsx | 4 +- .../src/components/InputWithLabel.tsx | 4 +- .../src/components/NextStepButton.tsx | 32 +++ .../src/components/ValidatedInput.tsx | 7 +- .../extension-ui/src/components/contexts.tsx | 3 +- packages/extension-ui/src/components/index.ts | 3 + .../extension-ui/src/components/themes.ts | 6 +- .../src/partials/DerivationPath.tsx | 106 ++++++++-- .../Step.tsx => partials/HeaderWithSteps.tsx} | 46 +++-- packages/extension-ui/src/partials/index.ts | 1 + .../src/utils/buildHierarchy.test.ts | 22 ++- .../extension-ui/src/utils/buildHierarchy.ts | 7 +- .../src/utils/nextDerivationPath.test.ts | 31 +++ .../src/utils/nextDerivationPath.ts | 15 ++ 35 files changed, 873 insertions(+), 216 deletions(-) create mode 100644 packages/extension-ui/src/Popup/Derive/AddressDropdown.tsx create mode 100644 packages/extension-ui/src/Popup/Derive/Derive.test.tsx create mode 100644 packages/extension-ui/src/Popup/Derive/SelectParent.tsx create mode 100644 packages/extension-ui/src/assets/arrowParentLabel.svg create mode 100644 packages/extension-ui/src/assets/lock.svg create mode 100644 packages/extension-ui/src/assets/unlock.svg create mode 100644 packages/extension-ui/src/components/BackButton.tsx create mode 100644 packages/extension-ui/src/components/NextStepButton.tsx rename packages/extension-ui/src/{Popup/Derive/Step.tsx => partials/HeaderWithSteps.tsx} (53%) create mode 100644 packages/extension-ui/src/utils/nextDerivationPath.test.ts create mode 100644 packages/extension-ui/src/utils/nextDerivationPath.ts 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. Rename {!isExternal && ( - Derive New Account + Derive New Account )} {!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}`; +};