diff --git a/packages/apps/public/locales/en/app-accounts.json b/packages/apps/public/locales/en/app-accounts.json index 9d7470f305..5dc3e36875 100644 --- a/packages/apps/public/locales/en/app-accounts.json +++ b/packages/apps/public/locales/en/app-accounts.json @@ -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", diff --git a/packages/page-accounts/src/Accounts/index.spec.ts b/packages/page-accounts/src/Accounts/index.spec.ts index 29742ba067..82326fa1c1 100644 --- a/packages/page-accounts/src/Accounts/index.spec.ts +++ b/packages/page-accounts/src/Accounts/index.spec.ts @@ -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) => ({ balance }); - const anAccountWithInfo = (info: { [P in keyof UseAccountInfo]?: UseAccountInfo[P] }) => ({ + const anAccountWithBalanceAndMeta = (balance: Override, meta: Override) => ({ + balance, + meta + }); + + const anAccountWithInfo = (info: Override) => ({ info }); - const anAccountWithStaking = (staking: { [P in keyof DeriveStakingAccount]?: DeriveStakingAccount[P]}) => ({ + const anAccountWithMeta = (meta: Override) => ({ + meta + }); + + const anAccountWithStaking = (staking: Override) => ({ 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); +}; diff --git a/packages/page-accounts/src/Accounts/index.tsx b/packages/page-accounts/src/Accounts/index.tsx index 073f199dcf..f0f6c71122 100644 --- a/packages/page-accounts/src/Accounts/index.tsx +++ b/packages/page-accounts/src/Accounts/index.tsx @@ -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 { @@ -58,10 +61,10 @@ function Overview ({ className = '', onStatusChange }: Props): React.ReactElemen const [favorites, toggleFavorite] = useFavorites(STORE_FAVS); const [balances, setBalances] = useState({ accounts: {} }); const [filterOn, setFilter] = useState(''); - const [sortedAccountsWithDelegation, setSortedAccountsWithDelegation] = useState(); - const [{ sortedAccounts, sortedAddresses }, setSorted] = useState({ sortedAccounts: [], sortedAddresses: [] }); - const delegations = useCall(api.query.democracy?.votingOf?.multi, [sortedAddresses]); - const proxies = useCall<[ProxyDefinition[], BN][]>(api.query.proxy?.proxies.multi, [sortedAddresses], { + const [sortedAccounts, setSorted] = useState([]); + const [{ sortBy, sortFromMax }, setSortBy] = useState(DEFAULT_SORT_CONTROLS); + const delegations = useCall(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 = {}; + + 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 ), [filterOn, t]); + const accountComponents = useMemo(() => { + const ret: Record = {}; + + accountsWithInfo.forEach(({ account, address, delegation, isFavorite }, index) => { + ret[address] = + ; + }); + + 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 (
{isCreateOpen && ( @@ -224,24 +268,37 @@ function Overview ({ className = '', onStatusChange }: Props): React.ReactElemen - ("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 => ( - +
+ ('sort by')} + onChange={onDropdownChange()} + options={dropdownOptions()} /> - ))} +
+ +
("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) }); + }) + }
); @@ -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; + } `); diff --git a/packages/page-accounts/src/modals/Create.spec.tsx b/packages/page-accounts/src/modals/Create.spec.tsx index 69580fec7d..fc380d08ed 100644 --- a/packages/page-accounts/src/modals/Create.spec.tsx +++ b/packages/page-accounts/src/modals/Create.spec.tsx @@ -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(); diff --git a/packages/page-accounts/src/types.ts b/packages/page-accounts/src/types.ts index 31379dded7..208b18c31d 100644 --- a/packages/page-accounts/src/types.ts +++ b/packages/page-accounts/src/types.ts @@ -28,8 +28,8 @@ export interface Delegation { } export interface SortedAccount { - account: KeyringAddress; - children: SortedAccount[]; + account?: KeyringAddress; + address: string; delegation?: Delegation; isFavorite: boolean; } diff --git a/packages/page-accounts/src/util.tsx b/packages/page-accounts/src/util.tsx index 0e2876eac8..90ebcfb221 100644 --- a/packages/page-accounts/src/util.tsx +++ b/packages/page-accounts/src/util.tsx @@ -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, balances: Record, 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 (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 '...' + 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, balances: Record, 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); } diff --git a/packages/page-accounts/test/hooks/default.ts b/packages/page-accounts/test/hooks/default.ts index 28c81abd33..fcf2cac448 100644 --- a/packages/page-accounts/test/hooks/default.ts +++ b/packages/page-accounts/test/hooks/default.ts @@ -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 = { + [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; + balance?: Override; + staking?: Override; + info?: Override; } 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 diff --git a/packages/page-accounts/test/pages/accountsPage.tsx b/packages/page-accounts/test/pages/accountsPage.tsx index 66d397bf7c..ae1dfbac80 100644 --- a/packages/page-accounts/test/pages/accountsPage.tsx +++ b/packages/page-accounts/test/pages/accountsPage.tsx @@ -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 { + this.assertRendered(); + + return await screen.findByTestId('sort-by-section'); + } + + async findSortByDropdownCurrent (): Promise { + 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 { + 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 { + this.assertRendered(); + + const section = await this.findSortBySection(); + + return await within(section).findByRole('button'); + } + + async checkOrder (order: number[]): Promise { + 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 { + const current = await this.findSortByDropdownCurrent(); + + fireEvent.click(current); + + const target = await this.findSortByDropdownItem(orderBy); + + fireEvent.click(target); + } + + async checkRowsColoring (): Promise { + 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 { + await this.checkOrder(order); + await this.checkRowsColoring(); + } + + format (amount: Balance | BN): string { + return formatBalance(amount, { decimals: 12, forceUnit: '-', withUnit: true }); + } + async enterCreateAccountModal (): Promise { this.renderPage([]); diff --git a/packages/react-components/src/CryptoType.tsx b/packages/react-components/src/CryptoType.tsx index bdc656016c..510a70e400 100644 --- a/packages/react-components/src/CryptoType.tsx +++ b/packages/react-components/src/CryptoType.tsx @@ -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]); diff --git a/packages/react-components/src/types.ts b/packages/react-components/src/types.ts index 26ad81c9fe..f76cac43c0 100644 --- a/packages/react-components/src/types.ts +++ b/packages/react-components/src/types.ts @@ -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; diff --git a/packages/react-components/src/util/getAccountCryptoType.ts b/packages/react-components/src/util/getAccountCryptoType.ts new file mode 100644 index 0000000000..7654973cca --- /dev/null +++ b/packages/react-components/src/util/getAccountCryptoType.ts @@ -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'; +} diff --git a/packages/react-components/src/util/index.ts b/packages/react-components/src/util/index.ts index 5eb7a7421e..ba1639dab2 100644 --- a/packages/react-components/src/util/index.ts +++ b/packages/react-components/src/util/index.ts @@ -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';