Account sidebar - single button for editing name and tags (#6049)

* WIP - merge edition of account name and tags

* [WIP] - tests

* Apply test improvements

* Improve tests

* Improve tests and use useCallback

* WIP - add cancel button

* Outside click

* Add more tests

* Apply styling

* Add opacity on not editable elements

* Remove unnecessary code and fix type of sidebar ref

* Disable eslint rule

* Place edit button within account menu buttons

* Adjust test setup

* Ato-lint fixes

* Update styles

* Apply review suggestions

* Remove unused styles and change type of sidebar ref

* Remove onCancel from useAccountInfo hook

* Fix issue with edit button

* Apply review suggestions, remove tests

* Adjust tests

* Simplify tests

* Apply lint fixes

* Change layout of buttons

Co-authored-by: MiZiet <michalzietalak@o2.pl>
Co-authored-by: Jaco Greeff <jacogr@gmail.com>
This commit is contained in:
Ewa Kowalska 2021-09-09 19:40:09 +02:00 committed by GitHub
parent fb30cdd356
commit ad8226a5c3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 643 additions and 179 deletions

View File

@ -32,6 +32,7 @@
"Approve this call hash": "Approve this call hash",
"Backup": "Backup",
"Backup account": "Backup account",
"Cancel": "Cancel",
"Cancel this call hash": "Cancel this call hash",
"Change": "Change",
"Change account password": "Change account password",
@ -55,6 +56,7 @@
"Determines what cryptography will be used to create this account. Note that to validate on Polkadot, the session account must use \"ed25519\".": "Determines what cryptography will be used to create this account. Note that to validate on Polkadot, the session account must use \"ed25519\".",
"Development": "Development",
"Download": "Download",
"Edit": "Edit",
"Either approve or reject this call.": "Either approve or reject this call.",
"Ensure that the extension has accounts, some accounts are visible globally and available for this chain and that you gave the application permission to access accounts from the extension to use them.": "Ensure that the extension has accounts, some accounts are visible globally and available for this chain and that you gave the application permission to access accounts from the extension to use them.",
"Ensure that you utilized the \"Save\" functionality before using a generated address to receive funds. Without saving the address any funds and the associated seed any funds sent to it will be lost.": "Ensure that you utilized the \"Save\" functionality before using a generated address to receive funds. Without saving the address any funds and the associated seed any funds sent to it will be lost.",

View File

@ -3,13 +3,13 @@
import type { Balance } from '@polkadot/types/interfaces';
import { fireEvent, screen, within } from '@testing-library/react';
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
import BN from 'bn.js';
import { DeriveBalancesAll, DeriveStakingAccount } from '@polkadot/api-derive/types';
import i18next from '@polkadot/react-components/i18n';
import toShortAddress from '@polkadot/react-components/util/toShortAddress';
import { UseAccountInfo } from '@polkadot/react-hooks/types';
import { AddressFlags, UseAccountInfo } from '@polkadot/react-hooks/types';
import { balanceOf } from '@polkadot/test-support/creation/balance';
import { makeStakingLedger as ledger } from '@polkadot/test-support/creation/stakingInfo/stakingLedger';
import { MemoryStore } from '@polkadot/test-support/keyring';
@ -17,6 +17,8 @@ import { keyring } from '@polkadot/ui-keyring';
import { KeyringJson$Meta } from '@polkadot/ui-keyring/types';
import { AccountOverrides, Override } from '../../test/hooks/default';
import { AccountRow } from '../../test/pageElements/AccountRow';
import { Sidebar } from '../../test/pageElements/Sidebar';
import { AccountsPage, format } from '../../test/pages/accountsPage';
describe('Accounts page', () => {
@ -374,6 +376,121 @@ describe('Accounts page', () => {
await accountsPage.checkOrderAndRowsColoring([1, 2, 3]);
});
});
describe('sidebar', () => {
const initialName = 'INITIAL_NAME';
const newName = 'NEW_NAME';
const defaultTag = 'Default';
const nameInputNotFoundError = 'Unable to find an element by: [data-testid="name-input"]';
let accountRows: AccountRow[];
let sideBar: Sidebar;
describe('changes name', () => {
beforeEach(async () => {
accountsPage.renderPage([[aliceAddress, anAccountWithMeta({ isDevelopment: false, name: initialName })]]);
sideBar = await openSidebarForAccountRow(0);
await sideBar.changeAccountName(newName);
});
it('within keyring', () => {
const changedAccount = keyring.getAccount(aliceAddress);
expect(changedAccount?.meta?.name).toEqual(newName);
});
it('within sidebar', async () => {
await sideBar.assertAccountName(newName);
});
it('within account row', async () => {
await accountRows[0].assertAccountName(newName);
});
});
it('cannot be edited if edit button has not been pressed', async () => {
await sideBar.clickByText('no tags');
expect(sideBar.queryByRole('combobox')).toBeFalsy();
await expect(sideBar.typeAccountName(newName)).rejects.toThrowError(nameInputNotFoundError);
});
it('when isEditable is false account name is not editable', async () => {
renderAccountsWithDefaultAddresses(
anAccountWithInfo({ flags: { isEditable: false } as AddressFlags })
);
sideBar = await openSidebarForAccountRow(0);
sideBar.edit();
await expect(sideBar.typeAccountName(newName)).rejects.toThrowError(nameInputNotFoundError);
});
describe('on edit cancel', () => {
beforeEach(async () => {
accountsPage.renderPage([[aliceAddress, anAccountWithMeta({ isDevelopment: false, name: initialName, tags: [] })]]);
sideBar = await openSidebarForAccountRow(0);
await sideBar.assertTags('no tags');
sideBar.edit();
});
it('restores tags and name to state from keyring', async () => {
await sideBar.typeAccountName(newName);
await sideBar.selectTag(defaultTag);
sideBar.cancel();
await sideBar.assertTags('no tags');
await sideBar.assertAccountName(initialName);
});
it('Cancel button disappears', () => {
sideBar.cancel();
expect(sideBar.queryByRole('button', { name: 'Cancel' })).toBeFalsy();
});
});
describe('outside click', () => {
beforeEach(async () => {
renderAccountsWithDefaultAddresses(
anAccountWithMeta({ name: 'alice' }),
anAccountWithMeta({ name: 'bob' })
);
sideBar = await openSidebarForAccountRow(0);
sideBar.edit();
});
it('cancels editing', async () => {
await sideBar.typeAccountName(newName);
await sideBar.selectTag(defaultTag);
fireEvent.click(await screen.findByText('accounts'));
await sideBar.assertTags('no tags');
await sideBar.assertAccountName('ALICE');
expect(sideBar.queryByRole('button', { name: 'Cancel' })).toBeFalsy();
});
it('within sidebar does not cancel editing', async () => {
await sideBar.clickByText('Tags');
expect(sideBar.queryByRole('button', { name: 'Cancel' })).toBeTruthy();
});
it('cancels editing and changes name when opening sidebar for another account', async () => {
await waitFor(() => sideBar.assertAccountInput('alice'));
sideBar = await openSidebarForAccountRow(1);
await sideBar.assertAccountName('BOB');
});
});
async function openSidebarForAccountRow (rowIndex: number) {
accountRows = await accountsPage.findAccountRows();
return accountRows[rowIndex].openSidebar();
}
});
});
const anAccount = (): AccountOverrides => ({});

