diff --git a/packages/page-accounts/src/Accounts/Account.tsx b/packages/page-accounts/src/Accounts/Account.tsx index 6294274a89..a962c44b16 100644 --- a/packages/page-accounts/src/Accounts/Account.tsx +++ b/packages/page-accounts/src/Accounts/Account.tsx @@ -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(api.api.derive.democracy?.locks, [address]); const recoveryInfo = useCall(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( diff --git a/packages/page-accounts/src/Accounts/Summary.tsx b/packages/page-accounts/src/Accounts/Summary.tsx new file mode 100644 index 0000000000..39e3459109 --- /dev/null +++ b/packages/page-accounts/src/Accounts/Summary.tsx @@ -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 ( + + {balance && ( + <> + {balance.total.gtn(0) && + ('total balance')}> + + } + {balance.transferrable.gtn(0) && + ('total transferrable')}> + + } + {balance.locked.gtn(0) && + ('total locked')}> + + } + {balance.bonded.gtn(0) && + ('bonded')}> + + } + {balance.redeemable.gtn(0) && + ('redeemable')}> + + } + {balance.unbonding.gtn(0) && + ('unbonding')}> + + } + )} + + ); +} + +export default React.memo(Summary); diff --git a/packages/page-accounts/src/Accounts/index.spec.ts b/packages/page-accounts/src/Accounts/index.spec.ts index 1f089b956f..44d67f58a4 100644 --- a/packages/page-accounts/src/Accounts/index.spec.ts +++ b/packages/page-accounts/src/Accounts/index.spec.ts @@ -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); + }; }); diff --git a/packages/page-accounts/src/Accounts/index.tsx b/packages/page-accounts/src/Accounts/index.tsx index cf454cb743..2cf9dbf423 100644 --- a/packages/page-accounts/src/Accounts/index.tsx +++ b/packages/page-accounts/src/Accounts/index.tsx @@ -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; - balanceTotal?: BN; + accounts: Record; + 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({ accounts: {} }); + const [balances, setBalances] = useState({ accounts: {} }); const [filterOn, setFilter] = useState(''); const [sortedAccountsWithDelegation, setSortedAccountsWithDelegation] = useState(); const [{ sortedAccounts, sortedAddresses }, setSorted] = useState({ 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(() => ( - - - - - - - {balanceTotal && } - - - - - ), [balanceTotal]); - const filter = useMemo(() => (
+ ("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 => ( diff --git a/packages/page-accounts/src/types.ts b/packages/page-accounts/src/types.ts index 1fd0283050..31379dded7 100644 --- a/packages/page-accounts/src/types.ts +++ b/packages/page-accounts/src/types.ts @@ -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 { diff --git a/packages/page-accounts/test/hooks/default.ts b/packages/page-accounts/test/hooks/default.ts index 676f0b42a3..6b7b1e1f71 100644 --- a/packages/page-accounts/test/hooks/default.ts +++ b/packages/page-accounts/test/hooks/default.ts @@ -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 }; diff --git a/packages/page-accounts/test/pages/accountsPage.tsx b/packages/page-accounts/test/pages/accountsPage.tsx index 1ed85abc51..294ad67777 100644 --- a/packages/page-accounts/test/pages/accountsPage.tsx +++ b/packages/page-accounts/test/pages/accountsPage.tsx @@ -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 () { diff --git a/packages/react-components/src/CardSummary.tsx b/packages/react-components/src/CardSummary.tsx index fe4c8ab37d..b7f55dcf94 100644 --- a/packages/react-components/src/CardSummary.tsx +++ b/packages/react-components/src/CardSummary.tsx @@ -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 ( -
+
', reg.createType('Balance', new BN(active))) + } as StakingLedger; +}