Add sorting for accounts view (#5923)

* Show basic balance info

* Show bonded summary

* Show redeemable summary

* Show unbonding summary

* Adopt linter suggestions

* Hide cards with zero balance

* Apply simplifications

Co-authored-by: Jaco <jacogr@gmail.com>

* Remove dead code

* Simplify balance==undefined case

* Add summary bar tests (#5890)

* Add tests for summary bar

* Improve test case name

Co-authored-by: Krzysztof Jelski <kjelski@gmail.com>

* Factor empty render into a beforeEach block

* Improve name of helper variable

Co-authored-by: Krzysztof Jelski <kjelski@gmail.com>

* Construct correct instance for StakingLedger.active

* Override only the relevant parts in each test case

* Simplify test cases

Co-authored-by: Krzysztof Jelski <kjelski@gmail.com>

* Sort by total balance and by type

* Rename "clas" to "htmlClass"

* Extract DEFAULT_SORT_CONTROLS constant

* Move stableSort to its own file

* Optimize sorting on accounts page

* Adopt new sorting design

* Apply suggestions from code review

Co-authored-by: Jaco <jacogr@gmail.com>

* Remove stableSort function

* Move Dropdown css to Account styles

* Add tests for sorting

* Adjust tests and apply lint fixes

* Apply lint fixes

* Improve row coloring and styling of button for sort direction

* Improve styling

* Fix tests setup and bug connected with cloning accounts

* Restore condition

* Fix imports

Co-authored-by: Jaco <jacogr@gmail.com>
Co-authored-by: Krzysztof Jelski <kjelski@gmail.com>
Co-authored-by: MiZiet <michalzietalak@o2.pl>
Co-authored-by: Ewa Kowalska <ewcia.kowalska@gmail.com>
This commit is contained in:
Vitaliy 2021-09-06 14:14:56 +02:00 committed by GitHub
parent 8295f01e9e
commit 734c3c653a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 452 additions and 177 deletions

View File

@ -332,6 +332,7 @@
"signatories": "signatories",
"signatory": "signatory",
"somebody@example.com": "somebody@example.com",
"sort by": "sort by",
"stash address": "stash address",
"sub": "sub",
"sub name": "sub name",

View File

@ -16,33 +16,27 @@ import { MemoryStore } from '@polkadot/test-support/keyring';
import { keyring } from '@polkadot/ui-keyring';
import { KeyringJson$Meta } from '@polkadot/ui-keyring/types';
import { AccountOverrides } from '../../test/hooks/default';
import { AccountOverrides, Override } from '../../test/hooks/default';
import { AccountsPage, format } from '../../test/pages/accountsPage';
describe('Accounts page', () => {
const aliceAddress = '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY';
const bobAddress = '5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty';
const charlieAddress = '5DAAnrj7VHTznn2AWBemMuyBwZWs6FNFjdyVXUeYum3PTXFy';
const aliceDerivedAddress = '5Dc96kiTPTfZHmq6yTFSqejJzfUNfQQjneNesRWf9MDppJsd';
const addAccountToKeyring = (address: string, meta: KeyringJson$Meta) => {
keyring.addExternal(address, meta);
};
const forgetAccountFromKeyring = (address: string) => {
keyring.forgetAccount(address);
};
let accountsPage: AccountsPage;
beforeAll(async () => {
await i18next.changeLanguage('en');
keyring.loadAll({ isDevelopment: true, store: new MemoryStore() });
if (keyring.getAccounts().length === 0) {
keyring.loadAll({ isDevelopment: true, store: new MemoryStore() });
}
});
beforeEach(() => {
defaultAddresses.forEach((address) => keyring.forgetAccount(address));
accountsPage = new AccountsPage();
forgetAccountFromKeyring(aliceDerivedAddress);
});
describe('when no accounts', () => {
@ -50,6 +44,16 @@ describe('Accounts page', () => {
accountsPage.renderPage([]);
});
it('shows sort-by controls', async () => {
const section = await accountsPage.findSortBySection();
expect(section).not.toBeNull();
const button = await accountsPage.findSortByReverseButton();
expect(button).not.toBeNull();
});
it('shows a table', async () => {
const accountsTable = await accountsPage.findAccountsTable();
@ -80,7 +84,6 @@ describe('Accounts page', () => {
describe('when some accounts exist', () => {
it('the accounts table contains some account rows', async () => {
renderDefaultAccounts(2);
const accountRows = await accountsPage.findAccountRows();
expect(accountRows).toHaveLength(2);
@ -115,11 +118,9 @@ describe('Accounts page', () => {
});
it('derived account displays parent account info', async () => {
addAccountToKeyring(aliceDerivedAddress, { parentAddress: aliceAddress });
renderAccountsForAddresses(
aliceAddress,
aliceDerivedAddress
renderAccountsWithDefaultAddresses(
anAccountWithMeta({ isInjected: true, name: 'ALICE', whenCreated: 200 }),
anAccountWithMeta({ name: 'ALICE_CHILD', parentAddress: aliceAddress, whenCreated: 300 })
);
const accountRows = await accountsPage.findAccountRows();
@ -168,16 +169,7 @@ describe('Accounts page', () => {
it('account details rows keep colouring from their primary rows', async () => {
renderDefaultAccounts(3);
const rows = await accountsPage.findAccountRows();
expect(rows[0].primaryRow).toHaveClass('isOdd');
expect(rows[0].detailsRow).toHaveClass('isOdd');
expect(rows[1].primaryRow).toHaveClass('isEven');
expect(rows[1].detailsRow).toHaveClass('isEven');
expect(rows[2].primaryRow).toHaveClass('isOdd');
expect(rows[2].detailsRow).toHaveClass('isOdd');
await accountsPage.checkRowsColoring();
});
it('account details rows toggled on icon toggle click', async () => {
@ -299,31 +291,111 @@ describe('Accounts page', () => {
expect(summary).toHaveTextContent(showBalance(4000 + 5000));
});
it('sorts accounts by date by default', async () => {
renderAccountsWithDefaultAddresses(
anAccountWithBalanceAndMeta({ freeBalance: balance(1) }, { whenCreated: 200 }),
anAccountWithBalanceAndMeta({ freeBalance: balance(2) }, { whenCreated: 300 }),
anAccountWithBalanceAndMeta({ freeBalance: balance(3) }, { whenCreated: 100 })
);
expect(await accountsPage.findSortByDropdownCurrent()).toHaveTextContent('date');
await accountsPage.checkOrderAndRowsColoring([3, 1, 2]);
});
describe('when sorting is used', () => {
beforeEach(() => {
renderAccountsWithDefaultAddresses(
anAccountWithBalanceAndMeta({ freeBalance: balance(1) }, { isInjected: true, name: 'bbb', whenCreated: 200 }),
anAccountWithBalanceAndMeta({ freeBalance: balance(2) }, {
hardwareType: 'ledger',
isHardware: true,
name: 'bb',
parentAddress: aliceAddress,
whenCreated: 300
}),
anAccountWithBalanceAndMeta({ freeBalance: balance(3) }, { isInjected: true, name: 'aaa', whenCreated: 100 })
);
});
it('changes default dropdown value', async () => {
await accountsPage.selectOrder('balances');
expect(await accountsPage.findSortByDropdownCurrent())
.toHaveTextContent('balances');
});
it('sorts by parent if asked', async () => {
await accountsPage.selectOrder('parent');
await accountsPage.checkOrderAndRowsColoring([3, 1, 2]);
});
it('sorts by name if asked', async () => {
await accountsPage.selectOrder('name');
await accountsPage.checkOrderAndRowsColoring([3, 2, 1]);
});
it('sorts by date if asked', async () => {
await accountsPage.selectOrder('date');
await accountsPage.checkOrderAndRowsColoring([3, 1, 2]);
});
it('sorts by balances if asked', async () => {
await accountsPage.selectOrder('balances');
await accountsPage.checkOrderAndRowsColoring([1, 2, 3]);
});
it('sorts by type if asked', async () => {
await accountsPage.selectOrder('type');
await accountsPage.checkOrderAndRowsColoring([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();
});
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]);
});
});
});
/**
* 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
*/
const balance = function (amountInt: number, decimalsString?: string): Balance {
const decimalsPadded = (decimalsString || '').padEnd(12, '0');
return balanceOf(amountInt.toString() + decimalsPadded);
};
const anAccount = (): AccountOverrides => ({});
const anAccountWithBalance = (balance: { [P in keyof DeriveBalancesAll]?: DeriveBalancesAll[P] }) => ({
const anAccountWithBalance = (balance: Override<DeriveBalancesAll>) => ({
balance
});
const anAccountWithInfo = (info: { [P in keyof UseAccountInfo]?: UseAccountInfo[P] }) => ({
const anAccountWithBalanceAndMeta = (balance: Override<DeriveBalancesAll>, meta: Override<KeyringJson$Meta>) => ({
balance,
meta
});
const anAccountWithInfo = (info: Override<UseAccountInfo>) => ({
info
});
const anAccountWithStaking = (staking: { [P in keyof DeriveStakingAccount]?: DeriveStakingAccount[P]}) => ({
const anAccountWithMeta = (meta: Override<KeyringJson$Meta>) => ({
meta
});
const anAccountWithStaking = (staking: Override<DeriveStakingAccount>) => ({
staking
});
@ -339,11 +411,7 @@ describe('Accounts page', () => {
return format(balance(amount));
}
const defaultAddresses = [
aliceAddress,
bobAddress,
charlieAddress
];
const defaultAddresses = [aliceAddress, bobAddress, charlieAddress];
function renderAccountsWithDefaultAddresses (...overrides: AccountOverrides[]): void {
const accounts = overrides.map((accountProperties, index) =>
@ -365,3 +433,15 @@ describe('Accounts page', () => {
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

@ -6,11 +6,12 @@ import type { ActionStatus } from '@polkadot/react-components/Status/types';
import type { AccountId, ProxyDefinition, ProxyType, Voting } from '@polkadot/types/interfaces';
import type { AccountBalance, Delegation, SortedAccount } from '../types';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import React, { ReactElement, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import styled from 'styled-components';
import { Button, Input, Table } from '@polkadot/react-components';
import { Button, Dropdown, Input, SummaryBox, Table } from '@polkadot/react-components';
import { useAccounts, useApi, useCall, useFavorites, useIpfs, useLedger, useLoadingDelay, useToggle } from '@polkadot/react-hooks';
import { keyring } from '@polkadot/ui-keyring';
import { BN_ZERO } from '@polkadot/util';
import CreateModal from '../modals/Create';
@ -20,7 +21,7 @@ import Multisig from '../modals/MultisigCreate';
import Proxy from '../modals/ProxiedAdd';
import Qr from '../modals/Qr';
import { useTranslation } from '../translate';
import { sortAccounts } from '../util';
import { sortAccounts, SortCategory, sortCategory } from '../util';
import Account from './Account';
import BannerClaims from './BannerClaims';
import BannerExtension from './BannerExtension';
@ -31,16 +32,18 @@ interface Balances {
summary?: AccountBalance;
}
interface Sorted {
sortedAccounts: SortedAccount[];
sortedAddresses: string[];
}
interface Props {
className?: string;
onStatusChange: (status: ActionStatus) => void;
}
interface SortControls {
sortBy: SortCategory;
sortFromMax: boolean;
}
const DEFAULT_SORT_CONTROLS: SortControls = { sortBy: 'date', sortFromMax: true };
const STORE_FAVS = 'accounts:favorites';
function Overview ({ className = '', onStatusChange }: Props): React.ReactElement<Props> {
@ -58,10 +61,10 @@ function Overview ({ className = '', onStatusChange }: Props): React.ReactElemen
const [favorites, toggleFavorite] = useFavorites(STORE_FAVS);
const [balances, setBalances] = useState<Balances>({ accounts: {} });
const [filterOn, setFilter] = useState<string>('');
const [sortedAccountsWithDelegation, setSortedAccountsWithDelegation] = useState<SortedAccount[] | undefined>();
const [{ sortedAccounts, sortedAddresses }, setSorted] = useState<Sorted>({ sortedAccounts: [], sortedAddresses: [] });
const delegations = useCall<Voting[]>(api.query.democracy?.votingOf?.multi, [sortedAddresses]);
const proxies = useCall<[ProxyDefinition[], BN][]>(api.query.proxy?.proxies.multi, [sortedAddresses], {
const [sortedAccounts, setSorted] = useState<SortedAccount[]>([]);
const [{ sortBy, sortFromMax }, setSortBy] = useState<SortControls>(DEFAULT_SORT_CONTROLS);
const delegations = useCall<Voting[]>(api.query.democracy?.votingOf?.multi, [allAccounts]);
const proxies = useCall<[ProxyDefinition[], BN][]>(api.query.proxy?.proxies.multi, [allAccounts], {
transform: (result: [([AccountId, ProxyType] | ProxyDefinition)[], BN][]): [ProxyDefinition[], BN][] =>
api.tx.proxy.addProxy.meta.args.length === 3
? result as [ProxyDefinition[], BN][]
@ -71,7 +74,38 @@ function Overview ({ className = '', onStatusChange }: Props): React.ReactElemen
});
const isLoading = useLoadingDelay();
const headerRef = useRef([
// We use favorites only to check if it includes some element,
// so Object is better than array for that because hashmap access is O(1).
const favoritesMap = useMemo(() => Object.fromEntries(favorites.map((x) => [x, true])), [favorites]);
const accountsWithInfo = useMemo(() =>
allAccounts
.map((address, index): SortedAccount => {
const deleg = delegations && delegations[index]?.isDelegating && delegations[index]?.asDelegating;
const delegation: Delegation | undefined = (deleg && {
accountDelegated: deleg.target.toString(),
amount: deleg.balance,
conviction: deleg.conviction
}) || undefined;
return {
account: keyring.getAccount(address),
address,
delegation,
isFavorite: favoritesMap[address ?? ''] ?? false
};
})
, [allAccounts, favoritesMap, delegations]);
const accountsMap = useMemo(() => {
const ret: Record<string, SortedAccount> = {};
accountsWithInfo.forEach(function (x) { ret[x.address] = x; });
return ret;
}, [accountsWithInfo]);
const header = useRef([
[t('accounts'), 'start', 3],
[t('type')],
[t('transactions'), 'media--1500'],
@ -80,34 +114,18 @@ function Overview ({ className = '', onStatusChange }: Props): React.ReactElemen
]);
useEffect((): void => {
const sortedAccounts = sortAccounts(allAccounts, favorites);
const sortedAddresses = sortedAccounts.map((a) => a.account.address);
// We add new accounts to the end
setSorted((sortedAccounts) =>
[...sortedAccounts.map((x) => accountsWithInfo.find((y) => x.address === y.address)).filter((x): x is SortedAccount => !!x),
...accountsWithInfo.filter((x) => !sortedAccounts.find((y) => x.address === y.address))]);
}, [accountsWithInfo]);
setSorted({ sortedAccounts, sortedAddresses });
}, [allAccounts, favorites]);
const accounts = balances.accounts;
useEffect(() => {
setSortedAccountsWithDelegation(
sortedAccounts?.map((account, index) => {
let delegation: Delegation | undefined;
if (delegations && delegations[index]?.isDelegating) {
const { balance: amount, conviction, target } = delegations[index].asDelegating;
delegation = {
accountDelegated: target.toString(),
amount,
conviction
};
}
return ({
...account,
delegation
});
})
);
}, [api, delegations, sortedAccounts]);
useEffect((): void => {
setSorted((sortedAccounts) =>
sortAccounts(sortedAccounts, accountsMap, accounts, sortBy, sortFromMax));
}, [accountsWithInfo, accountsMap, accounts, sortBy, sortFromMax]);
const _setBalance = useCallback(
(account: string, balance: AccountBalance) =>
@ -146,6 +164,32 @@ function Overview ({ className = '', onStatusChange }: Props): React.ReactElemen
</div>
), [filterOn, t]);
const accountComponents = useMemo(() => {
const ret: Record<string, React.ReactNode> = {};
accountsWithInfo.forEach(({ account, address, delegation, isFavorite }, index) => {
ret[address] =
<Account
account={account}
delegation={delegation}
filter={filterOn}
isFavorite={isFavorite}
key={`${index}:${address}`}
proxy={proxies?.[index]}
setBalance={_setBalance}
toggleFavorite={toggleFavorite}
/>;
});
return ret;
}, [accountsWithInfo, filterOn, proxies, _setBalance, toggleFavorite]);
const onDropdownChange = () => (item: SortCategory) => setSortBy({ sortBy: item, sortFromMax });
const dropdownOptions = () => sortCategory.map((x) => ({ text: x, value: x }));
const onSortDirectionChange = () => () => setSortBy({ sortBy, sortFromMax: !sortFromMax });
return (
<div className={className}>
{isCreateOpen && (
@ -224,24 +268,37 @@ function Overview ({ className = '', onStatusChange }: Props): React.ReactElemen
<BannerExtension />
<BannerClaims />
<Summary balance={balances.summary} />
<Table
empty={!isLoading && sortedAccountsWithDelegation && t<string>("You don't have any accounts. Some features are currently hidden and will only become available once you have accounts.")}
filter={filter}
header={headerRef.current}
>
{!isLoading && sortedAccountsWithDelegation?.map(({ account, delegation, isFavorite }, index): React.ReactNode => (
<Account
account={account}
delegation={delegation}
filter={filterOn}
isEven={!!(index % 2)}
isFavorite={isFavorite}
key={`${index}:${account.address}`}
proxy={proxies?.[index]}
setBalance={_setBalance}
toggleFavorite={toggleFavorite}
<SummaryBox>
<section
className='dropdown-section'
data-testid='sort-by-section'
>
<Dropdown
defaultValue={sortBy}
label={t<string>('sort by')}
onChange={onDropdownChange()}
options={dropdownOptions()}
/>
))}
<Button
className='sort-direction-button'
icon='arrows-alt-v'
isCircular
onClick={onSortDirectionChange()}
/>
</section>
</SummaryBox>
<Table
empty={!isLoading && sortedAccounts && t<string>("You don't have any accounts. Some features are currently hidden and will only become available once you have accounts.")}
filter={filter}
header={header.current}
>
{!isLoading &&
sortedAccounts.map(({ address }, index) => {
const account = accountComponents[address];
return account && React.cloneElement(account as ReactElement, { isEven: !!(index % 2) });
})
}
</Table>
</div>
);
@ -257,4 +314,20 @@ export default React.memo(styled(Overview)`
}
}
}
.ui--Dropdown {
max-width: 185px;
align: flex start;
margin-left: -28px;
}
.dropdown-section {
display: flex;
flex-direction: row;
align-items: center;
}
.sort-direction-button {
padding: revert;
}
`);

View File

@ -19,7 +19,10 @@ describe('Create an account modal', () => {
beforeAll(async () => {
await i18next.changeLanguage('en');
keyring.loadAll({ isDevelopment: true, store: new MemoryStore() });
if (keyring.getAccounts().length === 0) {
keyring.loadAll({ isDevelopment: true, store: new MemoryStore() });
}
});
beforeEach(() => {
accountsPage = new AccountsPage();

View File

@ -28,8 +28,8 @@ export interface Delegation {
}
export interface SortedAccount {
account: KeyringAddress;
children: SortedAccount[];
account?: KeyringAddress;
address: string;
delegation?: Delegation;
isFavorite: boolean;
}

View File

@ -2,15 +2,17 @@
// SPDX-License-Identifier: Apache-2.0
import type { ActionStatus } from '@polkadot/react-components/Status/types';
import type { CreateResult, KeyringAddress } from '@polkadot/ui-keyring/types';
import type { SortedAccount } from './types';
import type { AccountId, AccountIndex, Address } from '@polkadot/types/interfaces';
import type { CreateResult } from '@polkadot/ui-keyring/types';
import type { AccountBalance, SortedAccount } from './types';
import FileSaver from 'file-saver';
import React from 'react';
import { getEnvironment } from '@polkadot/react-api/util';
import { InputAddress, Menu } from '@polkadot/react-components';
import { keyring } from '@polkadot/ui-keyring';
import { getAccountCryptoType, getAddressMeta } from '@polkadot/react-components/util';
import { BN_ZERO } from '@polkadot/util';
export function createMenuGroup (key: string, items: (React.ReactNode | false | undefined | null)[], header?: string): React.ReactNode | null {
const filtered = items.filter((item): item is React.ReactNode => !!item);
@ -24,6 +26,8 @@ export function createMenuGroup (key: string, items: (React.ReactNode | false |
: null;
}
export type AccountIdIsh = AccountId | AccountIndex | Address | string | Uint8Array | null;
export function downloadAccount ({ json, pair }: CreateResult): void {
FileSaver.saveAs(
new Blob([JSON.stringify(json)], { type: 'application/json; charset=utf-8' }),
@ -58,49 +62,54 @@ export function tryCreateAccount (commitAccount: () => CreateResult, success: st
return status;
}
function expandList (mapped: SortedAccount[], entry: SortedAccount): SortedAccount[] {
mapped.push(entry);
export const sortCategory = ['parent', 'name', 'date', 'balances', 'type'] as const;
export type SortCategory = typeof sortCategory[number];
entry.children.forEach((entry): void => {
expandList(mapped, entry);
});
const comparator = (accounts: Record<string, SortedAccount | undefined>, balances: Record<string, AccountBalance | undefined>, category: SortCategory, fromMax: boolean) => {
function accountQualifiedName (account: SortedAccount | undefined): string {
if (account) {
const parent = (account.account?.meta.parentAddress || '') as string;
return mapped;
}
return accountQualifiedName(accounts[parent]) + account.address;
} else {
return '';
}
}
export function sortAccounts (addresses: string[], favorites: string[]): SortedAccount[] {
const mapped = addresses
.map((address) => keyring.getAccount(address))
.filter((account): account is KeyringAddress => !!account)
.map((account): SortedAccount => ({
account,
children: [],
isFavorite: favorites.includes(account.address)
}))
.sort((a, b) => (a.account.meta.whenCreated || 0) - (b.account.meta.whenCreated || 0));
function make <T> (getValue: (acc: SortedAccount) => T, compare: (a: T, b: T) => number) {
const mult = fromMax ? 1 : -1;
return mapped
.filter((entry): boolean => {
const parentAddress = entry.account.meta.parentAddress;
return (a: SortedAccount, b: SortedAccount) =>
mult * compare(getValue(a), getValue(b));
}
if (parentAddress) {
const parent = mapped.find(({ account: { address } }) => address === parentAddress);
switch (category) {
case 'parent':
// We need to ensure that parent will be adjacent to its children
// so we use format '...<grand-parent-addr><parent-addr><account-addr>'
return make(accountQualifiedName, (a, b) => a.localeCompare(b));
if (parent) {
parent.children.push(entry);
case 'name':
return make((acc) => getAddressMeta(acc.address).name ?? '', (a, b) => a.localeCompare(b));
return false;
}
}
case 'date':
return make((acc) => acc.account?.meta.whenCreated ?? 0, (a, b) => a - b);
return true;
})
.reduce(expandList, [])
.sort((a, b): number =>
case 'balances':
return make((acc) => balances[acc.address]?.total ?? BN_ZERO, (a, b) => a.cmp(b));
case 'type':
return make((acc) => getAccountCryptoType(acc.address), (a, b) => a.localeCompare(b));
}
};
export function sortAccounts (accountsList: SortedAccount[], accountsMap: Record<string, SortedAccount>, balances: Record<string, AccountBalance>, by: SortCategory, fromMax: boolean): SortedAccount[] {
return [...accountsList]
.sort(comparator(accountsMap, balances, by, fromMax))
.sort((a, b) =>
a.isFavorite === b.isFavorite
? 0
: b.isFavorite
? 1
: -1
);
: -1);
}

View File

@ -9,6 +9,7 @@ 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';
export interface Account {
balance: DeriveBalancesAll,
@ -18,19 +19,18 @@ export interface Account {
export type AccountsMap = { [address: string]: Account };
export type Override<T> = {
[P in keyof T]?: T[P];
}
/**
* Test inputs structure
*/
export interface AccountOverrides {
balance?: {
[P in keyof DeriveBalancesAll]?: DeriveBalancesAll[P];
};
staking?: {
[P in keyof DeriveStakingAccount]?: DeriveStakingAccount[P];
};
info?: {
[P in keyof UseAccountInfo]?: UseAccountInfo[P];
}
meta?: Override<KeyringJson$Meta>;
balance?: Override<DeriveBalancesAll>;
staking?: Override<DeriveStakingAccount>;
info?: Override<UseAccountInfo>;
}
export const emptyAccounts: UseAccounts = {
@ -79,6 +79,7 @@ export const defaultStakingAccount: DeriveStakingAccount = {
]
} as any;
export const defaultMeta: KeyringJson$Meta = {};
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
export const defaultAccountInfo: UseAccountInfo = {
flags: {},
@ -102,12 +103,15 @@ class MockAccountHooks {
for (const [address, props] of accounts) {
const staking = { ...defaultStakingAccount };
const meta = { ...defaultMeta };
const balance = { ...defaultBalanceAccount };
const info = { ...defaultAccountInfo };
// Typescript does not recognize that keys and values from Object.entries are safe,
// so we have to use "any" here.
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
Object.entries(props.meta || meta).forEach(function ([key, value]) { (meta as any)[key] = value; });
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
Object.entries(props.balance || balance).forEach(function ([key, value]) { (balance as any)[key] = value; });
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access

View File

@ -7,6 +7,7 @@ import React, { Suspense } from 'react';
import { MemoryRouter } from 'react-router-dom';
import { ThemeProvider } from 'styled-components';
import { balance } from '@polkadot/app-accounts/Accounts/index.spec';
import { lightTheme } from '@polkadot/apps/themes';
import { POLKADOT_GENESIS } from '@polkadot/apps-config';
import { ApiContext } from '@polkadot/react-api';
@ -15,6 +16,7 @@ import { QueueProvider } from '@polkadot/react-components/Status/Context';
import { PartialQueueTxExtrinsic, QueueProps, QueueTxExtrinsicAdd } from '@polkadot/react-components/Status/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';
@ -104,6 +106,11 @@ export class AccountsPage {
renderPage (accounts: [string, AccountOverrides][]): void {
mockAccountHooks.setAccounts(accounts);
accounts.forEach(([address, { meta }]) => {
keyring.addExternal(address, meta);
});
const mockApi: ApiProps = {
api: {
derive: {
@ -181,6 +188,86 @@ export class AccountsPage {
return rows;
}
async findSortBySection (): 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([]);

View File

@ -1,14 +1,13 @@
// Copyright 2017-2021 @polkadot/react-components authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { AccountId, AccountIndex, Address } from '@polkadot/types/interfaces';
import React, { useEffect, useState } from 'react';
import { keyring } from '@polkadot/ui-keyring';
import { AccountIdIsh } from './types';
import { getAccountCryptoType } from './util';
interface Props {
accountId: AccountId | AccountIndex | Address | string | Uint8Array | null;
accountId: AccountIdIsh;
className?: string;
label?: string;
}
@ -17,28 +16,10 @@ function CryptoType ({ accountId, className = '', label = '' }: Props): React.Re
const [type, setType] = useState('unknown');
useEffect((): void => {
try {
const current = accountId
? keyring.getPair(accountId.toString())
: null;
const result = getAccountCryptoType(accountId);
if (current) {
setType(
current.meta.isInjected
? 'injected'
: current.meta.isHardware
? current.meta.hardwareType as string || 'hardware'
: current.meta.isExternal
? current.meta.isMultisig
? 'multisig'
: current.meta.isProxied
? 'proxied'
: 'external'
: current.type
);
}
} catch (error) {
// cannot determine, keep unknown
if (result !== 'unknown') {
setType(result);
}
}, [accountId]);

View File

@ -9,6 +9,8 @@ import type { ActionStatus } from '@polkadot/react-components/Status/types';
import type { AccountId, Index } from '@polkadot/types/interfaces';
import type { TxCallback, TxFailedCallback } from './Status/types';
import { AccountIndex, Address } from '@polkadot/types/interfaces';
export interface BareProps {
children?: React.ReactNode;
className?: string;
@ -79,3 +81,5 @@ export interface ThemeProps {
}
export type FlagColor = 'blue' | 'green' | 'grey' | 'orange' | 'pink' | 'red' | 'yellow' | 'theme';
export type AccountIdIsh = AccountId | AccountIndex | Address | string | Uint8Array | null;

View File

@ -0,0 +1,32 @@
// Copyright 2017-2021 @polkadot/react-components authors & contributors
// SPDX-License-Identifier: Apache-2.0
import { keyring } from '@polkadot/ui-keyring';
import { AccountIdIsh } from '../types';
export default function getAccountCryptoType (accountId: AccountIdIsh): string {
try {
const current = accountId
? keyring.getPair(accountId.toString())
: null;
if (current) {
return current.meta.isInjected
? 'injected'
: current.meta.isHardware
? current.meta.hardwareType as string || 'hardware'
: current.meta.isExternal
? current.meta.isMultisig
? 'multisig'
: current.meta.isProxied
? 'proxied'
: 'external'
: current.type;
}
} catch {
// cannot determine, keep unknown
}
return 'unknown';
}

View File

@ -9,3 +9,4 @@ export { default as getContractAbi } from './getContractAbi';
export { default as isTreasuryProposalVote } from './isTreasuryProposalVote';
export { default as toAddress } from './toAddress';
export { default as toShortAddress } from './toShortAddress';
export { default as getAccountCryptoType } from './getAccountCryptoType';