View File

@ -0,0 +1,136 @@
// Copyright 2017-2021 @polkadot/app-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React, { useCallback } from 'react';
import styled from 'styled-components';
import Transfer from '@polkadot/app-accounts/modals/Transfer';
import { useTranslation } from '@polkadot/app-accounts/translate';
import { Button } from '@polkadot/react-components';
import { useToggle } from '@polkadot/react-hooks';
import { AddressFlags } from '@polkadot/react-hooks/types';
interface Props {
className?: string;
flags: AddressFlags;
isEditingName: boolean;
isEditing: boolean;
toggleIsEditingName: () => void;
toggleIsEditingTags: () => void;
onCancel: () => void;
onSaveName: () => void;
onSaveTags: () => void;
onForgetAddress: () => void;
onUpdateName: () => void;
recipientId: string;
}
function AccountMenuButtons ({ className = '', flags, isEditing, isEditingName, onCancel, onForgetAddress, onSaveName, onSaveTags, onUpdateName, recipientId, toggleIsEditingName, toggleIsEditingTags }: Props) {
const { t } = useTranslation();
const [isTransferOpen, toggleIsTransferOpen] = useToggle();
const _onForgetAddress = useCallback(
(): void => {
onForgetAddress();
onUpdateName && onUpdateName();
},
[onForgetAddress, onUpdateName]
);
const toggleIsEditing = useCallback(() => {
flags.isEditable && toggleIsEditingName();
toggleIsEditingTags();
}, [flags.isEditable, toggleIsEditingName, toggleIsEditingTags]);
const _onUpdateName = useCallback(
(): void => {
onSaveName();
onUpdateName && onUpdateName();
},
[onSaveName, onUpdateName]
);
const updateName = useCallback(() => {
if (isEditingName && (flags.isInContacts || flags.isOwned)) {
_onUpdateName();
toggleIsEditingName();
}
}, [isEditingName, flags.isInContacts, flags.isOwned, _onUpdateName, toggleIsEditingName]);
const onEdit = useCallback(() => {
if (isEditing) {
updateName();
onSaveTags();
}
toggleIsEditing();
}, [isEditing, toggleIsEditing, updateName, onSaveTags]);
return (
<div className={`${className} ui--AddressMenu-buttons`}>
{isEditing
? (
<Button.Group>
<Button
icon='times'
label={t<string>('Cancel')}
onClick={onCancel}
/>
<Button
icon='save'
label={t<string>('Save')}
onClick={onEdit}
/>
</Button.Group>
)
: (
<Button.Group>
<Button
icon='paper-plane'
isDisabled={isEditing}
label={t<string>('Send')}
onClick={toggleIsTransferOpen}
/>
{!flags.isOwned && !flags.isInContacts && (
<Button
icon='plus'
isDisabled={isEditing}
label={t<string>('Save')}
onClick={_onUpdateName}
/>
)}
{!flags.isOwned && flags.isInContacts && (
<Button
icon='ban'
isDisabled={isEditing}
label={t<string>('Remove')}
onClick={_onForgetAddress}
/>
)}
<Button
icon='edit'
label={t<string>('Edit')}
onClick={onEdit}
/>
</Button.Group>)}
{isTransferOpen && (
<Transfer
key='modal-transfer'
onClose={toggleIsTransferOpen}
recipientId={recipientId}
/>
)}
</div>
);
}
export default React.memo(styled(AccountMenuButtons)`
width: 100%;
.ui--Button-Group {
display: flex;
flex-direction: row;
justify-content: space-around;
margin-bottom: 0;
}
`);

