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:
parent
8295f01e9e
commit
734c3c653a
@ -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",
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
@ -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;
|
||||
}
|
||||
`);
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -28,8 +28,8 @@ export interface Delegation {
|
||||
}
|
||||
|
||||
export interface SortedAccount {
|
||||
account: KeyringAddress;
|
||||
children: SortedAccount[];
|
||||
account?: KeyringAddress;
|
||||
address: string;
|
||||
delegation?: Delegation;
|
||||
isFavorite: boolean;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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([]);
|
||||
|
||||
|
||||
@ -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]);
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
32
packages/react-components/src/util/getAccountCryptoType.ts
Normal file
32
packages/react-components/src/util/getAccountCryptoType.ts
Normal 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';
|
||||
}
|
||||
@ -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';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user