Apply collapsible row styling for addresses (#6122)

* Apply collapsible row for addresses

* Apply merge improvements

* Style buttons

* Restore unused export

Co-authored-by: MiZiet <michalzietalak@o2.pl>
This commit is contained in:
Ewa Kowalska 2021-09-15 08:54:48 +02:00 committed by GitHub
parent 2bbe1b9d34
commit 52b706ef14
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 1110 additions and 686 deletions

View File

@ -1,31 +1,24 @@
// Copyright 2017-2021 @polkadot/page-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Balance } from '@polkadot/types/interfaces';
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
import { fireEvent, screen, waitFor } 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 { AddressFlags, UseAccountInfo } from '@polkadot/react-hooks/types';
import { balanceOf } from '@polkadot/test-support/creation/balance';
import { AddressFlags } from '@polkadot/react-hooks/types';
import { anAccountWithBalance, anAccountWithBalanceAndMeta, anAccountWithInfo, anAccountWithMeta, anAccountWithStaking } from '@polkadot/test-support/creation/account';
import { makeStakingLedger as ledger } from '@polkadot/test-support/creation/stakingInfo/stakingLedger';
import { MemoryStore } from '@polkadot/test-support/keyring';
import { alice, MemoryStore } from '@polkadot/test-support/keyring';
import { Table } from '@polkadot/test-support/pagesElements';
import { Sidebar } from '@polkadot/test-support/pagesElements/Sidebar';
import { balance, showBalance } from '@polkadot/test-support/utils/balance';
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';
import { AccountsPage } from '../../test/pages/accountsPage';
describe('Accounts page', () => {
const aliceAddress = '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY';
const bobAddress = '5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty';
const charlieAddress = '5DAAnrj7VHTznn2AWBemMuyBwZWs6FNFjdyVXUeYum3PTXFy';
let accountsPage: AccountsPage;
beforeAll(async () => {
@ -37,43 +30,36 @@ describe('Accounts page', () => {
});
beforeEach(() => {
defaultAddresses.forEach((address) => keyring.forgetAccount(address));
accountsPage = new AccountsPage();
accountsPage.clearAccounts();
});
describe('when no accounts', () => {
beforeEach(() => {
accountsPage.renderPage([]);
accountsPage.render([]);
});
it('shows sort-by controls', async () => {
const section = await accountsPage.findSortBySection();
expect(section).not.toBeNull();
const button = await accountsPage.findSortByReverseButton();
expect(button).not.toBeNull();
await accountsPage.reverseSortingOrder();
});
it('shows a table', async () => {
const accountsTable = await accountsPage.findAccountsTable();
const accountsTable = await accountsPage.getTable();
expect(accountsTable).not.toBeNull();
});
it('the accounts table contains no account rows', async () => {
const accountRows = await accountsPage.findAccountRows();
const accountRows = await accountsPage.getAccountRows();
expect(accountRows).toHaveLength(0);
});
it('the accounts table contains a message about no accounts available', async () => {
const accountsTable = await accountsPage.findAccountsTable();
const noAccountsMessage = await within(accountsTable).findByText(
'You don\'t have any accounts. Some features are currently hidden and will only become available once you have accounts.');
const noAccountsMessage = 'You don\'t have any accounts. Some features are currently hidden and will only become available once you have accounts.';
const accountsTable = await accountsPage.getTable();
expect(noAccountsMessage).not.toBeNull();
await accountsTable.assertText(noAccountsMessage);
});
it('no summary is displayed', () => {
@ -85,31 +71,31 @@ describe('Accounts page', () => {
describe('when some accounts exist', () => {
it('the accounts table contains some account rows', async () => {
renderDefaultAccounts(2);
const accountRows = await accountsPage.findAccountRows();
accountsPage.renderDefaultAccounts(2);
const accountRows = await accountsPage.getAccountRows();
expect(accountRows).toHaveLength(2);
});
it('account rows display the total balance info', async () => {
renderAccountsWithDefaultAddresses(
accountsPage.renderAccountsWithDefaultAddresses(
anAccountWithBalance({ freeBalance: balance(500) }),
anAccountWithBalance({ freeBalance: balance(200), reservedBalance: balance(150) })
);
const rows = await accountsPage.findAccountRows();
const rows = await accountsPage.getAccountRows();
await rows[0].assertBalancesTotal(balance(500));
await rows[1].assertBalancesTotal(balance(350));
});
it('account rows display the details balance info', async () => {
renderAccountsWithDefaultAddresses(
accountsPage.renderAccountsWithDefaultAddresses(
anAccountWithBalance({ freeBalance: balance(500), lockedBalance: balance(30) }),
anAccountWithBalance({ availableBalance: balance(50), freeBalance: balance(200), reservedBalance: balance(150) })
);
const rows = await accountsPage.findAccountRows();
const rows = await accountsPage.getAccountRows();
await rows[0].assertBalancesDetails([
{ amount: balance(0), name: 'transferrable' },
@ -120,75 +106,74 @@ describe('Accounts page', () => {
});
it('derived account displays parent account info', async () => {
renderAccountsWithDefaultAddresses(
accountsPage.renderAccountsWithDefaultAddresses(
anAccountWithMeta({ isInjected: true, name: 'ALICE', whenCreated: 200 }),
anAccountWithMeta({ name: 'ALICE_CHILD', parentAddress: aliceAddress, whenCreated: 300 })
anAccountWithMeta({ name: 'ALICE_CHILD', parentAddress: alice, whenCreated: 300 })
);
const accountRows = await accountsPage.findAccountRows();
const accountRows = await accountsPage.getAccountRows();
expect(accountRows).toHaveLength(2);
await accountRows[1].assertParentAccountName('ALICE');
});
it('a separate column for parent account is not displayed', async () => {
renderDefaultAccounts(1);
const accountsTable = await accountsPage.findAccountsTable();
accountsPage.renderDefaultAccounts(1);
const accountsTable = await accountsPage.getTable();
assertColumnNotExistInTable('parent', accountsTable);
assertColumnExistsInTable('type', accountsTable);
accountsTable.assertColumnNotExist('parent');
accountsTable.assertColumnExists('type');
});
it('account rows display the shorted address', async () => {
renderAccountsForAddresses(
aliceAddress
accountsPage.renderAccountsForAddresses(
alice
);
const accountRows = await accountsPage.findAccountRows();
const accountRows = await accountsPage.getAccountRows();
expect(accountRows).toHaveLength(1);
const aliceShortAddress = toShortAddress(aliceAddress);
const aliceShortAddress = toShortAddress(alice);
await accountRows[0].assertShortAddress(aliceShortAddress);
});
it('when account is not tagged, account row details displays no tags info', async () => {
renderDefaultAccounts(1);
const rows = await accountsPage.findAccountRows();
accountsPage.renderDefaultAccounts(1);
const rows = await accountsPage.getAccountRows();
await rows[0].assertTags('no tags');
});
it('when account is tagged, account row details displays tags', async () => {
renderAccountsWithDefaultAddresses(
accountsPage.renderAccountsWithDefaultAddresses(
anAccountWithInfo({ tags: ['my tag', 'Super Tag'] })
);
const rows = await accountsPage.findAccountRows();
const rows = await accountsPage.getAccountRows();
await rows[0].assertTags('my tagSuper Tag');
});
it('account details rows keep colouring from their primary rows', async () => {
renderDefaultAccounts(3);
accountsPage.renderDefaultAccounts(3);
const accountsTable = await accountsPage.getTable();
await accountsPage.checkRowsColoring();
await accountsTable.assertColoring();
});
it('account details rows toggled on icon toggle click', async () => {
renderDefaultAccounts(1);
const row = (await accountsPage.findAccountRows())[0];
const toggle = await within(row.primaryRow).findByTestId('row-toggle');
accountsPage.renderDefaultAccounts(1);
const row = (await accountsPage.getAccountRows())[0];
expect(row.detailsRow).toHaveClass('isCollapsed');
fireEvent.click(toggle);
await row.expand();
expect(row.detailsRow).toHaveClass('isExpanded');
});
it('displays some summary', () => {
renderAccountsWithDefaultAddresses(
accountsPage.renderAccountsWithDefaultAddresses(
anAccountWithBalance({ freeBalance: balance(500) }),
anAccountWithBalance({ freeBalance: balance(200), reservedBalance: balance(150) })
);
@ -199,7 +184,7 @@ describe('Accounts page', () => {
});
it('displays balance summary', async () => {
renderAccountsWithDefaultAddresses(
accountsPage.renderAccountsWithDefaultAddresses(
anAccountWithBalance({ freeBalance: balance(500) }),
anAccountWithBalance({ freeBalance: balance(200), reservedBalance: balance(150) })
);
@ -210,7 +195,7 @@ describe('Accounts page', () => {
});
it('displays transferable summary', async () => {
renderAccountsWithDefaultAddresses(
accountsPage.renderAccountsWithDefaultAddresses(
anAccountWithBalance({ availableBalance: balance(400) }),
anAccountWithBalance({ availableBalance: balance(600) })
);
@ -221,7 +206,7 @@ describe('Accounts page', () => {
});
it('displays locked summary', async () => {
renderAccountsWithDefaultAddresses(
accountsPage.renderAccountsWithDefaultAddresses(
anAccountWithBalance({ lockedBalance: balance(400) }),
anAccountWithBalance({ lockedBalance: balance(600) })
);
@ -232,7 +217,7 @@ describe('Accounts page', () => {
});
it('displays bonded summary', async () => {
renderAccountsWithDefaultAddresses(
accountsPage.renderAccountsWithDefaultAddresses(
anAccountWithStaking({ stakingLedger: ledger(balance(70)) }),
anAccountWithStaking({ stakingLedger: ledger(balance(20)) })
);
@ -243,7 +228,7 @@ describe('Accounts page', () => {
});
it('displays unbonding summary', async () => {
renderAccountsWithDefaultAddresses(
accountsPage.renderAccountsWithDefaultAddresses(
anAccountWithStaking({
unlocking: [
{
@ -284,7 +269,7 @@ describe('Accounts page', () => {
});
it('displays redeemable summary', async () => {
renderAccountsWithDefaultAddresses(
accountsPage.renderAccountsWithDefaultAddresses(
anAccountWithStaking({ redeemable: balance(4000) }),
anAccountWithStaking({ redeemable: balance(5000) })
);
@ -295,270 +280,209 @@ describe('Accounts page', () => {
});
it('sorts accounts by date by default', async () => {
renderAccountsWithDefaultAddresses(
accountsPage.renderAccountsWithDefaultAddresses(
anAccountWithBalanceAndMeta({ freeBalance: balance(1) }, { whenCreated: 200 }),
anAccountWithBalanceAndMeta({ freeBalance: balance(2) }, { whenCreated: 300 }),
anAccountWithBalanceAndMeta({ freeBalance: balance(3) }, { whenCreated: 100 })
);
expect(await accountsPage.getCurrentSortCategory()).toHaveTextContent('date');
expect(await accountsPage.findSortByDropdownCurrent()).toHaveTextContent('date');
await accountsPage.checkOrderAndRowsColoring([3, 1, 2]);
const accountsTable = await accountsPage.getTable();
await accountsTable.assertRowsOrderAndColoring([3, 1, 2]);
});
describe('when sorting is used', () => {
beforeEach(() => {
renderAccountsWithDefaultAddresses(
let accountsTable: Table;
beforeEach(async () => {
accountsPage.renderAccountsWithDefaultAddresses(
anAccountWithBalanceAndMeta({ freeBalance: balance(1) }, { isInjected: true, name: 'bbb', whenCreated: 200 }),
anAccountWithBalanceAndMeta({ freeBalance: balance(2) }, {
hardwareType: 'ledger',
isHardware: true,
name: 'bb',
parentAddress: aliceAddress,
parentAddress: alice,
whenCreated: 300
}),
anAccountWithBalanceAndMeta({ freeBalance: balance(3) }, { isInjected: true, name: 'aaa', whenCreated: 100 })
);
accountsTable = await accountsPage.getTable();
});
it('changes default dropdown value', async () => {
await accountsPage.selectOrder('balances');
expect(await accountsPage.findSortByDropdownCurrent())
await accountsPage.sortBy('balances');
expect(await accountsPage.getCurrentSortCategory())
.toHaveTextContent('balances');
});
it('sorts by parent if asked', async () => {
await accountsPage.selectOrder('parent');
await accountsPage.checkOrderAndRowsColoring([3, 1, 2]);
await accountsPage.sortBy('parent');
await accountsTable.assertRowsOrderAndColoring([3, 1, 2]);
});
it('sorts by name if asked', async () => {
await accountsPage.selectOrder('name');
await accountsPage.checkOrderAndRowsColoring([3, 2, 1]);
await accountsPage.sortBy('name');
await accountsTable.assertRowsOrderAndColoring([3, 2, 1]);
});
it('sorts by date if asked', async () => {
await accountsPage.selectOrder('date');
await accountsPage.checkOrderAndRowsColoring([3, 1, 2]);
await accountsPage.sortBy('date');
await accountsTable.assertRowsOrderAndColoring([3, 1, 2]);
});
it('sorts by balances if asked', async () => {
await accountsPage.selectOrder('balances');
await accountsPage.checkOrderAndRowsColoring([1, 2, 3]);
await accountsPage.sortBy('balances');
await accountsTable.assertRowsOrderAndColoring([1, 2, 3]);
});
it('sorts by type if asked', async () => {
await accountsPage.selectOrder('type');
await accountsPage.checkOrderAndRowsColoring([3, 1, 2]);
await accountsPage.sortBy('type');
await accountsTable.assertRowsOrderAndColoring([3, 1, 2]);
});
it('implements stable sort', async () => {
// Notice that sorting by 'type' results in different order
// depending on the previous state.
await accountsPage.selectOrder('name');
await accountsPage.checkOrderAndRowsColoring([3, 2, 1]);
await accountsPage.selectOrder('type');
await accountsPage.checkOrderAndRowsColoring([3, 1, 2]);
await accountsPage.selectOrder('balances');
await accountsPage.checkOrderAndRowsColoring([1, 2, 3]);
await accountsPage.selectOrder('type');
await accountsPage.checkOrderAndRowsColoring([1, 3, 2]);
await accountsPage.checkRowsColoring();
await accountsPage.sortBy('name');
await accountsTable.assertRowsOrderAndColoring([3, 2, 1]);
await accountsPage.sortBy('type');
await accountsTable.assertRowsOrderAndColoring([3, 1, 2]);
await accountsPage.sortBy('balances');
await accountsTable.assertRowsOrderAndColoring([1, 2, 3]);
await accountsPage.sortBy('type');
await accountsTable.assertRowsOrderAndColoring([1, 3, 2]);
await accountsTable.assertColoring();
});
it('respects reverse button', async () => {
await accountsPage.selectOrder('name');
await accountsPage.checkOrderAndRowsColoring([3, 2, 1]);
await accountsPage.selectOrder('balances');
await accountsPage.checkOrderAndRowsColoring([1, 2, 3]);
fireEvent.click(await accountsPage.findSortByReverseButton());
await accountsPage.checkOrderAndRowsColoring([3, 2, 1]);
await accountsPage.selectOrder('name');
await accountsPage.checkOrderAndRowsColoring([1, 2, 3]);
await accountsPage.sortBy('name');
await accountsTable.assertRowsOrderAndColoring([3, 2, 1]);
await accountsPage.sortBy('balances');
await accountsTable.assertRowsOrderAndColoring([1, 2, 3]);
await accountsPage.reverseSortingOrder();
await accountsTable.assertRowsOrderAndColoring([3, 2, 1]);
await accountsPage.sortBy('name');
await accountsTable.assertRowsOrderAndColoring([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.render([[alice, anAccountWithMeta({ isDevelopment: false, name: initialName })]]);
accountRows = await accountsPage.getAccountRows();
sideBar = await accountRows[0].openSidebar();
await sideBar.changeAccountName(newName);
});
it('within keyring', () => {
const changedAccount = keyring.getAccount(alice);
expect(changedAccount?.meta?.name).toEqual(newName);
});
it('within sidebar', async () => {
await sideBar.assertAccountName(newName);
});
it('within account row', async () => {
await accountRows[0].assertAccountName(newName);
});
});
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"]';
it('cannot be edited if edit button has not been pressed', async () => {
accountsPage.renderDefaultAccounts(1);
accountRows = await accountsPage.getAccountRows();
sideBar = await accountRows[0].openSidebar();
await sideBar.clickByText('no tags');
expect(sideBar.queryByRole('combobox')).toBeFalsy();
let accountRows: AccountRow[];
let sideBar: Sidebar;
await expect(sideBar.typeAccountName(newName)).rejects.toThrowError(nameInputNotFoundError);
});
describe('changes name', () => {
beforeEach(async () => {
accountsPage.renderPage([[aliceAddress, anAccountWithMeta({ isDevelopment: false, name: initialName })]]);
sideBar = await openSidebarForAccountRow(0);
await sideBar.changeAccountName(newName);
});
it('when isEditable is false account name is not editable', async () => {
accountsPage.renderAccountsWithDefaultAddresses(
anAccountWithInfo({ flags: { isEditable: false } as AddressFlags })
);
accountRows = await accountsPage.getAccountRows();
sideBar = await accountRows[0].openSidebar();
sideBar.edit();
it('within keyring', () => {
const changedAccount = keyring.getAccount(aliceAddress);
await expect(sideBar.typeAccountName(newName)).rejects.toThrowError(nameInputNotFoundError);
});
expect(changedAccount?.meta?.name).toEqual(newName);
});
describe('on edit cancel', () => {
beforeEach(async () => {
accountsPage.render([[alice, anAccountWithMeta({ isDevelopment: false, name: initialName, tags: [] })]]);
accountRows = await accountsPage.getAccountRows();
sideBar = await accountRows[0].openSidebar();
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);
await sideBar.assertTags('no tags');
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: [] })]]);
it('restores tags and name to state from keyring', async () => {
await sideBar.typeAccountName(newName);
await sideBar.selectTag(defaultTag);
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();
});
sideBar.cancel();
await sideBar.assertTags('no tags');
await sideBar.assertAccountName(initialName);
});
describe('outside click', () => {
beforeEach(async () => {
renderAccountsWithDefaultAddresses(
anAccountWithMeta({ name: 'alice' }),
anAccountWithMeta({ name: 'bob' })
);
it('Cancel button disappears', () => {
sideBar.cancel();
expect(sideBar.queryByRole('button', { name: 'Cancel' })).toBeFalsy();
});
});
sideBar = await openSidebarForAccountRow(0);
sideBar.edit();
});
describe('outside click', () => {
beforeEach(async () => {
accountsPage.renderAccountsWithDefaultAddresses(
anAccountWithMeta({ name: 'alice' }),
anAccountWithMeta({ name: 'bob' })
);
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');
});
accountRows = await accountsPage.getAccountRows();
sideBar = await accountRows[0].openSidebar();
sideBar.edit();
});
async function openSidebarForAccountRow (rowIndex: number) {
accountRows = await accountsPage.findAccountRows();
it('cancels editing', async () => {
await sideBar.typeAccountName(newName);
await sideBar.selectTag(defaultTag);
return accountRows[rowIndex].openSidebar();
}
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 accountRows[1].openSidebar();
await sideBar.assertAccountName('BOB');
});
});
});
const anAccount = (): AccountOverrides => ({});
const anAccountWithBalance = (balance: Override<DeriveBalancesAll>) => ({
balance
});
const anAccountWithBalanceAndMeta = (balance: Override<DeriveBalancesAll>, meta: Override<KeyringJson$Meta>) => ({
balance,
meta
});
const anAccountWithInfo = (info: Override<UseAccountInfo>) => ({
info
});
const anAccountWithMeta = (meta: Override<KeyringJson$Meta>) => ({
meta
});
const anAccountWithStaking = (staking: Override<DeriveStakingAccount>) => ({
staking
});
function assertColumnNotExistInTable (columnName: string, table: HTMLElement) {
expect(within(table).queryByRole('columnheader', { name: columnName })).toBeFalsy();
}
function assertColumnExistsInTable (columnName: string, table: HTMLElement) {
expect(within(table).getByRole('columnheader', { name: columnName })).toBeTruthy();
}
function showBalance (amount: number): string {
return format(balance(amount));
}
const defaultAddresses = [aliceAddress, bobAddress, charlieAddress];
function renderAccountsWithDefaultAddresses (...overrides: AccountOverrides[]): void {
const accounts = overrides.map((accountProperties, index) =>
[defaultAddresses[index], accountProperties] as [string, AccountOverrides]);
accountsPage.renderPage(accounts);
}
function renderAccountsForAddresses (...addresses: string[]): void {
const accounts = addresses.map((address) => [address, anAccount()] as [string, AccountOverrides]);
accountsPage.renderPage(accounts);
}
function renderDefaultAccounts (accountsNumber: number): void {
const accounts = Array.from({ length: accountsNumber },
(_, index) => [defaultAddresses[index], anAccount()] as [string, AccountOverrides]);
accountsPage.renderPage(accounts);
}
});
/**
* Creates a balance instance for testing purposes which most often do not need to specifiy/use decimal part.
* @param amountInt Integer part of the balance number
* @param decimalsString Decimals part of the balance number. Note! This is a string sequence just after '.' separator
* that is the point that separates integers from decimals. E.g. (100, 4567) => 100.45670000...00
*/
export const balance = function (amountInt: number, decimalsString?: string): Balance {
const decimalsPadded = (decimalsString || '').padEnd(12, '0');
return balanceOf(amountInt.toString() + decimalsPadded);
};

View File

@ -5,6 +5,7 @@ import { fireEvent, screen, waitForElementToBeRemoved } from '@testing-library/r
import i18next from '@polkadot/react-components/i18n';
import { MemoryStore } from '@polkadot/test-support/keyring';
import { assertButtonDisabled, assertText, clickButton, fillInput } from '@polkadot/test-support/utils/renderedScreenUtils';
import { keyring } from '@polkadot/ui-keyring';
import { AccountsPage } from '../../test/pages/accountsPage';
@ -33,15 +34,15 @@ describe('Create an account modal', () => {
assertButtonDisabled('Next');
await fillFirstStep();
clickButton('Next');
await clickButton('Next');
await expectSecondStep();
assertButtonDisabled('Next');
fillSecondStep();
clickButton('Next');
await clickButton('Next');
await expectThirdStep();
clickButton('Save');
await clickButton('Save');
await waitForElementToBeRemoved(() => screen.queryByText('Add an account via seed 3/3'));
expectCreateAnAccountCall();
@ -75,7 +76,7 @@ describe('Create an account modal', () => {
fillInput('secret derivation path', '//abc//');
await screen.findByText('Unable to match provided value to a secret URI');
await assertText('Unable to match provided value to a secret URI');
});
});
@ -109,24 +110,6 @@ function expectCreateAnAccountCall () {
);
}
function fillInput (testId: string, value: string) {
const nameInput = screen.getByTestId(testId);
fireEvent.change(nameInput, { target: { value } });
}
function assertButtonDisabled (name: string) {
const button = screen.getByRole('button', { name });
expect(button).toHaveClass('isDisabled');
}
function clickButton (name: string) {
const button = screen.getByRole('button', { name });
fireEvent.click(button);
}
async function expectFirstStep () {
await screen.findByText('Add an account via seed 1/3');
}

View File

@ -1,70 +0,0 @@
// 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,14 @@
// Copyright 2017-2021 @polkadot/page-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import { within } from '@testing-library/react';
import { Row } from '@polkadot/test-support/pagesElements/Row';
export class AccountRow extends Row {
async assertParentAccountName (expectedParentAccount: string): Promise<void> {
const parentAccount = await within(this.primaryRow).findByTestId('parent');
expect(parentAccount).toHaveTextContent(expectedParentAccount);
}
}

View File

@ -1,260 +1,92 @@
// Copyright 2017-2021 @polkadot/page-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import { fireEvent, render, RenderResult, screen, within } from '@testing-library/react';
import BN from 'bn.js';
import React, { Suspense } from 'react';
import { MemoryRouter } from 'react-router-dom';
import { ThemeProvider } from 'styled-components';
import { fireEvent, screen, within } from '@testing-library/react';
import React from 'react';
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 { anAccount } from '@polkadot/test-support/creation/account';
import { Page } from '@polkadot/test-support/pages/Page';
import { AccountOverrides } from '@polkadot/test-support/types';
import { assertText, clickButton } from '@polkadot/test-support/utils/renderedScreenUtils';
import { AccountOverrides, mockAccountHooks } from '../hooks/default';
import AccountOverview from '../../src/Accounts/index';
import { AccountRow } from '../pageElements/AccountRow';
import Overview from '../pages/../../src/Accounts/index';
let queueExtrinsic: (value: PartialQueueTxExtrinsic) => void;
function noop (): void {
// ignore
}
class NotYetRendered extends Error {
}
jest.mock('@polkadot/react-hooks/useAccounts', () => ({
useAccounts: () => mockAccountHooks.useAccounts
}));
jest.mock('@polkadot/react-hooks/useLoadingDelay', () => ({
useLoadingDelay: () => false
}));
jest.mock('@polkadot/react-hooks/useBalancesAll', () => ({
useBalancesAll: (address: string) => mockAccountHooks.accountsMap[address].balance
}));
jest.mock('@polkadot/react-hooks/useStakingInfo', () => ({
useStakingInfo: (address: string) => mockAccountHooks.accountsMap[address].staking
}));
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
renderPage (accounts: [string, AccountOverrides][]): void {
mockAccountHooks.setAccounts(accounts);
accounts.forEach(([address, { meta }]) => {
keyring.addExternal(address, meta);
});
const mockApi: ApiProps = {
api: {
derive: {
accounts: {
info: () => { /**/ }
},
balances: {
all: () => ({
accountNonce: mockAccountHooks.nonce,
additional: [],
freeBalance: 0,
lockedBreakdown: [],
reservedBalance: 0
})
},
chain: {
bestNumber: () => new BN(1) as BlockNumber
}
},
genesisHash: new TypeRegistry().createType('Hash', POLKADOT_GENESIS),
query: {},
registry: { chainDecimals: [12], chainTokens: ['Unit'] },
tx: {
council: {
}
}
},
systemName: 'substrate'
} as unknown as ApiProps;
queueExtrinsic = jest.fn() as QueueTxExtrinsicAdd;
const queue = {
queueExtrinsic
} as QueueProps;
this.renderResult = render(
<>
<div id='tooltips' />
<Suspense fallback='...'>
<QueueProvider value={queue}>
<MemoryRouter>
<ThemeProvider theme={lightTheme}>
<ApiContext.Provider value={mockApi}>
<AccountSidebar>
<Overview onStatusChange={noop} />
</AccountSidebar>
</ApiContext.Provider>
</ThemeProvider>
</MemoryRouter>
</QueueProvider>
</Suspense>
</>
);
export class AccountsPage extends Page {
constructor () {
super(<AccountOverview />, 'Account-');
}
async findAccountsTable (): Promise<HTMLElement> {
this.assertRendered();
async getAccountRows (): Promise<AccountRow[]> {
const table = await this.getTable();
const rows = await table.getRows();
return await screen.findByRole('table');
return rows.map((row) => new AccountRow(row.primaryRow, row.detailsRow, row.rowIndex));
}
async findAccountRows (): Promise<AccountRow[]> {
const table = await this.findAccountsTable();
const tableBody = table.getElementsByTagName('tbody')[0];
const htmlRows = (await within(tableBody).findAllByRole('row'))
.filter((r) => r.className.startsWith('Account-'));
const rows: AccountRow[] = [];
async reverseSortingOrder (): Promise<void> {
const sortingComponent = await this.getSortByComponent();
const reverseOrderButton = await within(sortingComponent).findByRole('button');
for (let rowIdx = 0; rowIdx < htmlRows.length; rowIdx = rowIdx + 2) {
const primary = htmlRows[rowIdx];
const details = htmlRows[rowIdx + 1];
rows.push(new AccountRow(primary, details));
}
return rows;
fireEvent.click(reverseOrderButton);
}
async findSortBySection (): Promise<HTMLElement> {
async sortBy (category: string): Promise<void> {
const currentCategory = await this.getCurrentSortCategory();
fireEvent.click(currentCategory);
const selectedCategory = await this.getSortCategory(category);
fireEvent.click(selectedCategory);
}
async getCurrentSortCategory (): Promise<HTMLElement> {
const sortByComponent = await this.getSortByComponent();
return within(sortByComponent).findByRole('alert');
}
async enterCreateAccountModal (): Promise<void> {
this.render([]);
await clickButton('Add account');
await assertText('Add an account via seed 1/3');
}
renderAccountsWithDefaultAddresses (...overrides: AccountOverrides[]): void {
const accounts = overrides.map((accountProperties, index) =>
[this.defaultAddresses[index], accountProperties] as [string, AccountOverrides]);
this.render(accounts);
}
renderAccountsForAddresses (...addresses: string[]): void {
const accounts = addresses.map((address) => [address, anAccount()] as [string, AccountOverrides]);
this.render(accounts);
}
renderDefaultAccounts (accountsNumber: number): void {
const accounts = Array.from({ length: accountsNumber },
(_, index) => [this.defaultAddresses[index], anAccount()] as [string, AccountOverrides]);
this.render(accounts);
}
private async getSortCategory (categoryName: string): Promise<HTMLElement> {
const sortByComponent = await this.getSortByComponent();
const availableCategories = await within(sortByComponent).findAllByRole('option');
const selectedCategory = availableCategories.find((category) => category.textContent === categoryName);
expect(selectedCategory).not.toBeUndefined();
return selectedCategory as HTMLElement;
}
private async getSortByComponent (): Promise<HTMLElement> {
this.assertRendered();
return await screen.findByTestId('sort-by-section');
}
async findSortByDropdownCurrent (): Promise<HTMLElement> {
this.assertRendered();
const section = await this.findSortBySection();
const alerts = await within(section).findAllByRole('alert');
const names = await within(section).findAllByText(/\w+/);
const ret = names.filter((x) => alerts.includes(x));
expect(ret).toHaveLength(1);
return ret[0];
}
async findSortByDropdownItem (name: string): Promise<HTMLElement> {
this.assertRendered();
const section = await this.findSortBySection();
const alerts = await within(section).findAllByRole('alert');
const names = await within(section).findAllByText(name);
const ret = names.filter((x) => !alerts.includes(x));
expect(ret).toHaveLength(1);
return ret[0];
}
async findSortByReverseButton (): Promise<HTMLElement> {
this.assertRendered();
const section = await this.findSortBySection();
return await within(section).findByRole('button');
}
async checkOrder (order: number[]): Promise<void> {
const rows = await this.findAccountRows();
const rowsBalances = await Promise.all(rows.map((row) => within(row.primaryRow).findByTestId('balance-summary')));
const expected = order.map((amount) => this.format(balance(amount)));
for (let i = 0; i < expected.length; i++) {
expect(rowsBalances[i]).toHaveTextContent(expected[i]);
}
}
async selectOrder (orderBy: string): Promise<void> {
const current = await this.findSortByDropdownCurrent();
fireEvent.click(current);
const target = await this.findSortByDropdownItem(orderBy);
fireEvent.click(target);
}
async checkRowsColoring (): Promise<void> {
const rows = await this.findAccountRows();
rows.forEach((row, index) => {
const expectedClass = index % 2 ? 'isEven' : 'isOdd';
expect(row.primaryRow).toHaveClass(expectedClass);
expect(row.detailsRow).toHaveClass(expectedClass);
});
}
async checkOrderAndRowsColoring (order: number[]): Promise<void> {
await this.checkOrder(order);
await this.checkRowsColoring();
}
format (amount: Balance | BN): string {
return formatBalance(amount, { decimals: 12, forceUnit: '-', withUnit: true });
}
async enterCreateAccountModal (): Promise<void> {
this.renderPage([]);
fireEvent.click(await screen.findByRole('button', { name: 'Add account' }));
await screen.findByText('Add an account via seed 1/3');
}
private assertRendered () {
if (this.renderResult === undefined) {
throw new NotYetRendered();
}
}
}
export const format = (amount: Balance | BN): string => {
return formatBalance(amount, { decimals: 12, forceUnit: '-', withUnit: true });
};

View File

@ -1,7 +1,7 @@
// Copyright 2017-2021 @polkadot/app-addresses authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DeriveAccountInfo, DeriveBalancesAll } from '@polkadot/api-derive/types';
import type { DeriveAccountInfo } from '@polkadot/api-derive/types';
import type { ActionStatus } from '@polkadot/react-components/Status/types';
import type { ThemeDef } from '@polkadot/react-components/types';
import type { KeyringAddress } from '@polkadot/ui-keyring/types';
@ -10,8 +10,8 @@ import React, { useCallback, useContext, useEffect, useState } from 'react';
import styled, { ThemeContext } from 'styled-components';
import Transfer from '@polkadot/app-accounts/modals/Transfer';
import { AddressInfo, AddressSmall, Button, ChainLock, Forget, Icon, LinkExternal, Menu, Popup, Tags } from '@polkadot/react-components';
import { useApi, useCall } from '@polkadot/react-hooks';
import { AddressInfo, AddressSmall, Button, ChainLock, ExpandButton, Forget, Icon, LinkExternal, Menu, Popup, Tags } from '@polkadot/react-components';
import { useApi, useBalancesAll, useCall, useToggle } from '@polkadot/react-hooks';
import { keyring } from '@polkadot/ui-keyring';
import { BN_ZERO, formatNumber, isFunction } from '@polkadot/util';
@ -21,20 +21,19 @@ interface Props {
address: string;
className?: string;
filter: string;
isEven: boolean;
isFavorite: boolean;
toggleFavorite: (address: string) => void;
}
const WITH_BALANCE = { available: true, bonded: true, free: true, locked: true, reserved: true, total: true, unlocking: true };
const isEditable = true;
function Address ({ address, className = '', filter, isFavorite, toggleFavorite }: Props): React.ReactElement<Props> | null {
function Address ({ address, className = '', filter, isEven, isFavorite, toggleFavorite }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const { theme } = useContext<ThemeDef>(ThemeContext);
const api = useApi();
const info = useCall<DeriveAccountInfo>(api.api.derive.accounts.info, [address]);
const balancesAll = useCall<DeriveBalancesAll>(api.api.derive.balances?.all, [address]);
const balancesAll = useBalancesAll(address);
const [tags, setTags] = useState<string[]>([]);
const [accName, setAccName] = useState('');
const [current, setCurrent] = useState<KeyringAddress | null>(null);
@ -42,6 +41,7 @@ function Address ({ address, className = '', filter, isFavorite, toggleFavorite
const [isForgetOpen, setIsForgetOpen] = useState(false);
const [isTransferOpen, setIsTransferOpen] = useState(false);
const [isVisible, setIsVisible] = useState(true);
const [isExpanded, toggleIsExpanded] = useToggle(false);
const _setTags = useCallback(
(tags: string[]): void => setTags(tags.sort()),
@ -164,82 +164,160 @@ function Address ({ address, className = '', filter, isFavorite, toggleFavorite
);
return (
<tr className={className}>
<td className='favorite'>
<Icon
color={isFavorite ? 'orange' : 'gray'}
icon='star'
onClick={_onFavorite}
/>
</td>
<td className='address'>
<AddressSmall value={address} />
{address && current && (
<>
{isForgetOpen && (
<Forget
address={current.address}
key='modal-forget-account'
mode='address'
onClose={_toggleForget}
onForget={_onForget}
/>
)}
{isTransferOpen && (
<Transfer
key='modal-transfer'
onClose={_toggleTransfer}
recipientId={address}
/>
)}
</>
)}
</td>
<td className='all'>
<div className='tags'>
<Tags value={tags} />
</div>
</td>
<td className='number media--1500'>
{balancesAll?.accountNonce.gt(BN_ZERO) && formatNumber(balancesAll.accountNonce)}
</td>
<td className='number'>
<AddressInfo
address={address}
withBalance={WITH_BALANCE}
withBalanceToggle
withExtended={false}
/>
</td>
<td className='links media--1400'>
<LinkExternal
className='ui--AddressCard-exporer-link'
data={address}
isLogo
type='address'
/>
</td>
<td className='button'>
{isFunction(api.api.tx.balances?.transfer) && (
<Button
icon='paper-plane'
key='send'
label={t<string>('send')}
onClick={_toggleTransfer}
<>
<tr className={`${className}${isExpanded ? ' noBorder' : ''} ${isEven ? 'isEven' : 'isOdd'}`}>
<td className='favorite'>
<Icon
color={isFavorite ? 'orange' : 'gray'}
icon='star'
onClick={_onFavorite}
/>
)}
<Popup
className={`theme--${theme}`}
value={PopupDropdown}
/>
</td>
</tr>
</td>
<td className='address'>
<AddressSmall value={address} />
{address && current && (
<>
{isForgetOpen && (
<Forget
address={current.address}
key='modal-forget-account'
mode='address'
onClose={_toggleForget}
onForget={_onForget}
/>
)}
{isTransferOpen && (
<Transfer
key='modal-transfer'
onClose={_toggleTransfer}
recipientId={address}
/>
)}
</>
)}
</td>
<td className='number media--1500'>
{balancesAll?.accountNonce.gt(BN_ZERO) && formatNumber(balancesAll.accountNonce)}
</td>
<td className='number'>
<AddressInfo
address={address}
balancesAll={balancesAll}
withBalance={{
available: false,
bonded: false,
locked: false,
redeemable: false,
reserved: false,
total: true,
unlocking: false,
vested: false
}}
withExtended={false}
/>
</td>
<td className='links media--1400'>
<LinkExternal
className='ui--AddressCard-exporer-link'
data={address}
isLogo
type='address'
/>
</td>
<td className='fast-actions-addresses'>
<div className='fast-actions-row'>
{isFunction(api.api.tx.balances?.transfer) && (
<Button
className='send-button'
icon='paper-plane'
key='send'
label={t<string>('send')}
onClick={_toggleTransfer}
/>
)}
<Popup
className={`theme--${theme}`}
value={PopupDropdown}
/>
<ExpandButton
expanded={isExpanded}
onClick={toggleIsExpanded}
/>
</div>
</td>
</tr>
<tr className={`${className} ${isExpanded ? 'isExpanded' : 'isCollapsed'} ${isEven ? 'isEven' : 'isOdd'}`}>
<td />
<td>
<div
className='tags'
data-testid='tags'
>
<Tags
value={tags}
withTitle
/>
</div>
</td>
<td className='number media--1500' />
<td>
<AddressInfo
address={address}
balancesAll={balancesAll}
withBalance={{
available: true,
bonded: true,
locked: true,
redeemable: true,
reserved: true,
total: false,
unlocking: true,
vested: true
}}
withExtended={false}
/>
</td>
<td colSpan={2} />
</tr>
</>
);
}
export default React.memo(styled(Address)`
&.isCollapsed {
visibility: collapse;
}
&.isExpanded {
visibility: visible;
}
.tags {
width: 100%;
min-height: 1.5rem;
}
&& td.button {
padding-bottom: 0.5rem;
}
.fast-actions-addresses {
padding-left: 0.2rem;
padding-right: 1rem;
width: 1%;
.fast-actions-row {
display: flex;
align-items: center;
justify-content: flex-end;
& > * + * {
margin-left: 0.35rem;
}
.send-button {
min-width: 6.5rem;
}
}
}
`);

View File

@ -0,0 +1,135 @@
// Copyright 2017-2021 @polkadot/page-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import { screen } from '@testing-library/react';
import i18next from '@polkadot/react-components/i18n';
import { aContactWithBalance } from '@polkadot/test-support/creation/contact';
import { MemoryStore } from '@polkadot/test-support/keyring';
import { Table } from '@polkadot/test-support/pagesElements';
import { balance } from '@polkadot/test-support/utils/balance';
import { keyring } from '@polkadot/ui-keyring';
import { AddressesPage } from '../../test/pages/addressesPage';
describe('Addresses page', () => {
let addressesPage: AddressesPage;
beforeAll(async () => {
await i18next.changeLanguage('en');
if (keyring.getAccounts().length === 0) {
keyring.loadAll({ isDevelopment: true, store: new MemoryStore() });
}
});
beforeEach(() => {
addressesPage = new AddressesPage();
addressesPage.clearAccounts();
});
describe('when no contacts', () => {
let addressesTable: Table;
beforeEach(async () => {
addressesPage.render([]);
addressesTable = await addressesPage.getTable();
});
it('shows a table', () => {
expect(addressesTable).not.toBeNull();
});
it('the contacts table contains no contact rows', async () => {
expect(await addressesTable.getRows()).toHaveLength(0);
});
it('the contacts table contains a message about no contacts available', async () => {
const noContactsMessage = 'no addresses saved yet, add any existing address';
await addressesTable.assertText(noContactsMessage);
});
it('no summary is displayed', () => {
const summaries = screen.queryAllByTestId(/card-summary:total \w+/i);
expect(summaries).toHaveLength(0);
});
});
describe('when some contacts exist', () => {
it('the contacts table contains some contact rows', async () => {
addressesPage.renderDefaultContacts(2);
const rows = await addressesPage.getAddressesRows();
expect(rows).toHaveLength(2);
});
it('contact rows display the total balance info', async () => {
addressesPage.renderContactsWithDefaultAddresses(
aContactWithBalance({ freeBalance: balance(500) }),
aContactWithBalance({ freeBalance: balance(200), reservedBalance: balance(150) })
);
const rows = await addressesPage.getAddressesRows();
await rows[0].assertBalancesTotal(balance(500));
await rows[1].assertBalancesTotal(balance(350));
});
it('contact rows display the details balance info', async () => {
addressesPage.renderContactsWithDefaultAddresses(
aContactWithBalance({ freeBalance: balance(500), lockedBalance: balance(30) }),
aContactWithBalance({ availableBalance: balance(50), freeBalance: balance(200), reservedBalance: balance(150) })
);
const rows = await addressesPage.getAddressesRows();
await rows[0].assertBalancesDetails([
{ amount: balance(0), name: 'transferrable' },
{ amount: balance(30), name: 'locked' }]);
await rows[1].assertBalancesDetails([
{ amount: balance(50), name: 'transferrable' },
{ amount: balance(150), name: 'reserved' }]);
});
it('when a contact is not tagged, details row displays no tags info', async () => {
addressesPage.renderDefaultContacts(1);
const rows = await addressesPage.getAddressesRows();
await rows[0].assertTags('no tags');
});
it('when a contact is tagged, the details row displays tags', async () => {
const injectedAddress = '5CMCFVfsauWXmKaUB6tbznVUpBxcUZyU78DzvPTYrhdXe8Xp';
addressesPage.renderContacts([
[injectedAddress, { meta: { tags: ['my tag', 'Super Tag'] } }]
]);
const rows = await addressesPage.getAddressesRows();
expect(rows).toHaveLength(1);
await rows[0].assertTags('Super Tagmy tag');
});
it('details rows keep colouring from their primary rows', async () => {
addressesPage.renderDefaultContacts(3);
const addressesTable = await addressesPage.getTable();
await addressesTable.assertColoring();
});
it('contact details rows toggled on icon toggle click', async () => {
addressesPage.renderDefaultContacts(1);
const row = (await addressesPage.getAddressesRows())[0];
expect(row.detailsRow).toHaveClass('isCollapsed');
await row.expand();
expect(row.detailsRow).toHaveClass('isExpanded');
});
});
});

View File

@ -28,9 +28,8 @@ function Overview ({ className = '', onStatusChange }: Props): React.ReactElemen
const headerRef = useRef([
[t('contacts'), 'start', 2],
[t('tags'), 'start'],
[t('transactions'), 'media--1500'],
[t('balances'), 'expand'],
[t('transactions'), 'number media--1500'],
[t('balances'), 'balances'],
[undefined, 'media--1400'],
[]
]);
@ -81,10 +80,11 @@ function Overview ({ className = '', onStatusChange }: Props): React.ReactElemen
filter={filter}
header={headerRef.current}
>
{!isLoading && sortedAddresses?.map(({ address, isFavorite }): React.ReactNode => (
{!isLoading && sortedAddresses?.map(({ address, isFavorite }, index): React.ReactNode => (
<Address
address={address}
filter={filterOn}
isEven={!!(index % 2) }
isFavorite={isFavorite}
key={address}
toggleFavorite={toggleFavorite}

View File

@ -0,0 +1,56 @@
// Copyright 2017-2021 @polkadot/page-addresses authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import { aContact } from '@polkadot/test-support/creation/contact';
import { Page } from '@polkadot/test-support/pages/Page';
import { Row } from '@polkadot/test-support/pagesElements';
import { AccountOverrides as ContactOverrides } from '@polkadot/test-support/types';
import { mockAccountHooks } from '@polkadot/test-support/utils/accountDefaults';
import AddressOverview from '../../src/Contacts/index';
jest.mock('@polkadot/react-hooks/useAddresses', () => ({
useAddresses: () => ({
allAddresses: mockAccountHooks.useAccounts.allAccounts,
hasAddresses: mockAccountHooks.useAccounts.hasAccounts,
isAddress: true
})
}));
export class AddressesPage extends Page {
constructor () {
super(<AddressOverview />, 'Address-');
}
async getAddressesRows (): Promise<Row[]> {
const addressesTable = await this.getTable();
return addressesTable.getRows();
}
renderDefaultContacts (contactsNumber: number): void {
const contacts = Array.from({ length: contactsNumber },
(_, index) => [this.defaultAddresses[index], aContact()] as [string, ContactOverrides]);
this.render(contacts);
}
renderContactsWithDefaultAddresses (...overrides: ContactOverrides[]): void {
const contacts = overrides.map((contactProperties, index) =>
[this.defaultAddresses[index], contactProperties] as [string, ContactOverrides]);
this.render(contacts);
}
renderContactsForAddresses (...addresses: string[]): void {
const contacts = addresses.map((address) => [address, aContact()] as [string, ContactOverrides]);
this.render(contacts);
}
renderContacts (contacts: [string, ContactOverrides][]): void {
this.render(contacts);
}
}

View File

@ -0,0 +1,31 @@
// Copyright 2017-2021 @polkadot/test-supports authors & contributors
// SPDX-License-Identifier: Apache-2.0
import { DeriveBalancesAll, DeriveStakingAccount } from '@polkadot/api-derive/types';
import { UseAccountInfo } from '@polkadot/react-hooks/types';
import { KeyringJson$Meta } from '@polkadot/ui-keyring/types';
import { AccountOverrides, Override } from '../types';
export const anAccount = (): AccountOverrides => ({});
export const anAccountWithBalance = (balance: Override<DeriveBalancesAll>): AccountOverrides => ({
balance
});
export const anAccountWithBalanceAndMeta = (balance: Override<DeriveBalancesAll>, meta: Override<KeyringJson$Meta>): AccountOverrides => ({
balance,
meta
});
export const anAccountWithInfo = (info: Override<UseAccountInfo>): AccountOverrides => ({
info
});
export const anAccountWithMeta = (meta: Override<KeyringJson$Meta>): AccountOverrides => ({
meta
});
export const anAccountWithStaking = (staking: Override<DeriveStakingAccount>): AccountOverrides => ({
staking
});

View File

@ -1,4 +1,4 @@
// Copyright 2017-2021 @polkadot/app-bounties authors & contributors
// Copyright 2017-2021 @polkadot/test-supports authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Balance } from '@polkadot/types/interfaces';

View File

@ -0,0 +1,26 @@
// Copyright 2017-2021 @polkadot/test-supports authors & contributors
// SPDX-License-Identifier: Apache-2.0
import { DeriveBalancesAll, DeriveStakingAccount } from '@polkadot/api-derive/types';
import { UseAccountInfo } from '@polkadot/react-hooks/types';
import { KeyringJson$Meta } from '@polkadot/ui-keyring/types';
import { AccountOverrides as ContactOverrides, Override } from '../types';
export const aContact = (): ContactOverrides => ({});
export const aContactWithBalance = (balance: Override<DeriveBalancesAll>): ContactOverrides => ({
balance
});
export const aContactWithInfo = (info: Override<UseAccountInfo>): ContactOverrides => ({
info
});
export const aContactWithStaking = (staking: Override<DeriveStakingAccount>): ContactOverrides => ({
staking
});
export const aContactWithMeta = (meta: Override<KeyringJson$Meta>): ContactOverrides => ({
meta
});

View File

@ -3,5 +3,6 @@
export const alice = '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY';
export const bob = '5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty';
export const charlie = '5DAAnrj7VHTznn2AWBemMuyBwZWs6FNFjdyVXUeYum3PTXFy';
export const ferdie = '5CiPPseXPECbkjWCa6MnjNokrgYjMqmKndv2rSnekmSK2DjL';
export const defaultMembers = { isMember: true, members: [alice, bob, ferdie] };

View File

@ -1,5 +1,6 @@
// Copyright 2017-2021 @polkadot/test-support authors & contributors
// SPDX-License-Identifier: Apache-2.0
export * from './addresses';
export * from './MemoryStore';
export * from './signers';

View File

@ -0,0 +1,161 @@
// Copyright 2017-2021 @polkadot/page-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import { render, RenderResult, screen } from '@testing-library/react';
import BN from 'bn.js';
import React, { Suspense } from 'react';
import { MemoryRouter } from 'react-router-dom';
import { ThemeProvider } from 'styled-components';
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 { BlockNumber } from '@polkadot/types/interfaces';
import { keyring } from '@polkadot/ui-keyring';
import { alice, bob, charlie } from '../keyring';
import { Table } from '../pagesElements';
import { AccountOverrides, mockAccountHooks } from '../utils/accountDefaults';
let queueExtrinsic: (value: PartialQueueTxExtrinsic) => void;
function noop (): void {
// ignore
}
class NotYetRendered extends Error {
}
jest.mock('@polkadot/react-hooks/useAccounts', () => ({
useAccounts: () => mockAccountHooks.useAccounts
}));
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);
}
});
});
jest.mock('@polkadot/react-hooks/useLoadingDelay', () => ({
useLoadingDelay: () => false
}));
jest.mock('@polkadot/react-hooks/useBalancesAll', () => ({
useBalancesAll: (address: string) => mockAccountHooks.accountsMap[address].balance
}));
jest.mock('@polkadot/react-hooks/useStakingInfo', () => ({
useStakingInfo: (address: string) => mockAccountHooks.accountsMap[address].staking
}));
jest.mock('@polkadot/react-hooks/useBestNumber', () => ({
useBestNumber: () => 1
}));
export abstract class Page {
private renderResult?: RenderResult
protected readonly defaultAddresses = [alice, bob, charlie];
protected constructor (private readonly overview: React.ReactElement, private readonly rowClassName: string) {
this.overview = overview;
this.rowClassName = rowClassName;
}
render (accounts: [string, AccountOverrides][]): void {
mockAccountHooks.setAccounts(accounts);
accounts.forEach(([address, { meta }]) => {
keyring.addExternal(address, meta);
});
const mockApi: ApiProps = {
api: {
derive: {
accounts: {
info: () => Promise.resolve(() => { /**/
})
},
balances: {
all: () => ({
accountNonce: mockAccountHooks.nonce,
additional: [],
freeBalance: 0,
lockedBreakdown: [],
reservedBalance: 0
})
},
chain: {
bestNumber: () => new BN(1) as BlockNumber
}
},
genesisHash: new TypeRegistry().createType('Hash', POLKADOT_GENESIS),
query: {},
registry: { chainDecimals: [12], chainTokens: ['Unit'] },
tx: {
council: {
}
}
},
systemName: 'substrate'
} as unknown as ApiProps;
queueExtrinsic = jest.fn() as QueueTxExtrinsicAdd;
const queue = {
queueExtrinsic
} as QueueProps;
this.renderResult = render(
<>
<div id='tooltips' />
<Suspense fallback='...'>
<QueueProvider value={queue}>
<MemoryRouter>
<ThemeProvider theme={lightTheme}>
<ApiContext.Provider value={mockApi}>
<AccountSidebar>
{React.cloneElement(this.overview, { onStatusChange: noop }) }
</AccountSidebar>
</ApiContext.Provider>
</ThemeProvider>
</MemoryRouter>
</QueueProvider>
</Suspense>
</>
);
}
async getTable (): Promise<Table> {
this.assertRendered();
return new Table(await screen.findByRole('table'), this.rowClassName);
}
clearAccounts (): void {
this.defaultAddresses.forEach((address) => keyring.forgetAccount(address));
}
protected assertRendered (): void {
if (this.renderResult === undefined) {
throw new NotYetRendered();
}
}
}

View File

@ -0,0 +1,99 @@
// Copyright 2017-2021 @polkadot/test-supports authors & contributors
// SPDX-License-Identifier: Apache-2.0
// utility wrapper over an account item in accounts table, serves basic assertions about an account row
import { fireEvent, screen, within } from '@testing-library/react';
import { Sidebar } from '@polkadot/test-support/pagesElements/Sidebar';
import { Balance } from '@polkadot/types/interfaces';
import { format } from '../utils/balance';
export class Row {
public primaryRow: HTMLElement;
public detailsRow: HTMLElement;
public rowIndex: number;
constructor (primaryRow: HTMLElement, detailsRow: HTMLElement, rowIndex: number) {
this.primaryRow = primaryRow;
this.detailsRow = detailsRow;
this.rowIndex = rowIndex;
}
async assertBalancesTotal (expectedTotalBalance: Balance): Promise<void> {
const actualBalanceText = await this.getBalanceSummary();
const expectedBalanceText = format(expectedTotalBalance);
expect(actualBalanceText).toHaveTextContent(expectedBalanceText);
}
async getBalanceSummary (): Promise<HTMLElement> {
return within(this.primaryRow).findByTestId('balance-summary');
}
async assertAccountName (expectedName: string): Promise<void> {
const accountName = await this.getAccountName();
expect(accountName).toHaveTextContent(expectedName);
}
async assertBalancesDetails (expectedBalanceComponents: { name: string, amount: Balance }[]): Promise<void> {
for (const { amount, name } of expectedBalanceComponents) {
await this.assertBalanceComponent({ amount, name });
}
}
async assertTags (expectedTagsContent: string): Promise<void> {
const actualTags = await within(this.detailsRow).findByTestId('tags');
expect(actualTags).toHaveTextContent(expectedTagsContent);
}
async assertShortAddress (expectedShortAddress: string): Promise<void> {
const actualShortAddress = await within(this.primaryRow).findByTestId('short-address');
expect(actualShortAddress).toHaveTextContent(expectedShortAddress);
}
assertColoring (): void {
const expectedColorClass = this.getExpectedColorClass();
expect(this.primaryRow).toHaveClass(expectedColorClass);
expect(this.detailsRow).toHaveClass(expectedColorClass);
}
async expand (): Promise<void> {
const toggle = await within(this.primaryRow).findByTestId('row-toggle');
fireEvent.click(toggle);
}
async openSidebar (): Promise<Sidebar> {
const accountName = await this.getAccountName();
fireEvent.click(accountName);
return new Sidebar(await screen.findByTestId('account-sidebar'));
}
private async assertBalanceComponent (expectedBalanceComponent: { name: string; amount: Balance }): Promise<void> {
const balanceElement = await this.getBalanceElementByLabelName(expectedBalanceComponent.name);
const balanceText = format(expectedBalanceComponent.amount);
expect(balanceElement).toHaveTextContent(balanceText);
}
private async getBalanceElementByLabelName (labelName: string): Promise<ChildNode | null> {
const labelElement = await within(this.detailsRow).findByText(labelName);
return labelElement.nextSibling;
}
private getAccountName (): Promise<HTMLElement> {
return within(this.primaryRow).findByTestId('account-name');
}
private getExpectedColorClass (): string {
return this.rowIndex % 2 ? 'isEven' : 'isOdd';
}
}

View File

@ -31,9 +31,9 @@ export class Sidebar {
}
async assertAccountInput (expectedInput: string): Promise<void> {
const sideBarName = await this.findByTestId('name-input');
const nameInput = await this.findByTestId('name-input');
expect(sideBarName).toHaveProperty('value', expectedInput);
expect(nameInput).toHaveProperty('value', expectedInput);
}
async assertAccountName (expectedAccountName: string): Promise<void> {
@ -109,9 +109,9 @@ export class Sidebar {
}
private clickButton (buttonName: string) {
const saveButton = this.getByRole('button', { name: buttonName });
const button = this.getByRole('button', { name: buttonName });
fireEvent.click(saveButton);
fireEvent.click(button);
}
private openTagsDropdown (): HTMLElement {

View File

@ -0,0 +1,75 @@
// Copyright 2017-2021 @polkadot/test-supports authors & contributors
// SPDX-License-Identifier: Apache-2.0
import { within } from '@testing-library/react';
import { showBalance } from '../utils/balance';
import { Row } from './Row';
export class Table {
constructor (private readonly table: HTMLElement, private readonly rowClassName: string) {
this.table = table;
this.rowClassName = rowClassName;
}
async assertRowsOrderAndColoring (order: number[]): Promise<void> {
await this.assertRowsOrder(order);
await this.assertColoring();
}
async assertRowsOrder (balancesExpectedOrder: number[]): Promise<void> {
const orderedRows = await this.getRows();
await Promise.all(orderedRows.map(async (row, index) => {
const expectedBalanceTextContent = showBalance(balancesExpectedOrder[index]);
expect(await row.getBalanceSummary()).toHaveTextContent(expectedBalanceTextContent);
}));
}
async assertColoring (): Promise<void> {
const collapsibleRows = await this.getRows();
for (const collapsibleRow of collapsibleRows) {
collapsibleRow.assertColoring();
}
}
async getRows (): Promise<Row[]> {
const htmlRows = await this.getFilteredHtmlRows();
const collapsibleRows: Row[] = [];
for (let rowIdx = 0; rowIdx < htmlRows.length; rowIdx = rowIdx + 2) {
const primaryRow = htmlRows[rowIdx];
const detailsRow = htmlRows[rowIdx + 1];
collapsibleRows.push(new Row(primaryRow, detailsRow, rowIdx / 2));
}
return collapsibleRows;
}
assertColumnNotExist (columnName: string): void {
expect(within(this.table).queryByRole('columnheader', { name: columnName })).toBeFalsy();
}
assertColumnExists (columnName: string): void {
expect(within(this.table).getByRole('columnheader', { name: columnName })).toBeTruthy();
}
async assertText (text: string): Promise<HTMLElement> {
return within(this.table).findByText(text);
}
private async getFilteredHtmlRows (): Promise<HTMLElement[]> {
const htmlRows = await this.getAllHtmlRows();
return htmlRows.filter((row) => row.className.startsWith(this.rowClassName));
}
private async getAllHtmlRows (): Promise<HTMLElement[]> {
const tableBody = this.table.getElementsByTagName('tbody')[0];
return within(tableBody).findAllByRole('row');
}
}

View File

@ -0,0 +1,5 @@
// Copyright 2017-2021 @polkadot/test-supports authors & contributors
// SPDX-License-Identifier: Apache-2.0
export * from './Row';
export * from './Table';

View File

@ -0,0 +1,17 @@
// Copyright 2017-2021 @polkadot/test-supports authors & contributors
// SPDX-License-Identifier: Apache-2.0
import { DeriveBalancesAll, DeriveStakingAccount } from '@polkadot/api-derive/types';
import { UseAccountInfo } from '@polkadot/react-hooks/types';
import { KeyringJson$Meta } from '@polkadot/ui-keyring/types';
export type Override<T> = {
[P in keyof T]?: T[P];
}
export interface AccountOverrides {
meta?: Override<KeyringJson$Meta>;
balance?: Override<DeriveBalancesAll>;
staking?: Override<DeriveStakingAccount>;
info?: Override<UseAccountInfo>;
}

View File

@ -1,4 +1,4 @@
// Copyright 2017-2021 @polkadot/page-accounts authors & contributors
// Copyright 2017-2021 @polkadot/test-supports authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DeriveBalancesAll, DeriveStakingAccount } from '@polkadot/api-derive/types';
@ -7,10 +7,11 @@ import type { UseAccountInfo } from '@polkadot/react-hooks/types';
import BN from 'bn.js';
import { UseAccounts } from '@polkadot/react-hooks/useAccounts';
import { balanceOf } from '@polkadot/test-support/creation/balance';
import { makeStakingLedger } from '@polkadot/test-support/creation/stakingInfo/stakingLedger';
import { KeyringJson$Meta } from '@polkadot/ui-keyring/types';
import { balanceOf } from '../creation/balance';
import { makeStakingLedger } from '../creation/stakingInfo/stakingLedger';
export interface Account {
balance: DeriveBalancesAll,
info: UseAccountInfo,

View File

@ -0,0 +1,29 @@
// Copyright 2017-2021 @polkadot/test-supports authors & contributors
// SPDX-License-Identifier: Apache-2.0
import BN from 'bn.js';
import { Balance } from '@polkadot/types/interfaces';
import { formatBalance } from '@polkadot/util';
import { balanceOf } from '../creation/balance';
/**
* Creates a balance instance for testing purposes which most often do not need to specifiy/use decimal part.
* @param amountInt Integer part of the balance number
* @param decimalsString Decimals part of the balance number. Note! This is a string sequence just after '.' separator
* that is the point that separates integers from decimals. E.g. (100, 4567) => 100.45670000...00
*/
export function balance (amountInt: number, decimalsString?: string): Balance {
const decimalsPadded = (decimalsString || '').padEnd(12, '0');
return balanceOf(amountInt.toString() + decimalsPadded);
}
export function showBalance (amount: number): string {
return format(balance(amount));
}
export function format (amount: Balance | BN): string {
return formatBalance(amount, { decimals: 12, forceUnit: '-', withUnit: true });
}

View File

@ -0,0 +1,26 @@
// Copyright 2017-2021 @polkadot/test-supports authors & contributors
// SPDX-License-Identifier: Apache-2.0
import { fireEvent, screen } from '@testing-library/react';
export const clickButton = async (buttonName: string): Promise<void> => {
const button = await screen.findByRole('button', { name: buttonName });
fireEvent.click(button);
};
export const assertText = async (text: string): Promise<HTMLElement> => {
return screen.findByText(text);
};
export const fillInput = (inputTestId: string, value: string): void => {
const nameInput = screen.getByTestId(inputTestId);
fireEvent.change(nameInput, { target: { value } });
};
export const assertButtonDisabled = (buttonName: string): void => {
const button = screen.getByRole('button', { name: buttonName });
expect(button).toHaveClass('isDisabled');
};