Add summary bar for accounts view (#5876)

* 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>

Co-authored-by: Jaco <jacogr@gmail.com>
Co-authored-by: Krzysztof Jelski <kjelski@gmail.com>
This commit is contained in:
Vitaliy 2021-08-18 11:26:12 +02:00 committed by GitHub
parent 20620dbdde
commit c9aef5cfe9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 307 additions and 64 deletions

View File

@ -1,23 +1,23 @@
// Copyright 2017-2021 @polkadot/app-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type BN from 'bn.js';
import type { SubmittableExtrinsic } from '@polkadot/api/types';
import type { DeriveDemocracyLock } from '@polkadot/api-derive/types';
import type { DeriveDemocracyLock, DeriveStakingAccount } from '@polkadot/api-derive/types';
import type { Ledger } from '@polkadot/hw-ledger';
import type { ActionStatus } from '@polkadot/react-components/Status/types';
import type { ThemeDef } from '@polkadot/react-components/types';
import type { Option } from '@polkadot/types';
import type { ProxyDefinition, RecoveryConfig } from '@polkadot/types/interfaces';
import type { KeyringAddress, KeyringJson$Meta } from '@polkadot/ui-keyring/types';
import type { Delegation } from '../types';
import type { AccountBalance, Delegation } from '../types';
import BN from 'bn.js';
import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react';
import styled, { ThemeContext } from 'styled-components';
import { ApiPromise } from '@polkadot/api';
import { AddressInfo, AddressMini, AddressSmall, Badge, Button, ChainLock, CryptoType, Forget, Icon, IdentityIcon, LinkExternal, Menu, Popup, StatusContext, Tags } from '@polkadot/react-components';
import { useAccountInfo, useApi, useBalancesAll, useBestNumber, useCall, useLedger, useToggle } from '@polkadot/react-hooks';
import { useAccountInfo, useApi, useBalancesAll, useBestNumber, useCall, useLedger, useStakingInfo, useToggle } from '@polkadot/react-hooks';
import { keyring } from '@polkadot/ui-keyring';
import { BN_ZERO, formatBalance, formatNumber, isFunction } from '@polkadot/util';
@ -45,7 +45,7 @@ interface Props {
filter: string;
isFavorite: boolean;
proxy?: [ProxyDefinition[], BN];
setBalance: (address: string, value: BN) => void;
setBalance: (address: string, value: AccountBalance) => void;
toggleFavorite: (address: string) => void;
}
@ -66,6 +66,19 @@ function calcVisible (filter: string, name: string, tags: string[]): boolean {
}, name.toLowerCase().includes(_filter));
}
function calcUnbonding (stakingInfo?: DeriveStakingAccount) {
if (!stakingInfo?.unlocking) {
return BN_ZERO;
}
const filtered = stakingInfo.unlocking
.filter(({ remainingEras, value }) => value.gt(BN_ZERO) && remainingEras.gt(BN_ZERO))
.map((unlock) => unlock.value);
const total = filtered.reduce((total, value) => total.iadd(value), new BN(0));
return total;
}
function createClearDemocracyTx (api: ApiPromise, address: string, unlockableIds: BN[]): SubmittableExtrinsic<'promise'> | null {
return api.tx.utility
? api.tx.utility.batch(
@ -94,6 +107,7 @@ function Account ({ account: { address, meta }, className = '', delegation, filt
const { getLedger } = useLedger();
const bestNumber = useBestNumber();
const balancesAll = useBalancesAll(address);
const stakingInfo = useStakingInfo(address);
const democracyLocks = useCall<DeriveDemocracyLock[]>(api.api.derive.democracy?.locks, [address]);
const recoveryInfo = useCall<RecoveryConfig | null>(api.api.query.recovery?.recoverable, [address], transformRecovery);
const multiInfos = useMultisigApprovals(address);
@ -118,7 +132,14 @@ function Account ({ account: { address, meta }, className = '', delegation, filt
useEffect((): void => {
if (balancesAll) {
setBalance(address, balancesAll.freeBalance.add(balancesAll.reservedBalance));
setBalance(address, {
bonded: stakingInfo?.stakingLedger.active.unwrap() ?? BN_ZERO,
locked: balancesAll.lockedBalance,
redeemable: stakingInfo?.redeemable ?? BN_ZERO,
total: balancesAll.freeBalance.add(balancesAll.reservedBalance),
transferrable: balancesAll.availableBalance,
unbonding: calcUnbonding(stakingInfo)
});
api.api.tx.vesting?.vest && setVestingTx(() =>
balancesAll.vestingLocked.isZero()
@ -126,7 +147,7 @@ function Account ({ account: { address, meta }, className = '', delegation, filt
: api.api.tx.vesting.vest()
);
}
}, [address, api, balancesAll, setBalance]);
}, [address, api, balancesAll, setBalance, stakingInfo]);
useEffect((): void => {
bestNumber && democracyLocks && setUnlockableIds(

View File

@ -0,0 +1,53 @@
// Copyright 2017-2021 @polkadot/app-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { AccountBalance } from '../types';
import React from 'react';
import { useTranslation } from '@polkadot/app-treasury/translate';
import { CardSummary, SummaryBox } from '@polkadot/react-components';
import { FormatBalance } from '@polkadot/react-query';
interface Props {
className?: string;
balance?: AccountBalance;
}
function Summary ({ balance, className }: Props) {
const { t } = useTranslation();
return (
<SummaryBox className={className}>
{balance && (
<>
{balance.total.gtn(0) &&
<CardSummary label={t<string>('total balance')}>
<FormatBalance value={balance.total} />
</CardSummary>}
{balance.transferrable.gtn(0) &&
<CardSummary label={t<string>('total transferrable')}>
<FormatBalance value={balance.transferrable} />
</CardSummary>}
{balance.locked.gtn(0) &&
<CardSummary label={t<string>('total locked')}>
<FormatBalance value={balance.locked} />
</CardSummary>}
{balance.bonded.gtn(0) &&
<CardSummary label={t<string>('bonded')}>
<FormatBalance value={balance.bonded} />
</CardSummary>}
{balance.redeemable.gtn(0) &&
<CardSummary label={t<string>('redeemable')}>
<FormatBalance value={balance.redeemable} />
</CardSummary>}
{balance.unbonding.gtn(0) &&
<CardSummary label={t<string>('unbonding')}>
<FormatBalance value={balance.unbonding} />
</CardSummary>}
</>)}
</SummaryBox>
);
}
export default React.memo(Summary);

View File

@ -2,11 +2,14 @@
// SPDX-License-Identifier: Apache-2.0
import type { Balance } from '@polkadot/types/interfaces';
import type { AccountOverrides } from '../../test/hooks/default';
import { within } from '@testing-library/react';
import { screen, within } from '@testing-library/react';
import BN from 'bn.js';
import i18next from '@polkadot/react-components/i18n';
import { balanceOf } from '@polkadot/test-support/creation/balance';
import { makeStakingLedger as ledger } from '@polkadot/test-support/creation/stakingInfo/stakingLedger';
import { MemoryStore } from '@polkadot/test-support/keyring';
import { keyring } from '@polkadot/ui-keyring';
@ -25,49 +28,50 @@ describe('Accounts page', () => {
});
describe('when no accounts', () => {
it('shows a table', async () => {
beforeEach(() => {
accountsPage.renderPage([]);
});
it('shows a table', async () => {
const accountsTable = await accountsPage.findAccountsTable();
expect(accountsTable).not.toBeNull();
});
it('the accounts table contains no account rows', async () => {
accountsPage.renderPage([]);
const accountRows = await accountsPage.findAccountRows();
expect(accountRows).toHaveLength(0);
});
it('the accounts table contains a message about no accounts available', async () => {
accountsPage.renderPage([]);
const accountsTable = await accountsPage.findAccountsTable();
const noAccountsMessage = await within(accountsTable).findByText(
'You don\'t have any accounts. Some features are currently hidden and will only become available once you have accounts.');
expect(noAccountsMessage).not.toBeNull();
});
it('no summary is displayed', () => {
const summaries = screen.queryAllByTestId(/card-summary:total \w+/i);
expect(summaries).toHaveLength(0);
});
});
describe('when some accounts exist', () => {
it('the accounts table contains some account rows', async () => {
accountsPage.renderPage([
defaultRender(
{
address: '5DAAnrj7VHTznn2AWBemMuyBwZWs6FNFjdyVXUeYum3PTXFy',
balance: {
freeBalance: balance(10000)
}
},
{
address: '5HGjWAeFDfFCWPsjFQdVV2Msvz2XtMktvgocEZcCj68kUMaw',
balance: {
freeBalance: balance(999)
}
}
]);
});
const accountRows = await accountsPage.findAccountRows();
@ -75,21 +79,18 @@ describe('Accounts page', () => {
});
it('account rows display the total balance info', async () => {
accountsPage.renderPage([
defaultRender(
{
address: '5DAAnrj7VHTznn2AWBemMuyBwZWs6FNFjdyVXUeYum3PTXFy',
balance: {
freeBalance: balance(500)
}
},
{
address: '5HGjWAeFDfFCWPsjFQdVV2Msvz2XtMktvgocEZcCj68kUMaw',
balance: {
freeBalance: balance(200),
reservedBalance: balance(150)
}
}
]);
});
const rows = await accountsPage.findAccountRows();
const row1TotalActual = await within(rows[0]).findByTestId('balance-summary');
@ -102,6 +103,130 @@ describe('Accounts page', () => {
expect(row2TotalActual).toHaveTextContent(row2TotalExpected);
});
it('displays some summary', () => {
defaultRender(
{
balance: {
freeBalance: balance(500)
}
},
{
balance: {
freeBalance: balance(200),
reservedBalance: balance(150)
}
});
const summaries = screen.queryAllByTestId(/card-summary:total \w+/i);
expect(summaries).not.toHaveLength(0);
});
it('displays balance summary', async () => {
defaultRender(
{
balance: {
freeBalance: balance(500)
}
},
{
balance: {
freeBalance: balance(200),
reservedBalance: balance(150)
}
});
const summary = await screen.findByTestId(/card-summary:(total )?balance/i);
expect(summary).toHaveTextContent(showBalance(500 + 200 + 150));
});
it('displays transferrable summary', async () => {
defaultRender({ balance: { availableBalance: balance(400) } }, { balance: { availableBalance: balance(600) } });
const summary = await screen.findByTestId(/card-summary:(total )?transferrable/i);
expect(summary).toHaveTextContent(showBalance(400 + 600));
});
it('displays locked summary', async () => {
defaultRender({ balance: { lockedBalance: balance(400) } }, { balance: { lockedBalance: balance(600) } });
const summary = await screen.findByTestId(/card-summary:(total )?locked/i);
expect(summary).toHaveTextContent(showBalance(400 + 600));
});
it('displays bonded summary', async () => {
defaultRender(
{
staking: {
stakingLedger: ledger(balance(70))
}
},
{
staking: {
stakingLedger: ledger(balance(20))
}
});
const summary = await screen.findByTestId(/card-summary:(total )?bonded/i);
expect(summary).toHaveTextContent(showBalance(70 + 20));
});
it('displays unbonding summary', async () => {
defaultRender(
{
staking: {
unlocking: [
{
remainingEras: new BN('1000000000'),
value: balance(200)
},
{
remainingEras: new BN('2000000000'),
value: balance(300)
},
{
remainingEras: new BN('3000000000'),
value: balance(400)
}
]
}
},
{
staking: {
unlocking: [
{
remainingEras: new BN('1000000000'),
value: balance(100)
},
{
remainingEras: new BN('2000000000'),
value: balance(200)
},
{
remainingEras: new BN('3000000000'),
value: balance(300)
}
]
}
});
const summary = await screen.findByTestId(/card-summary:(total )?unbonding/i);
expect(summary).toHaveTextContent(showBalance(200 + 300 + 400 + 100 + 200 + 300));
});
it('displays redeemable summary', async () => {
defaultRender({ staking: { redeemable: balance(4000) } }, { staking: { redeemable: balance(5000) } });
const summary = await screen.findByTestId(/card-summary:(total )?redeemable/i);
expect(summary).toHaveTextContent(showBalance(4000 + 5000));
});
});
/**
@ -115,4 +240,23 @@ describe('Accounts page', () => {
return balanceOf(amountInt.toString() + decimalsPadded);
};
const showBalance = function (amount: number): string {
return accountsPage.format(balance(amount));
};
const defaultAddresses = [
'5DAAnrj7VHTznn2AWBemMuyBwZWs6FNFjdyVXUeYum3PTXFy',
'5HGjWAeFDfFCWPsjFQdVV2Msvz2XtMktvgocEZcCj68kUMaw'
];
const defaultRender = function (...overrides: AccountOverrides[]): void {
const accounts: [string, AccountOverrides][] = [];
for (let i = 0; i < overrides.length; i++) {
accounts.push([defaultAddresses[i], overrides[i]]);
}
accountsPage.renderPage(accounts);
};
});

View File

@ -4,14 +4,13 @@
import type BN from 'bn.js';
import type { ActionStatus } from '@polkadot/react-components/Status/types';
import type { AccountId, ProxyDefinition, ProxyType, Voting } from '@polkadot/types/interfaces';
import type { Delegation, SortedAccount } from '../types';
import type { AccountBalance, Delegation, SortedAccount } from '../types';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import styled from 'styled-components';
import { Button, Input, Table } from '@polkadot/react-components';
import { useAccounts, useApi, useCall, useFavorites, useIpfs, useLedger, useLoadingDelay, useToggle } from '@polkadot/react-hooks';
import { FormatBalance } from '@polkadot/react-query';
import { BN_ZERO } from '@polkadot/util';
import CreateModal from '../modals/Create';
@ -25,10 +24,11 @@ import { sortAccounts } from '../util';
import Account from './Account';
import BannerClaims from './BannerClaims';
import BannerExtension from './BannerExtension';
import Summary from './Summary';
interface Balances {
accounts: Record<string, BN>;
balanceTotal?: BN;
accounts: Record<string, AccountBalance>;
summary?: AccountBalance;
}
interface Sorted {
@ -56,7 +56,7 @@ function Overview ({ className = '', onStatusChange }: Props): React.ReactElemen
const [isProxyOpen, toggleProxy] = useToggle();
const [isQrOpen, toggleQr] = useToggle();
const [favorites, toggleFavorite] = useFavorites(STORE_FAVS);
const [{ balanceTotal }, setBalances] = useState<Balances>({ accounts: {} });
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: [] });
@ -113,32 +113,28 @@ function Overview ({ className = '', onStatusChange }: Props): React.ReactElemen
}, [api, delegations, sortedAccounts]);
const _setBalance = useCallback(
(account: string, balance: BN) =>
(account: string, balance: AccountBalance) =>
setBalances(({ accounts }: Balances): Balances => {
accounts[account] = balance;
const aggregate = (key: keyof AccountBalance) =>
Object.values(accounts).reduce((total: BN, value: AccountBalance) => total.add(value[key]), BN_ZERO);
return {
accounts,
balanceTotal: Object.values(accounts).reduce((total: BN, value: BN) => total.add(value), BN_ZERO)
summary: {
bonded: aggregate('bonded'),
locked: aggregate('locked'),
redeemable: aggregate('redeemable'),
total: aggregate('total'),
transferrable: aggregate('transferrable'),
unbonding: aggregate('unbonding')
}
};
}),
[]
);
const footer = useMemo(() => (
<tr>
<td colSpan={3} />
<td className='media--1400' />
<td colSpan={2} />
<td className='media--1500' />
<td className='number'>
{balanceTotal && <FormatBalance value={balanceTotal} />}
</td>
<td />
<td className='media--1400' />
</tr>
), [balanceTotal]);
const filter = useMemo(() => (
<div className='filter--tags'>
<Input
@ -228,10 +224,10 @@ function Overview ({ className = '', onStatusChange }: Props): React.ReactElemen
</Button.Group>
<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}
footer={footer}
header={headerRef.current}
>
{!isLoading && sortedAccountsWithDelegation?.map(({ account, delegation, isFavorite }, index): React.ReactNode => (

View File

@ -1,6 +1,7 @@
// Copyright 2017-2021 @polkadot/app-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type BN from 'bn.js';
import type { ActionStatus } from '@polkadot/react-components/Status/types';
import type { Balance, Conviction } from '@polkadot/types/interfaces';
import type { KeyringAddress } from '@polkadot/ui-keyring/types';
@ -33,6 +34,15 @@ export interface SortedAccount {
isFavorite: boolean;
}
export interface AccountBalance {
total: BN;
locked: BN;
transferrable: BN;
bonded: BN;
redeemable: BN;
unbonding: BN;
}
export type PairType = 'ecdsa' | 'ed25519' | 'ed25519-ledger' | 'ethereum' | 'sr25519';
export interface CreateProps extends ModalProps {

View File

@ -7,6 +7,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';
export interface Account {
balance: DeriveBalancesAll,
@ -19,8 +20,6 @@ export type AccountsMap = { [address: string]: Account };
* Test inputs structure
*/
export interface AccountOverrides {
address: string;
balance?: {
[P in keyof DeriveBalancesAll]?: DeriveBalancesAll[P];
};
@ -58,12 +57,7 @@ export const defaultStakingAccount: DeriveStakingAccount = {
nominators: [],
redeemable: balanceOf(0),
sessionIds: [],
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
stakingLedger: {
active: {
unwrap: () => new BN(0)
}
} as any,
stakingLedger: makeStakingLedger(0),
unlocking: [
{
remainingEras: new BN('1000000000'),
@ -86,16 +80,16 @@ class MockAccountHooks {
public nonce: BN = new BN(1);
public setAccounts (accounts: AccountOverrides[]): void {
public setAccounts (accounts: [string, AccountOverrides][]): void {
this.useAccounts = {
allAccounts: accounts.map((account) => account.address),
allAccounts: accounts.map(([address]) => address),
allAccountsHex: [],
areAccountsLoaded: true,
hasAccounts: accounts && accounts.length !== 0,
isAccount: () => true
};
for (const account of accounts) {
for (const [address, props] of accounts) {
const staking = { ...defaultStakingAccount };
const balance = { ...defaultBalanceAccount };
@ -103,11 +97,11 @@ class MockAccountHooks {
// so we have to use "any" here.
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
Object.entries(account.balance || balance).forEach(function ([key, value]) { (balance as any)[key] = value; });
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
Object.entries(account.staking || staking).forEach(function ([key, value]) { (staking as any)[key] = value; });
Object.entries(props.staking || staking).forEach(function ([key, value]) { (staking as any)[key] = value; });
this.accountsMap[account.address] = {
this.accountsMap[address] = {
balance: balance,
staking: staking
};

View File

@ -50,7 +50,7 @@ jest.mock('@polkadot/react-hooks/useBestNumber', () => ({
export class AccountsPage {
private renderResult?: RenderResult
renderPage (accounts: AccountOverrides[]): void {
renderPage (accounts: [string, AccountOverrides][]): void {
mockAccountHooks.setAccounts(accounts);
const mockApi: ApiProps = {
api: {
@ -121,8 +121,8 @@ export class AccountsPage {
return rows.filter((r) => r.className.startsWith('Account-'));
}
format (amount: Balance): string {
return formatBalance(amount, { decimals: 12, withUnit: true });
format (amount: Balance | BN): string {
return formatBalance(amount, { decimals: 12, forceUnit: '-', withUnit: true });
}
private assertRendered () {

View File

@ -54,9 +54,11 @@ function CardSummary ({ children, className = '', help, label, progress }: Props
}
const isTimed = progress && progress.withTime && !isUndefined(progress.total);
const testidSuffix = (label ?? '').toString();
return (
<article className={className}>
<article className={className}
data-testid={`card-summary:${testidSuffix}`}>
<Labelled
help={help}
isSmall

View File

@ -42,6 +42,7 @@ export { useIsParasLinked, useParaEndpoints } from './useParaEndpoints';
export { usePassword } from './usePassword';
export { useRegistrars } from './useRegistrars';
export { useSavedFlags } from './useSavedFlags';
export { useStakingInfo } from './useStakingInfo';
export { useStepper } from './useStepper';
export { useSudo } from './useSudo';
export { useTeleport } from './useTeleport';

View File

@ -3,7 +3,8 @@
import type { DeriveStakingAccount } from '@polkadot/api-derive/types';
import { useApi, useCall } from '@polkadot/react-hooks';
import { useApi } from './useApi';
import { useCall } from './useCall';
export function useStakingInfo (accountId: string | null): DeriveStakingAccount | undefined {
const { api } = useApi();

View File

@ -0,0 +1,21 @@
// Copyright 2017-2021 @polkadot/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { StakingLedger } from '@polkadot/types/interfaces';
import BN from 'bn.js';
import { TypeRegistry } from '@polkadot/types/create';
export function makeStakingLedger (active: BN | number | string): StakingLedger {
const reg = new TypeRegistry();
// Constructing the whole StakingLedger structure is hard,
// so we fill out just the fields that are definitely required,
// and hope that nothing more is required.
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
return {
active: reg.createType('Compact<Balance>', reg.createType('Balance', new BN(active)))
} as StakingLedger;
}