Feat/45 - Validator list (#833)

* frontend-monorepo-45: Removed party data from validators table

* frontend-monorepo-45: Removed redundant query fields

* frontend-monorepo-45: Validator AG Grid working and fields formatted

* frontend-monorepo-45: Removed old node list tests (as now handled by AG Grid)

* Frontend-monorepo-45: Removed unwanted auto-added imports

* Frontend-monorepo-45: Fixing cypress tests

* Frontend-monorepo-45: Removed unnecessary class added as part of layout test

* Frontend-monorepo-45: Removed unnecessary refactor added as part of layout test

* Frontend-monorepo-45: Removed unnecessary grid export added as part of grid setup

* frontend-monorepo-45: Removed unwanted font-size and adjusted avatar size in proportion

* frontend-monorepo-45: Expanded node-list tests

* frontend-monorepo-45: Used toString on translations to make node list coldefs a bit clearer

* frontend-monorepo-45: Converted nodeFactory to use lodash merge and PartialDeep

* frontend-monorepo-45: Refactored so node list AG grid field names are decoupled from translations

* frontend-monorepo-45: Added refreshing when epoch changes

* frontend-monorepo-45: Used epoch data already passed to node list instead of a new query

* frontend-monorepo-45: Node list - used formatNumber from libs/react-helpers

* frontend-monorepo-45: formatNumber now accepts BigNumbers
This commit is contained in:
Sam Keen 2022-07-26 20:26:12 +01:00 committed by GitHub
parent 8186bc3948
commit 6f374264c0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 332 additions and 348 deletions

View File

@ -1,5 +1,5 @@
const guideLink = '[data-testid="staking-guide-link"]';
const validators = '[data-testid="node-list-item"]';
const validators = '[data-testid="validators-grid"]';
context('Staking Page - verify elements on page', function () {
before('navigate to staking page', function () {

View File

@ -548,6 +548,16 @@
"fail": "Fail",
"rankingScore": "Ranking score",
"stakeScore": "Stake score",
"performanceScore": "Performance score",
"votingPower": "Voting score"
"performanceScore": "Performance",
"votingPower": "Voting power",
"noValidators": "No validators",
"validator": "Validator",
"totalStakeThisEpoch": "Total stake this epoch",
"share": "Share",
"validatorStake": "Validator stake",
"Nodes": "Nodes",
"status-tendermint": "Consensus",
"status-ersatz": "Ersatz",
"status-pending": "Pending",
"status-unspecified": "Unspecified"
}

View File

@ -3,28 +3,10 @@
// @generated
// This file was automatically generated and should not be edited.
import { NodeStatus } from "@vegaprotocol/types";
// ====================================================
// GraphQL query operation: Nodes
// ====================================================
export interface Nodes_nodes_epochData {
__typename: "EpochData";
/**
* Total number of epochs since node was created
*/
total: number;
/**
* Total number of offline epochs since node was created
*/
offline: number;
/**
* Total number of online epochs since node was created
*/
online: number;
}
export interface Nodes_nodes_rankingScore {
__typename: "RankingScore";
/**
@ -43,10 +25,15 @@ export interface Nodes_nodes_rankingScore {
* The tendermint voting power of the validator (uint32)
*/
votingPower: string;
/**
* The current validation status of the validator
*/
status: string;
}
export interface Nodes_nodes {
__typename: "Node";
avatarUrl: string | null;
/**
* The node url eg n01.vega.xyz
*/
@ -56,36 +43,15 @@ export interface Nodes_nodes {
* Pubkey of the node operator
*/
pubkey: string;
/**
* URL where I can find out more info on the node. Will this be possible?
*/
infoUrl: string;
/**
* Country code for the location of the node
*/
location: string;
/**
* The amount the node has put up themselves
*/
stakedByOperator: string;
/**
* The amount of stake that has been delegated by token holders
*/
stakedByDelegates: string;
/**
* Total amount staked on node
*/
stakedTotal: string;
stakedTotalFormatted: string;
/**
* Amount of stake on the next epoch
*/
pendingStake: string;
stakedByOperatorFormatted: string;
stakedByDelegatesFormatted: string;
stakedTotalFormatted: string;
pendingStakeFormatted: string;
epochData: Nodes_nodes_epochData | null;
status: NodeStatus;
/**
* Ranking scores and status for the validator for the current epoch
*/
@ -99,22 +65,6 @@ export interface Nodes_nodeData {
*/
stakedTotal: string;
stakedTotalFormatted: string;
/**
* Total number of nodes
*/
totalNodes: number;
/**
* Number of inactive nodes
*/
inactiveNodes: number;
/**
* Number of nodes validating
*/
validatingNodes: number;
/**
* Total uptime for all epochs across all nodes. Or specify a number of epochs
*/
uptime: number;
}
export interface Nodes {

View File

@ -1,37 +1,30 @@
import { render, screen, waitFor, within } from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import merge from 'lodash/merge';
import { NodeList, NODES_QUERY } from './node-list';
import { MockedProvider } from '@apollo/client/testing';
import { MemoryRouter } from 'react-router-dom';
import { addDecimal } from '@vegaprotocol/react-helpers';
import type { Nodes_nodes } from './__generated__/Nodes';
import type { PartialDeep } from 'type-fest';
jest.mock('../../components/epoch-countdown', () => ({
EpochCountdown: () => <div data-testid="epoch-info"></div>,
}));
const nodeFactory = (overrides?: Partial<Nodes_nodes>) => ({
const nodeFactory = (overrides?: PartialDeep<Nodes_nodes>) => {
const defaultNode = {
id: 'ccc022b7e63a4d0a6d3a193c3940c88574060e58a184964c994998d86835a1b4',
name: 'Skynet',
avatarUrl: 'https://upload.wikimedia.org/wikipedia/en/2/25/Marvin-TV-3.jpg',
pubkey: '6abc23391a9f888ab240415bf63d6844b03fc360be822f4a1d2cd832d87b2917',
infoUrl: 'https://en.wikipedia.org/wiki/Skynet_(Terminator)',
location: '',
stakedByOperator: '3000000000000000000000',
stakedByDelegates: '11182454495731682635157',
stakedTotal: '14182454495731682635157',
stakedTotalFormatted: addDecimal('14182454495731682635157', 18),
pendingStake: '0',
stakedByOperatorFormatted: addDecimal(
overrides?.stakedByOperator || '3000000000000000000000',
18
),
stakedByDelegatesFormatted: addDecimal(
overrides?.stakedByDelegates || '11182454495731682635157',
18
),
stakedTotalFormatted: addDecimal(
overrides?.stakedTotal || '14182454495731682635157',
18
),
pendingStakeFormatted: addDecimal(overrides?.pendingStake || '0', 18),
pendingStakeFormatted: addDecimal('0', 18),
epochData: null,
status: 'Validator',
rankingScore: {
@ -39,11 +32,13 @@ const nodeFactory = (overrides?: Partial<Nodes_nodes>) => ({
stakeScore: '0.3392701644525644',
performanceScore: '0.9998677767864936',
votingPower: '2407',
status: 'tendermint',
__typename: 'RankingScore',
},
__typename: 'Node',
...overrides,
});
};
return merge(defaultNode, overrides);
};
const MOCK_NODES = {
nodes: [
@ -53,15 +48,14 @@ const MOCK_NODES = {
name: 'T-800 Terminator',
pubkey:
'ccc3b8362c25b09d20df8ea407b0a476d6b24a0e72bc063d0033c8841652ddd4',
infoUrl: 'https://en.wikipedia.org/wiki/Terminator_(character)',
stakedByOperator: '3000000000000000000000',
stakedByDelegates: '6618711883996159534058',
stakedTotal: '9618711883996159534058',
stakedTotalFormatted: addDecimal('9618711883996159534058', 18),
rankingScore: {
rankingScore: '0.4601942440481428',
stakeScore: '0.2300971220240714',
performanceScore: '1',
votingPower: '2408',
status: 'tendermint',
__typename: 'RankingScore',
},
}),
@ -70,16 +64,15 @@ const MOCK_NODES = {
name: 'NCC-1701-E',
pubkey:
'0931a8fd8cc935458f470e435a05414387cea6f329d648be894fcd44bd517a2b',
infoUrl: 'https://en.wikipedia.org/wiki/USS_Enterprise_(NCC-1701-E)',
stakedByOperator: '3000000000000000000000',
stakedByDelegates: '1041343338923442976709',
stakedTotal: '4041343338923442976709',
stakedTotalFormatted: addDecimal('4041343338923442976709', 18),
pendingStake: '0',
rankingScore: {
rankingScore: '0.1932810100133910357676209647912',
stakeScore: '0.0966762995515676',
performanceScore: '0.999629748500531',
votingPower: '1163',
status: 'tendermint',
__typename: 'RankingScore',
},
}),
@ -95,14 +88,14 @@ const MOCK_NODES = {
},
};
const renderNodeList = () => {
const renderNodeList = (data = MOCK_NODES) => {
return render(
<MemoryRouter>
<MockedProvider
mocks={[
{
request: { query: NODES_QUERY },
result: { data: MOCK_NODES },
result: { data },
},
]}
>
@ -117,27 +110,6 @@ const renderNodeList = () => {
expiry: new Date(1000 * 60 * 60 * 24).toISOString(),
},
}}
party={{
__typename: 'Party',
id: 'foo',
delegations: [
{
__typename: 'Delegation',
amount: '0',
amountFormatted: '0',
epoch: 1,
node: {
__typename: 'Node',
id: 'bar',
},
},
],
stake: {
__typename: 'PartyStake',
currentStakeAvailable: '0',
currentStakeAvailableFormatted: '0',
},
}}
/>
</MockedProvider>
</MemoryRouter>
@ -162,43 +134,101 @@ describe('Nodes list', () => {
expect(screen.getByTestId('epoch-info')).toBeInTheDocument();
});
it('should a lit of all nodes', async () => {
it('should render a list of all nodes', async () => {
renderNodeList();
await waitFor(() => {
expect(screen.getByText(MOCK_NODES.nodes[0].name)).toBeInTheDocument();
expect(screen.getByText(MOCK_NODES.nodes[1].name)).toBeInTheDocument();
expect(screen.getByText(MOCK_NODES.nodes[2].name)).toBeInTheDocument();
});
const items = screen.queryAllByTestId('node-list-item');
expect(items).toHaveLength(3);
for (const item of MOCK_NODES.nodes) {
expect(screen.getByText(item.name)).toBeInTheDocument();
}
});
it('should list the total stake and rewards of each node', async () => {
renderNodeList();
it('should display the correctly formatted fields in the correct columns', async () => {
const MOCK_NODE = {
nodes: [
nodeFactory({
id: '966438c6bffac737cfb08173ffcb3f393c4692b099ad80cb45a82e2dc0a8cf99',
name: 'T-800 Terminator',
avatarUrl:
'https://upload.wikimedia.org/wikipedia/en/9/94/T-800_%28Model_101%29.png',
pubkey:
'ccc3b8362c25b09d20df8ea407b0a476d6b24a0e72bc063d0033c8841652ddd4',
stakedTotal: '9618711883996159534058',
stakedTotalFormatted: addDecimal('9618711883996159534058', 18),
rankingScore: {
rankingScore: '0.4601942440481428',
stakeScore: '0.2300971220240714',
performanceScore: '1',
votingPower: '2408',
status: 'tendermint',
__typename: 'RankingScore',
},
}),
],
nodeData: {
stakedTotal: '9618711883996159534058',
stakedTotalFormatted: addDecimal('9618711883996159534058', 18),
totalNodes: 1,
inactiveNodes: 0,
validatingNodes: 1,
uptime: 1560.266845703125,
__typename: 'NodeData',
},
};
renderNodeList(MOCK_NODE);
await waitFor(() => {
expect(screen.getByText(MOCK_NODES.nodes[0].name)).toBeInTheDocument();
expect(screen.getByText(MOCK_NODE.nodes[0].name)).toBeInTheDocument();
});
const items = screen.queryAllByTestId('node-list-item');
const item = within(items[0]);
const rows = item.getAllByRole('row');
const expectedValues = [
['Total stake', '14,182.45 (50.94%)'],
['Ranking score', '0.6785'],
['Stake score', '0.3393'],
['Performance score', '0.9999'],
['Voting score', '2,407.0000'],
];
const grid = screen.getByTestId('validators-grid');
for (const [i, r] of rows.entries()) {
const row = within(r);
const cell = row.getByRole('cell');
const header = row.getByRole('rowheader');
expect(header).toHaveTextContent(expectedValues[i][0]);
expect(cell).toHaveTextContent(expectedValues[i][1]);
}
expect(
grid.querySelector('[role="gridcell"][col-id="validator"]')
).toHaveTextContent('T-800 Terminator');
expect(
grid.querySelector('[role="gridcell"][col-id="validator"] img')
).toHaveAttribute(
'src',
'https://upload.wikimedia.org/wikipedia/en/9/94/T-800_%28Model_101%29.png'
);
expect(
grid.querySelector('[role="gridcell"][col-id="status"]')
).toHaveTextContent('Consensus');
expect(
grid.querySelector('[role="gridcell"][col-id="totalStakeThisEpoch"]')
).toHaveTextContent('9,618.71');
expect(
grid.querySelector('[role="gridcell"][col-id="share"]')
).toHaveTextContent('100%');
expect(
grid.querySelector('[role="gridcell"][col-id="validatorStake"]')
).toHaveTextContent('9,618.71');
expect(
grid.querySelector('[role="gridcell"][col-id="pendingStake"]')
).toHaveTextContent('0');
expect(
grid.querySelector('[role="gridcell"][col-id="rankingScore"]')
).toHaveTextContent('0.46019');
expect(
grid.querySelector('[role="gridcell"][col-id="stakeScore"]')
).toHaveTextContent('0.23010');
expect(
grid.querySelector('[role="gridcell"][col-id="performanceScore"]')
).toHaveTextContent('1.00000');
expect(
grid.querySelector('[role="gridcell"][col-id="votingPower"]')
).toHaveTextContent('2408');
});
});

View File

@ -1,153 +1,228 @@
import { gql, useQuery } from '@apollo/client';
import { Callout, Intent } from '@vegaprotocol/ui-toolkit';
import React from 'react';
import { useEffect, useMemo, useRef, forwardRef } from 'react';
import {
AgGridDynamic as AgGrid,
AsyncRenderer,
} from '@vegaprotocol/ui-toolkit';
import type { AgGridReact } from 'ag-grid-react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { EpochCountdown } from '../../components/epoch-countdown';
import { BigNumber } from '../../lib/bignumber';
import { formatNumber } from '../../lib/format-number';
import { truncateMiddle } from '../../lib/truncate-middle';
import type { Nodes, Nodes_nodes_rankingScore } from './__generated__/Nodes';
import type { Staking_epoch, Staking_party } from './__generated__/Staking';
import { formatNumber } from '@vegaprotocol/react-helpers';
import type { Nodes } from './__generated__/Nodes';
import type { Staking_epoch } from './__generated__/Staking';
const VALIDATOR = 'validator';
const STATUS = 'status';
const TOTAL_STAKE_THIS_EPOCH = 'totalStakeThisEpoch';
const SHARE = 'share';
const VALIDATOR_STAKE = 'validatorStake';
const PENDING_STAKE = 'pendingStake';
const RANKING_SCORE = 'rankingScore';
const STAKE_SCORE = 'stakeScore';
const PERFORMANCE_SCORE = 'performanceScore';
const VOTING_POWER = 'votingPower';
export const NODES_QUERY = gql`
query Nodes {
nodes {
avatarUrl
id
name
pubkey
infoUrl
location
stakedByOperator
stakedByDelegates
stakedTotal
pendingStake
stakedByOperatorFormatted @client
stakedByDelegatesFormatted @client
stakedTotalFormatted @client
pendingStakeFormatted @client
epochData {
total
offline
online
}
status
pendingStake
rankingScore {
rankingScore
stakeScore
performanceScore
votingPower
stakeScore
status
}
}
nodeData {
stakedTotal
stakedTotalFormatted @client
totalNodes
inactiveNodes
validatingNodes
uptime
}
}
`;
const NodeListItemName = ({ children }: { children: React.ReactNode }) => (
<span className="mr-4 underline text-white">{children}</span>
);
const NodeListTr = ({ children }: { children: React.ReactNode }) => (
<tr className="flex">{children}</tr>
);
const NodeListTh = ({ children }: { children: React.ReactNode }) => (
<th
role="rowheader"
className="flex-1 break-words py-1 pr-4 pl-0 text-white-60 font-normal"
>
{children}
</th>
);
const NodeListTd = ({ children }: { children: React.ReactNode }) => (
<td className="flex-1 break-words py-1 px-4 font-mono text-right">
{children}
</td>
);
interface NodeListProps {
epoch: Staking_epoch | undefined;
party: Staking_party | null | undefined;
}
export const NodeList = ({ epoch, party }: NodeListProps) => {
const { t } = useTranslation();
const { data, error, loading } = useQuery<Nodes>(NODES_QUERY);
interface ValidatorRendererProps {
data: { validator: { avatarUrl: string; name: string } };
}
const nodes = React.useMemo<NodeListItemProps[]>(() => {
const ValidatorRenderer = ({ data }: ValidatorRendererProps) => {
const { avatarUrl, name } = data.validator;
return (
<div className="flex items-center">
{avatarUrl && (
<img
className="h-24 w-24 rounded-full mr-8"
src={avatarUrl}
alt={`Avatar icon for ${name}`}
/>
)}
{name}
</div>
);
};
// Custom styling to account for the scrollbar. This is needed because the
// AG Grid places the scrollbar over the bottom validator, which obstructs
const nodeListGridStyles = `
.ag-theme-balham-dark .ag-body-horizontal-scroll {
opacity: 0.25;
}
`;
export const NodeList = ({ epoch }: NodeListProps) => {
const { t } = useTranslation();
const { data, error, loading, refetch } = useQuery<Nodes>(NODES_QUERY);
const navigate = useNavigate();
useEffect(() => {
const epochInterval = setInterval(() => {
if (!epoch?.timestamps.expiry) return;
const now = Date.now();
const expiry = new Date(epoch.timestamps.expiry).getTime();
if (now > expiry) {
refetch();
clearInterval(epochInterval);
}
}, 10000);
return () => {
clearInterval(epochInterval);
};
}, [epoch?.timestamps.expiry, refetch]);
const nodes = useMemo(() => {
if (!data?.nodes) return [];
const nodesWithPercentages = data.nodes.map((node) => {
return data.nodes.map(
({
id,
name,
avatarUrl,
stakedTotalFormatted,
rankingScore: {
rankingScore,
stakeScore,
status,
performanceScore,
votingPower,
},
pendingStake,
}) => {
const stakedTotal = new BigNumber(
data?.nodeData?.stakedTotalFormatted || 0
);
const stakedOnNode = new BigNumber(node.stakedTotalFormatted);
const stakedOnNode = new BigNumber(stakedTotalFormatted);
const stakedTotalPercentage =
stakedTotal.isEqualTo(0) || stakedOnNode.isEqualTo(0)
? '-'
: stakedOnNode.dividedBy(stakedTotal).times(100).dp(2).toString() +
'%';
const userStake = party?.delegations?.length
? party?.delegations
?.filter((d) => d.node.id === node.id)
?.filter((d) => d.epoch === Number(epoch?.id))
.reduce((sum, d) => {
const value = new BigNumber(d.amountFormatted);
return sum.plus(value);
}, new BigNumber(0))
: new BigNumber(0);
const userStakePercentage =
userStake.isEqualTo(0) || stakedOnNode.isEqualTo(0)
? '-'
: userStake.dividedBy(stakedOnNode).times(100).dp(2).toString() + '%';
const statusTranslated = t(`status-${status}`);
return {
id: node.id,
name: node.name,
pubkey: node.pubkey,
stakedTotal,
stakedOnNode,
stakedTotalPercentage,
userStake,
userStakePercentage,
epoch,
scores: node.rankingScore,
id,
[VALIDATOR]: {
avatarUrl,
name,
},
[STATUS]: statusTranslated,
[TOTAL_STAKE_THIS_EPOCH]: formatNumber(stakedTotal, 2),
[SHARE]: stakedTotalPercentage,
[VALIDATOR_STAKE]: formatNumber(stakedOnNode, 2),
[PENDING_STAKE]: pendingStake,
[RANKING_SCORE]: formatNumber(new BigNumber(rankingScore), 5),
[STAKE_SCORE]: formatNumber(new BigNumber(stakeScore), 5),
[PERFORMANCE_SCORE]: formatNumber(new BigNumber(performanceScore), 5),
[VOTING_POWER]: votingPower,
};
});
return nodesWithPercentages;
}, [data, epoch, party]);
if (error) {
return (
<Callout intent={Intent.Danger} title={t('Something went wrong')}>
<pre>{error.message}</pre>
</Callout>
);
}
);
}, [data, t]);
const gridRef = useRef<AgGridReact | null>(null);
const NodeListTable = forwardRef<AgGridReact>((_, ref) => {
const colDefs = useMemo(
() => [
{
field: VALIDATOR,
headerName: t('validator').toString(),
cellRenderer: ValidatorRenderer,
},
{ field: STATUS, headerName: t('status').toString() },
{
field: TOTAL_STAKE_THIS_EPOCH,
headerName: t('totalStakeThisEpoch').toString(),
},
{ field: SHARE, headerName: t('share').toString() },
{ field: VALIDATOR_STAKE, headerName: t('validatorStake').toString() },
{ field: PENDING_STAKE, headerName: t('nextEpoch').toString() },
{ field: RANKING_SCORE, headerName: t('rankingScore').toString() },
{ field: STAKE_SCORE, headerName: t('stakeScore').toString() },
{
field: PERFORMANCE_SCORE,
headerName: t('performanceScore').toString(),
},
{ field: VOTING_POWER, headerName: t('votingPower').toString() },
],
[]
);
const defaultColDef = useMemo(
() => ({
sortable: true,
}),
[]
);
if (loading) {
return (
<div>
<p>{t('Loading')}</p>
<div data-testid="validators-grid">
<AgGrid
domLayout="autoHeight"
style={{ width: '100%' }}
customThemeParams={nodeListGridStyles}
overlayNoRowsTemplate={t('noValidators')}
ref={ref}
rowData={nodes}
rowHeight={32}
columnDefs={colDefs}
defaultColDef={defaultColDef}
animateRows={true}
suppressCellFocus={true}
onGridReady={(event) => {
event.columnApi.applyColumnState({
state: [
{
colId: t('rankingScore'),
sort: 'desc',
},
],
});
event.columnApi.autoSizeAllColumns(false);
}}
onCellClicked={(event) => {
navigate(event.data.id);
}}
/>
</div>
);
}
});
return (
<>
<AsyncRenderer loading={loading} error={error} data={nodes}>
{epoch && epoch.timestamps.start && epoch.timestamps.expiry && (
<EpochCountdown
id={epoch.id}
@ -155,82 +230,7 @@ export const NodeList = ({ epoch, party }: NodeListProps) => {
endDate={new Date(epoch.timestamps.expiry)}
/>
)}
<ul role="list" className="mt-24">
{nodes.map((n, i) => {
return <NodeListItem key={i} {...n} />;
})}
</ul>
</>
);
};
export interface NodeListItemProps {
id: string;
name: string;
stakedOnNode: BigNumber;
stakedTotalPercentage: string;
userStake: BigNumber;
userStakePercentage: string;
scores: Nodes_nodes_rankingScore;
}
export const NodeListItem = ({
id,
name,
stakedOnNode,
stakedTotalPercentage,
userStake,
userStakePercentage,
scores,
}: NodeListItemProps) => {
const { t } = useTranslation();
return (
<li
className="break-words flex flex-col justify-between mb-16 last:mb-0"
data-testid="node-list-item"
>
<Link to={id}>
{name ? (
<NodeListItemName>{name}</NodeListItemName>
) : (
<>
<NodeListItemName>{t('validatorTitleFallback')}</NodeListItemName>
<span
className="uppercase text-white-60"
title={`${t('id')}: ${id}`}
data-testid="node-list-item-name"
>
{truncateMiddle(id)}
</span>
</>
)}
</Link>
<table
className="flex-1 text-body border-collapse mt-4"
data-testid="node-list-item-table"
>
<tbody>
<NodeListTr>
<NodeListTh>{t('Total stake')}</NodeListTh>
<NodeListTd>
{formatNumber(stakedOnNode, 2)} ({stakedTotalPercentage})
</NodeListTd>
</NodeListTr>
{scores
? Object.entries(scores)
.filter(([key]) => key !== '__typename')
.map(([key, value]) => (
<NodeListTr key={`${id}_${key}`}>
<NodeListTh>{t(key)}</NodeListTh>
<NodeListTd>
{formatNumber(new BigNumber(value), 4)}
</NodeListTd>
</NodeListTr>
))
: null}
</tbody>
</table>
</li>
<NodeListTable ref={gridRef} />
</AsyncRenderer>
);
};

View File

@ -9,7 +9,7 @@ export const Staking = ({ data }: { data?: StakingQueryResult }) => {
return (
<>
<section data-testid="staking-description" className="mb-24">
<section className="mb-20">
<Callout
intent={Intent.Primary}
iconName="help"
@ -33,18 +33,9 @@ export const Staking = ({ data }: { data?: StakingQueryResult }) => {
</section>
<section>
<StakingStepSelectNode data={data} />
<h2 className="text-h4 uppercase">{t('Nodes')}</h2>
<NodeList data-testid="node-list" epoch={data?.epoch} />
</section>
</>
);
};
export const StakingStepSelectNode = ({
data,
}: {
data?: StakingQueryResult;
}) => {
return (
<NodeList data-testid="node-list" epoch={data?.epoch} party={data?.party} />
);
};

View File

@ -46,7 +46,10 @@ export const getDecimalSeparator = memoize(
.find((part) => part.type === 'decimal')?.value
);
export const formatNumber = (rawValue: string | number, formatDecimals = 0) => {
export const formatNumber = (
rawValue: string | number | BigNumber,
formatDecimals = 0
) => {
return getNumberFormat(formatDecimals).format(Number(rawValue));
};