View File

@ -1,55 +1,44 @@
// Copyright 2017-2021 @polkadot/app-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React, { useCallback } from 'react';
import React, { useRef, useState } from 'react';
import styled from 'styled-components';
import { AccountName, Button, Icon, IdentityIcon, Input, LinkExternal, Sidebar, Tags } from '@polkadot/react-components';
import SidebarEditableSection from '@polkadot/app-accounts/Sidebar/SidebarEditableSection';
import { IdentityIcon, LinkExternal, Sidebar } from '@polkadot/react-components';
import { colorLink } from '@polkadot/react-components/styles/theme';
import { useAccountInfo, useToggle } from '@polkadot/react-hooks';
import { useAccountInfo } from '@polkadot/react-hooks';
import Transfer from '../modals/Transfer';
import { useTranslation } from '../translate';
import Balances from './Balances';
import Flags from './Flags';
import Identity from './Identity';
import Multisig from './Multisig';
interface Props {
address: string;
className?: string;
dataTestId?: string;
onClose: () => void;
onUpdateName: () => void;
}
function FullSidebar ({ address, className = '', onClose, onUpdateName }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { accountIndex, flags, identity, isEditingName, isEditingTags, meta, name, onForgetAddress, onSaveName, onSaveTags, setName, setTags, tags, toggleIsEditingName, toggleIsEditingTags } = useAccountInfo(address);
const [isTransferOpen, toggleIsTransferOpen] = useToggle();
function FullSidebar ({ address, className = '', dataTestId, onClose, onUpdateName }: Props): React.ReactElement<Props> {
const [inEditMode, setInEditMode] = useState<boolean>(false);
const { accountIndex, flags, identity, meta } = useAccountInfo(address);
const _onForgetAddress = useCallback(
(): void => {
onForgetAddress();
onUpdateName && onUpdateName();
},
[onForgetAddress, onUpdateName]
);
const _onUpdateName = useCallback(
(): void => {
onSaveName();
onUpdateName && onUpdateName();
},
[onSaveName, onUpdateName]
);
const ref = useRef<HTMLDivElement>(null);
return (
<Sidebar
className={className}
className={`${className}${inEditMode ? ' inEditMode' : ''}`}
dataTestId={dataTestId}
onClose={onClose}
position='right'
sidebarRef={ref}
>
<div className='ui--AddressMenu-header'>
<div
className='ui--AddressMenu-header'
data-testid='sidebar-address-menu'
>
<IdentityIcon
size={80}
value={address}
@ -62,84 +51,12 @@ function FullSidebar ({ address, className = '', onClose, onUpdateName }: Props)
{accountIndex}
</div>
)}
<AccountName
onClick={(flags.isEditable && !isEditingName) ? toggleIsEditingName : undefined}
override={
isEditingName
? (
<Input
autoFocus
className='name--input'
defaultValue={name}
onBlur={(flags.isInContacts || flags.isOwned) ? _onUpdateName : undefined}
onChange={setName}
withLabel={false}
/>
)
: flags.isEditable
? (name.toUpperCase() || t<string>('<unknown>'))
: undefined
}
value={address}
withSidebar={false}
>
{(!isEditingName && flags.isEditable) && (
<Icon
className='inline-icon'
icon='edit'
/>
)}
</AccountName>
<div className='ui--AddressMenu-tags'>
<Tags
isEditable
isEditing={isEditingTags}
onChange={setTags}
onSave={onSaveTags}
onToggleIsEditing={toggleIsEditingTags}
size='tiny'
value={tags}
withTitle
/>
</div>
<Flags flags={flags} />
<div className='ui--AddressMenu-buttons'>
<Button.Group>
<Button
icon='paper-plane'
label={t<string>('Send')}
onClick={toggleIsTransferOpen}
/>
{flags.isOwned && (
<Button
icon='check'
isBasic
label={t<string>('Owned')}
/>
)}
{!flags.isOwned && !flags.isInContacts && (
<Button
icon='plus'
label={t<string>('Save')}
onClick={_onUpdateName}
/>
)}
{!flags.isOwned && flags.isInContacts && (
<Button
icon='ban'
label={t<string>('Remove')}
onClick={_onForgetAddress}
/>
)}
</Button.Group>
{isTransferOpen && (
<Transfer
key='modal-transfer'
onClose={toggleIsTransferOpen}
recipientId={address}
/>
)}
</div>
<SidebarEditableSection
address={address}
isBeingEdited={setInEditMode}
onUpdateName={onUpdateName}
sidebarRef={ref}
/>
</div>
<Balances address={address} />
<Identity
@ -288,12 +205,13 @@ export default React.memo(styled(FullSidebar)`
margin: 0 !important;
> input {
padding: 0 !important;
background: rgba(230, 230, 230, 0.8) !important;
border: 0 !important;
border-radius: 0 !important;
box-shadow: 0 3px 3px rgba(0,0,0,.2);
}
}
}
&.inEditMode {
.ui--AddressMenu-flags {
opacity: 60%;
}
}
`);

View File

@ -0,0 +1,99 @@
// Copyright 2017-2021 @polkadot/page-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React, { useCallback, useEffect } from 'react';
import AccountMenuButtons from '@polkadot/app-accounts/Sidebar/AccountMenuButtons';
import Flags from '@polkadot/app-accounts/Sidebar/Flags';
import { useTranslation } from '@polkadot/app-accounts/translate';
import { AccountName, Input, Tags } from '@polkadot/react-components';
import { useAccountInfo, useOutsideClick } from '@polkadot/react-hooks';
import { keyring } from '@polkadot/ui-keyring';
interface Props {
address: string;
isBeingEdited: (arg: boolean) => void;
onUpdateName: () => void;
sidebarRef: React.RefObject<HTMLDivElement>;
}
function SidebarEditableSection ({ address, isBeingEdited, onUpdateName, sidebarRef }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { flags, isEditing, isEditingName, isEditingTags, name, onForgetAddress, onSaveName, onSaveTags, setIsEditingName, setIsEditingTags, setName, setTags, tags, toggleIsEditingName, toggleIsEditingTags } = useAccountInfo(address);
useEffect(() => {
isBeingEdited(isEditing());
}, [isBeingEdited, isEditing]);
const onCancel = useCallback(
(): void => {
if (isEditing()) {
try {
const accountOrAddress = keyring.getAccount(address) || keyring.getAddress(address);
setName(accountOrAddress?.meta.name || '');
setTags(accountOrAddress?.meta.tags ? (accountOrAddress.meta.tags as string[]).sort() : []);
setIsEditingName(false);
setIsEditingTags(false);
} catch (error) {
// ignore
}
}
}, [isEditing, setName, setTags, setIsEditingName, setIsEditingTags, address]);
useOutsideClick([sidebarRef], onCancel);
return (
<>
<AccountName
override={
isEditingName
? (
<Input
className='name--input'
defaultValue={name}
label='name-input'
onChange={setName}
withLabel={false}
/>
)
: flags.isEditable
? (name.toUpperCase() || t<string>('<unknown>'))
: undefined
}
value={address}
withSidebar={false}
/>
<div
className='ui--AddressMenu-tags'
data-testid='sidebar-tags'
>
<Tags
isEditable
isEditing={isEditingTags}
onChange={setTags}
size='tiny'
value={tags}
withEditButton={false}
withTitle
/>
</div>
<Flags flags={flags} />
<AccountMenuButtons
flags={flags}
isEditing={isEditing()}
isEditingName={isEditingName}
onCancel={onCancel}
onForgetAddress={onForgetAddress}
onSaveName={onSaveName}
onSaveTags={onSaveTags}
onUpdateName={onUpdateName}
recipientId={address}
toggleIsEditingName={toggleIsEditingName}
toggleIsEditingTags={toggleIsEditingTags}
/>
</>
);
}
export default React.memo(SidebarEditableSection);

View File

@ -29,6 +29,7 @@ function AccountSidebar ({ children }: Props): React.ReactElement<Props> {
{address && (
<Sidebar
address={address}
dataTestId='account-sidebar'
onClose={onClose}
onUpdateName={onUpdateName}
/>

View File

@ -0,0 +1,70 @@
// Copyright 2017-2021 @polkadot/page-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import { fireEvent, screen, within } from '@testing-library/react';
import { Balance } from '@polkadot/types/interfaces';
import { format } from '../pages/accountsPage';
import { Sidebar } from './Sidebar';
// utility wrapper over an account item in accounts table, serves basic assertions about an account row
export class AccountRow {
public primaryRow: HTMLElement;
public detailsRow: HTMLElement;
constructor (primaryRow: HTMLElement, detailsRow: HTMLElement) {
this.primaryRow = primaryRow;
this.detailsRow = detailsRow;
}
async assertBalancesTotal (expected: Balance): Promise<void> {
const balanceActual = await within(this.primaryRow).findByTestId('balance-summary');
const balanceExpected = format(expected);
expect(balanceActual).toHaveTextContent(balanceExpected);
}
async assertBalancesDetails (expected: {name: string, amount: Balance}[]): Promise<void> {
for (const expectedBalanceDetailsItem of expected) {
const labelElement = await within(this.detailsRow).findByText(expectedBalanceDetailsItem.name);
const balanceElement = labelElement.nextSibling;
const amount = format(expectedBalanceDetailsItem.amount);
expect(balanceElement).toHaveTextContent(amount);
}
}
async assertAccountName (expectedName: string): Promise<void> {
const accountName = await within(this.primaryRow).findByTestId('account-name');
expect(accountName).toHaveTextContent(expectedName);
}
async assertParentAccountName (expectedParentAccount: string): Promise<void> {
const parentAccount = await within(this.primaryRow).findByTestId('parent');
expect(parentAccount).toHaveTextContent(expectedParentAccount);
}
async assertTags (tagsContent: string): Promise<void> {
const tagsActual = await within(this.detailsRow).findByTestId('tags');
expect(tagsActual).toHaveTextContent(tagsContent);
}
async assertShortAddress (expectedShortAddress: string): Promise<void> {
const actualShortAddress = await within(this.primaryRow).findByTestId('short-address');
expect(actualShortAddress).toHaveTextContent(expectedShortAddress);
}
async openSidebar (): Promise<Sidebar> {
const accountName = await within(this.primaryRow).findByTestId('account-name');
fireEvent.click(accountName);
return new Sidebar(await screen.findByTestId('account-sidebar'));
}
}

View File

@ -0,0 +1,124 @@
// Copyright 2017-2021 @polkadot/page-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import { fireEvent, within } from '@testing-library/react';
export class Sidebar {
public sidebar: HTMLElement
constructor (sidebar: HTMLElement) {
this.sidebar = sidebar;
}
async changeAccountName (accountName: string): Promise<void> {
this.edit();
await this.typeAccountName(accountName);
this.save();
}
async typeAccountName (accountName: string): Promise<void> {
const accountNameInput = await this.findByTestId('name-input');
fireEvent.change(accountNameInput, { target: { value: accountName } });
}
async selectTag (tagName: string): Promise<void> {
const tagsCombobox = this.openTagsDropdown();
const tagOptions = await within(tagsCombobox).findAllByRole('option');
const tag = tagOptions.find((tag) => tag.textContent === tagName) as HTMLElement;
fireEvent.click(tag);
}
async assertAccountInput (expectedInput: string): Promise<void> {
const sideBarName = await this.findByTestId('name-input');
expect(sideBarName).toHaveProperty('value', expectedInput);
}
async assertAccountName (expectedAccountName: string): Promise<void> {
const sideBarName = await this.findByTestId('account-name');
expect(sideBarName).toHaveTextContent(expectedAccountName);
}
async assertTags (tagsContent: string): Promise<void> {
const sideBarTags = await this.findByTestId('sidebar-tags');
expect(sideBarTags).toHaveTextContent(tagsContent);
}
close (): Promise<void> {
return this.clickByTestId('close-sidebar-button');
}
cancel (): void {
this.clickButton('Cancel');
}
edit (): void {
this.clickButton('Edit');
}
save (): void {
this.clickButton('Save');
}
async clickByText (text: string): Promise<void> {
const htmlElement = await this.findByText(text);
fireEvent.click(htmlElement);
}
async clickByTestId (testId: string): Promise<void> {
const htmlElement = await this.findByTestId(testId);
fireEvent.click(htmlElement);
}
async findByText (text: string): Promise<HTMLElement> {
return within(this.sidebar).findByText(text);
}
async findByTestId (testId: string): Promise<HTMLElement> {
return within(this.sidebar).findByTestId(testId);
}
async findByRole (role: string): Promise<HTMLElement> {
return within(this.sidebar).findByRole(role);
}
async findAllByRole (role: string): Promise<HTMLElement[]> {
return within(this.sidebar).findAllByRole(role);
}
getByTestId (testId: string): HTMLElement {
return within(this.sidebar).getByTestId(testId);
}
getByRole (roleName: string, options?: Record<string, unknown>): HTMLElement {
return within(this.sidebar).getByRole(roleName, options);
}
queryByRole (roleName: string, options?: Record<string, unknown>): HTMLElement | null {
return within(this.sidebar).queryByRole(roleName, options);
}
queryByTestId (testId: string): HTMLElement | null {
return within(this.sidebar).queryByTestId(testId);
}
private clickButton (buttonName: string) {
const saveButton = this.getByRole('button', { name: buttonName });
fireEvent.click(saveButton);
}
private openTagsDropdown (): HTMLElement {
const tagsDropdown = this.getByRole('combobox', { expanded: false });
fireEvent.click(tagsDropdown);
return tagsDropdown;
}
}

View File

@ -8,18 +8,21 @@ import { MemoryRouter } from 'react-router-dom';
import { ThemeProvider } from 'styled-components';
import { balance } from '@polkadot/app-accounts/Accounts/index.spec';
import AccountSidebar from '@polkadot/app-accounts/Sidebar';
import { lightTheme } from '@polkadot/apps/themes';
import { POLKADOT_GENESIS } from '@polkadot/apps-config';
import { ApiContext } from '@polkadot/react-api';
import { ApiProps } from '@polkadot/react-api/types';
import { QueueProvider } from '@polkadot/react-components/Status/Context';
import { PartialQueueTxExtrinsic, QueueProps, QueueTxExtrinsicAdd } from '@polkadot/react-components/Status/types';
import { UseAccountInfo } from '@polkadot/react-hooks/types';
import { TypeRegistry } from '@polkadot/types/create';
import { Balance, BlockNumber } from '@polkadot/types/interfaces';
import { keyring } from '@polkadot/ui-keyring';
import { formatBalance } from '@polkadot/util';
import { AccountOverrides, mockAccountHooks } from '../hooks/default';
import { AccountRow } from '../pageElements/AccountRow';
import Overview from '../pages/../../src/Accounts/index';
let queueExtrinsic: (value: PartialQueueTxExtrinsic) => void;
@ -31,60 +34,10 @@ function noop (): void {
class NotYetRendered extends Error {
}
// utility wrapper over an account item in accounts table, serves basic assertions about an account row
export class AccountRow {
public primaryRow: HTMLElement;
public detailsRow: HTMLElement;
constructor (primaryRow: HTMLElement, detailsRow: HTMLElement) {
this.primaryRow = primaryRow;
this.detailsRow = detailsRow;
}
async assertBalancesTotal (expected: Balance): Promise<void> {
const balanceActual = await within(this.primaryRow).findByTestId('balance-summary');
const balanceExpected = format(expected);
expect(balanceActual).toHaveTextContent(balanceExpected);
}
async assertBalancesDetails (expected: {name: string, amount: Balance}[]): Promise<void> {
for (const expectedBalanceDetailsItem of expected) {
const labelElement = await within(this.detailsRow).findByText(expectedBalanceDetailsItem.name);
const balanceElement = labelElement.nextSibling;
const amount = format(expectedBalanceDetailsItem.amount);
expect(balanceElement).toHaveTextContent(amount);
}
}
async assertParentAccountName (expectedParentAccount: string): Promise<void> {
const parentAccount = await within(this.primaryRow).findByTestId('parent');
expect(parentAccount).toHaveTextContent(expectedParentAccount);
}
async assertTags (tagsContent: string): Promise<void> {
const tagsActual = await within(this.detailsRow).findByTestId('tags');
expect(tagsActual).toHaveTextContent(tagsContent);
}
async assertShortAddress (expectedShortAddress: string): Promise<void> {
const actualShortAddress = await within(this.primaryRow).findByTestId('short-address');
expect(actualShortAddress).toHaveTextContent(expectedShortAddress);
}
}
jest.mock('@polkadot/react-hooks/useAccounts', () => ({
useAccounts: () => mockAccountHooks.useAccounts
}));
jest.mock('@polkadot/react-hooks/useAccountInfo', () => ({
useAccountInfo: (address: string) => mockAccountHooks.accountsMap[address]?.info || {}
}));
jest.mock('@polkadot/react-hooks/useLoadingDelay', () => ({
useLoadingDelay: () => false
}));
@ -101,6 +54,25 @@ jest.mock('@polkadot/react-hooks/useBestNumber', () => ({
useBestNumber: () => 1
}));
jest.mock('@polkadot/react-hooks/useAccountInfo', () => {
// eslint-disable-next-line func-call-spacing
const actual = jest.requireActual<{useAccountInfo: (address: string) => UseAccountInfo}>('@polkadot/react-hooks/useAccountInfo');
return ({
useAccountInfo: (address: string) => {
const mockInfo = mockAccountHooks.accountsMap[address];
return mockInfo
? {
...actual.useAccountInfo(address),
flags: { ...actual.useAccountInfo(address).flags, ...(mockInfo.info.flags) },
tags: [...actual.useAccountInfo(address).tags, ...(mockInfo.info.tags)]
}
: actual.useAccountInfo(address);
}
});
});
export class AccountsPage {
private renderResult?: RenderResult
@ -115,8 +87,7 @@ export class AccountsPage {
api: {
derive: {
accounts: {
info: () => Promise.resolve(() => { /**/
})
info: () => { /**/ }
},
balances: {
all: () => ({
@ -155,7 +126,9 @@ export class AccountsPage {
<MemoryRouter>
<ThemeProvider theme={lightTheme}>
<ApiContext.Provider value={mockApi}>
<Overview onStatusChange={noop} />
<AccountSidebar>
<Overview onStatusChange={noop} />
</AccountSidebar>
</ApiContext.Provider>
</ThemeProvider>
</MemoryRouter>

View File

@ -171,6 +171,7 @@ function AccountName ({ children, className = '', defaultName, label, onClick, o
return (
<div
className={`ui--AccountName${withSidebar ? ' withSidebar' : ''} ${className}`}
data-testid='account-name'
onClick={
withSidebar
? _onToggleSidebar

View File

@ -42,7 +42,7 @@ function Button ({ activeOnEnter, children, className = '', dataTestId = '', ico
onMouseLeave={onMouseLeave}
tabIndex={tabIndex}
>
<Icon icon={icon} />
{icon && <Icon icon={icon} />}
{label}
{children}
<Spinner

View File

@ -11,7 +11,7 @@ export interface ButtonProps {
children?: React.ReactNode;
className?: string;
dataTestId?: string;
icon: IconName;
icon?: IconName;
isBasic?: boolean;
isBusy?: boolean;
isCircular?: boolean;

View File

@ -24,9 +24,16 @@ interface Props {
library.add(fas);
function Icon ({ className = '', color = 'normal', icon, isPadded, isSpinning, onClick, size = '1x', tooltip }: Props): React.ReactElement<Props> {
const extraProps = tooltip
? { 'data-for': tooltip, 'data-testid': icon, 'data-tip': true }
: {};
const extraProps: Record<string, unknown> = {
'data-testid': icon,
...(tooltip
? {
'data-for': tooltip,
'data-tip': true
}
: {}
)
};
return (
<FontAwesomeIcon

View File

@ -10,17 +10,24 @@ interface Props {
button?: React.ReactNode;
children: React.ReactNode;
className?: string;
dataTestId?: string;
offset?: number | string;
onClose: () => void;
position: 'left' | 'right';
sidebarRef: React.RefObject<HTMLDivElement>;
}
function Sidebar ({ button, children, className = '', onClose }: Props): React.ReactElement<Props> {
function Sidebar ({ button, children, className = '', dataTestId = '', onClose, sidebarRef }: Props): React.ReactElement<Props> {
return (
<div className={`ui--Sidebar ${className}`}>
<div
className={`ui--Sidebar ${className}`}
data-testid={dataTestId}
ref={sidebarRef}
>
<Button.Group className='ui--Sidebar-buttons'>
{button}
<Button
dataTestId='close-sidebar-button'
icon='times'
isBasic
isCircular

View File

@ -18,10 +18,11 @@ interface Props {
onToggleIsEditing?: () => void;
onSave?: () => void;
value: string[];
withEditButton?: boolean;
withTitle?: boolean;
}
function Tags ({ children, className = '', isEditable, isEditing, onChange, onSave, onToggleIsEditing, value, withTitle }: Props): React.ReactElement<Props> {
function Tags ({ children, className = '', isEditable, isEditing, onChange, onSave, onToggleIsEditing, value, withEditButton = true, withTitle }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const contents = useMemo(
@ -57,12 +58,12 @@ function Tags ({ children, className = '', isEditable, isEditing, onChange, onSa
onChange={onChange}
onClose={_onSave}
openOnFocus
searchInput={{ autoFocus: true }}
searchInput={{ autoFocus: false }}
value={value}
withLabel={false}
/>
)
: isEditable
: isEditable && withEditButton
? (
<EditButton
className={value.length === 0 ? 'center' : 'left'}

View File

@ -122,12 +122,15 @@ export interface UseAccountInfo {
meta?: KeyringJson$Meta;
toggleIsEditingName: () => void;
isEditingTags: boolean;
isEditing: () => boolean;
isNull: boolean;
toggleIsEditingTags: () => void;
onSaveName: () => void;
onSaveTags: () => void;
onSetGenesisHash: (genesisHash: string | null) => void;
onForgetAddress: () => void;
setIsEditingName: (isEditing: boolean) => void;
setIsEditingTags: (isEditing: boolean) => void;
}
export interface StakerState {

View File

@ -51,8 +51,8 @@ export function useAccountInfo (value: string | null, isContract = false): UseAc
const [identity, setIdentity] = useState<AddressIdentity | undefined>();
const [flags, setFlags] = useState<AddressFlags>(IS_NONE);
const [meta, setMeta] = useState<KeyringJson$Meta | undefined>();
const [isEditingName, toggleIsEditingName] = useToggle();
const [isEditingTags, toggleIsEditingTags] = useToggle();
const [isEditingName, toggleIsEditingName, setIsEditingName] = useToggle();
const [isEditingTags, toggleIsEditingTags, setIsEditingTags] = useToggle();
useEffect((): void => {
validator && setFlags((flags) => ({
@ -251,11 +251,14 @@ export function useAccountInfo (value: string | null, isContract = false): UseAc
[]
);
const isEditing = useCallback(() => isEditingName || isEditingTags, [isEditingName, isEditingTags]);
return {
accountIndex,
flags,
genesisHash,
identity,
isEditing,
isEditingName,
isEditingTags,
isNull: !value,
@ -265,6 +268,8 @@ export function useAccountInfo (value: string | null, isContract = false): UseAc
onSaveName,
onSaveTags,
onSetGenesisHash,
setIsEditingName,
setIsEditingTags,
setName,
setTags,
tags,

View File

@ -15,10 +15,10 @@ export const useOutsideClick = (elements: RefObject<HTMLDivElement>[], callback:
}, [elements, callback]);
useEffect(() => {
document.addEventListener('click', handleClick);
document.addEventListener('click', handleClick, true);
return () => {
document.removeEventListener('click', handleClick);
document.removeEventListener('click', handleClick, true);
};
}, [handleClick, callback]);
};