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 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 () { context('Staking Page - verify elements on page', function () {
before('navigate to staking page', function () { before('navigate to staking page', function () {

View File

@ -548,6 +548,16 @@
"fail": "Fail", "fail": "Fail",
"rankingScore": "Ranking score", "rankingScore": "Ranking score",
"stakeScore": "Stake score", "stakeScore": "Stake score",
"performanceScore": "Performance score", "performanceScore": "Performance",
"votingPower": "Voting score" "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 // @generated
// This file was automatically generated and should not be edited. // This file was automatically generated and should not be edited.
import { NodeStatus } from "@vegaprotocol/types";
// ==================================================== // ====================================================
// GraphQL query operation: Nodes // 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 { export interface Nodes_nodes_rankingScore {
__typename: "RankingScore"; __typename: "RankingScore";
/** /**
@ -43,10 +25,15 @@ export interface Nodes_nodes_rankingScore {
* The tendermint voting power of the validator (uint32) * The tendermint voting power of the validator (uint32)
*/ */
votingPower: string; votingPower: string;
/**
* The current validation status of the validator
*/
status: string;
} }
export interface Nodes_nodes { export interface Nodes_nodes {
__typename: "Node"; __typename: "Node";
avatarUrl: string | null;
/** /**
* The node url eg n01.vega.xyz * The node url eg n01.vega.xyz
*/ */
@ -56,36 +43,15 @@ export interface Nodes_nodes {
* Pubkey of the node operator * Pubkey of the node operator
*/ */
pubkey: string; 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 * Total amount staked on node
*/ */
stakedTotal: string; stakedTotal: string;
stakedTotalFormatted: string;
/** /**
* Amount of stake on the next epoch * Amount of stake on the next epoch
*/ */
pendingStake: string; 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 * Ranking scores and status for the validator for the current epoch
*/ */
@ -99,22 +65,6 @@ export interface Nodes_nodeData {
*/ */
stakedTotal: string; stakedTotal: string;
stakedTotalFormatted: 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 { export interface Nodes {

View File

@ -1,49 +1,44 @@
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 { NodeList, NODES_QUERY } from './node-list';
import { MockedProvider } from '@apollo/client/testing'; import { MockedProvider } from '@apollo/client/testing';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import { addDecimal } from '@vegaprotocol/react-helpers'; import { addDecimal } from '@vegaprotocol/react-helpers';
import type { Nodes_nodes } from './__generated__/Nodes'; import type { Nodes_nodes } from './__generated__/Nodes';
import type { PartialDeep } from 'type-fest';
jest.mock('../../components/epoch-countdown', () => ({ jest.mock('../../components/epoch-countdown', () => ({
EpochCountdown: () => <div data-testid="epoch-info"></div>, EpochCountdown: () => <div data-testid="epoch-info"></div>,
})); }));
const nodeFactory = (overrides?: Partial<Nodes_nodes>) => ({ const nodeFactory = (overrides?: PartialDeep<Nodes_nodes>) => {
id: 'ccc022b7e63a4d0a6d3a193c3940c88574060e58a184964c994998d86835a1b4', const defaultNode = {
name: 'Skynet', id: 'ccc022b7e63a4d0a6d3a193c3940c88574060e58a184964c994998d86835a1b4',
pubkey: '6abc23391a9f888ab240415bf63d6844b03fc360be822f4a1d2cd832d87b2917', name: 'Skynet',
infoUrl: 'https://en.wikipedia.org/wiki/Skynet_(Terminator)', avatarUrl: 'https://upload.wikimedia.org/wikipedia/en/2/25/Marvin-TV-3.jpg',
location: '', pubkey: '6abc23391a9f888ab240415bf63d6844b03fc360be822f4a1d2cd832d87b2917',
stakedByOperator: '3000000000000000000000', infoUrl: 'https://en.wikipedia.org/wiki/Skynet_(Terminator)',
stakedByDelegates: '11182454495731682635157', location: '',
stakedTotal: '14182454495731682635157', stakedByOperator: '3000000000000000000000',
pendingStake: '0', stakedByDelegates: '11182454495731682635157',
stakedByOperatorFormatted: addDecimal( stakedTotal: '14182454495731682635157',
overrides?.stakedByOperator || '3000000000000000000000', stakedTotalFormatted: addDecimal('14182454495731682635157', 18),
18 pendingStake: '0',
), pendingStakeFormatted: addDecimal('0', 18),
stakedByDelegatesFormatted: addDecimal( epochData: null,
overrides?.stakedByDelegates || '11182454495731682635157', status: 'Validator',
18 rankingScore: {
), rankingScore: '0.67845061012234727427532760837568',
stakedTotalFormatted: addDecimal( stakeScore: '0.3392701644525644',
overrides?.stakedTotal || '14182454495731682635157', performanceScore: '0.9998677767864936',
18 votingPower: '2407',
), status: 'tendermint',
pendingStakeFormatted: addDecimal(overrides?.pendingStake || '0', 18), __typename: 'RankingScore',
epochData: null, },
status: 'Validator', __typename: 'Node',
rankingScore: { };
rankingScore: '0.67845061012234727427532760837568', return merge(defaultNode, overrides);
stakeScore: '0.3392701644525644', };
performanceScore: '0.9998677767864936',
votingPower: '2407',
__typename: 'RankingScore',
},
__typename: 'Node',
...overrides,
});
const MOCK_NODES = { const MOCK_NODES = {
nodes: [ nodes: [
@ -53,15 +48,14 @@ const MOCK_NODES = {
name: 'T-800 Terminator', name: 'T-800 Terminator',
pubkey: pubkey:
'ccc3b8362c25b09d20df8ea407b0a476d6b24a0e72bc063d0033c8841652ddd4', 'ccc3b8362c25b09d20df8ea407b0a476d6b24a0e72bc063d0033c8841652ddd4',
infoUrl: 'https://en.wikipedia.org/wiki/Terminator_(character)',
stakedByOperator: '3000000000000000000000',
stakedByDelegates: '6618711883996159534058',
stakedTotal: '9618711883996159534058', stakedTotal: '9618711883996159534058',
stakedTotalFormatted: addDecimal('9618711883996159534058', 18),
rankingScore: { rankingScore: {
rankingScore: '0.4601942440481428', rankingScore: '0.4601942440481428',
stakeScore: '0.2300971220240714', stakeScore: '0.2300971220240714',
performanceScore: '1', performanceScore: '1',
votingPower: '2408', votingPower: '2408',
status: 'tendermint',
__typename: 'RankingScore', __typename: 'RankingScore',
}, },
}), }),
@ -70,16 +64,15 @@ const MOCK_NODES = {
name: 'NCC-1701-E', name: 'NCC-1701-E',
pubkey: pubkey:
'0931a8fd8cc935458f470e435a05414387cea6f329d648be894fcd44bd517a2b', '0931a8fd8cc935458f470e435a05414387cea6f329d648be894fcd44bd517a2b',
infoUrl: 'https://en.wikipedia.org/wiki/USS_Enterprise_(NCC-1701-E)',
stakedByOperator: '3000000000000000000000',
stakedByDelegates: '1041343338923442976709',
stakedTotal: '4041343338923442976709', stakedTotal: '4041343338923442976709',
stakedTotalFormatted: addDecimal('4041343338923442976709', 18),
pendingStake: '0', pendingStake: '0',
rankingScore: { rankingScore: {
rankingScore: '0.1932810100133910357676209647912', rankingScore: '0.1932810100133910357676209647912',
stakeScore: '0.0966762995515676', stakeScore: '0.0966762995515676',
performanceScore: '0.999629748500531', performanceScore: '0.999629748500531',
votingPower: '1163', votingPower: '1163',
status: 'tendermint',
__typename: 'RankingScore', __typename: 'RankingScore',
}, },
}), }),
@ -95,14 +88,14 @@ const MOCK_NODES = {
}, },
}; };
const renderNodeList = () => { const renderNodeList = (data = MOCK_NODES) => {
return render( return render(
<MemoryRouter> <MemoryRouter>
<MockedProvider <MockedProvider
mocks={[ mocks={[
{ {
request: { query: NODES_QUERY }, request: { query: NODES_QUERY },
result: { data: MOCK_NODES }, result: { data },
}, },
]} ]}
> >
@ -117,27 +110,6 @@ const renderNodeList = () => {
expiry: new Date(1000 * 60 * 60 * 24).toISOString(), 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> </MockedProvider>
</MemoryRouter> </MemoryRouter>
@ -162,43 +134,101 @@ describe('Nodes list', () => {
expect(screen.getByTestId('epoch-info')).toBeInTheDocument(); expect(screen.getByTestId('epoch-info')).toBeInTheDocument();
}); });
it('should a lit of all nodes', async () => { it('should render a list of all nodes', async () => {
renderNodeList(); renderNodeList();
await waitFor(() => { await waitFor(() => {
expect(screen.getByText(MOCK_NODES.nodes[0].name)).toBeInTheDocument(); 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 () => { it('should display the correctly formatted fields in the correct columns', async () => {
renderNodeList(); 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(() => { 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 = [ const grid = screen.getByTestId('validators-grid');
['Total stake', '14,182.45 (50.94%)'],
['Ranking score', '0.6785'],
['Stake score', '0.3393'],
['Performance score', '0.9999'],
['Voting score', '2,407.0000'],
];
for (const [i, r] of rows.entries()) { expect(
const row = within(r); grid.querySelector('[role="gridcell"][col-id="validator"]')
const cell = row.getByRole('cell'); ).toHaveTextContent('T-800 Terminator');
const header = row.getByRole('rowheader');
expect(header).toHaveTextContent(expectedValues[i][0]); expect(
expect(cell).toHaveTextContent(expectedValues[i][1]); 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 { gql, useQuery } from '@apollo/client';
import { Callout, Intent } from '@vegaprotocol/ui-toolkit'; import { useEffect, useMemo, useRef, forwardRef } from 'react';
import React 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 { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { EpochCountdown } from '../../components/epoch-countdown'; import { EpochCountdown } from '../../components/epoch-countdown';
import { BigNumber } from '../../lib/bignumber'; import { BigNumber } from '../../lib/bignumber';
import { formatNumber } from '../../lib/format-number'; import { formatNumber } from '@vegaprotocol/react-helpers';
import { truncateMiddle } from '../../lib/truncate-middle'; import type { Nodes } from './__generated__/Nodes';
import type { Nodes, Nodes_nodes_rankingScore } from './__generated__/Nodes'; import type { Staking_epoch } from './__generated__/Staking';
import type { Staking_epoch, Staking_party } 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` export const NODES_QUERY = gql`
query Nodes { query Nodes {
nodes { nodes {
avatarUrl
id id
name name
pubkey pubkey
infoUrl
location
stakedByOperator
stakedByDelegates
stakedTotal stakedTotal
pendingStake
stakedByOperatorFormatted @client
stakedByDelegatesFormatted @client
stakedTotalFormatted @client stakedTotalFormatted @client
pendingStakeFormatted @client pendingStake
epochData {
total
offline
online
}
status
rankingScore { rankingScore {
rankingScore rankingScore
stakeScore stakeScore
performanceScore performanceScore
votingPower votingPower
stakeScore status
} }
} }
nodeData { nodeData {
stakedTotal stakedTotal
stakedTotalFormatted @client 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 { interface NodeListProps {
epoch: Staking_epoch | undefined; epoch: Staking_epoch | undefined;
party: Staking_party | null | undefined;
} }
export const NodeList = ({ epoch, party }: NodeListProps) => { interface ValidatorRendererProps {
const { t } = useTranslation(); data: { validator: { avatarUrl: string; name: string } };
const { data, error, loading } = useQuery<Nodes>(NODES_QUERY); }
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 []; if (!data?.nodes) return [];
const nodesWithPercentages = data.nodes.map((node) => { return data.nodes.map(
const stakedTotal = new BigNumber( ({
data?.nodeData?.stakedTotalFormatted || 0 id,
); name,
const stakedOnNode = new BigNumber(node.stakedTotalFormatted); avatarUrl,
const stakedTotalPercentage = stakedTotalFormatted,
stakedTotal.isEqualTo(0) || stakedOnNode.isEqualTo(0) rankingScore: {
? '-' rankingScore,
: stakedOnNode.dividedBy(stakedTotal).times(100).dp(2).toString() + stakeScore,
'%'; status,
performanceScore,
votingPower,
},
pendingStake,
}) => {
const stakedTotal = new BigNumber(
data?.nodeData?.stakedTotalFormatted || 0
);
const stakedOnNode = new BigNumber(stakedTotalFormatted);
const stakedTotalPercentage =
stakedTotal.isEqualTo(0) || stakedOnNode.isEqualTo(0)
? '-'
: stakedOnNode.dividedBy(stakedTotal).times(100).dp(2).toString() +
'%';
const statusTranslated = t(`status-${status}`);
const userStake = party?.delegations?.length return {
? party?.delegations id,
?.filter((d) => d.node.id === node.id) [VALIDATOR]: {
?.filter((d) => d.epoch === Number(epoch?.id)) avatarUrl,
.reduce((sum, d) => { name,
const value = new BigNumber(d.amountFormatted); },
return sum.plus(value); [STATUS]: statusTranslated,
}, new BigNumber(0)) [TOTAL_STAKE_THIS_EPOCH]: formatNumber(stakedTotal, 2),
: new BigNumber(0); [SHARE]: stakedTotalPercentage,
[VALIDATOR_STAKE]: formatNumber(stakedOnNode, 2),
const userStakePercentage = [PENDING_STAKE]: pendingStake,
userStake.isEqualTo(0) || stakedOnNode.isEqualTo(0) [RANKING_SCORE]: formatNumber(new BigNumber(rankingScore), 5),
? '-' [STAKE_SCORE]: formatNumber(new BigNumber(stakeScore), 5),
: userStake.dividedBy(stakedOnNode).times(100).dp(2).toString() + '%'; [PERFORMANCE_SCORE]: formatNumber(new BigNumber(performanceScore), 5),
[VOTING_POWER]: votingPower,
return { };
id: node.id, }
name: node.name, );
pubkey: node.pubkey, }, [data, t]);
stakedTotal,
stakedOnNode, const gridRef = useRef<AgGridReact | null>(null);
stakedTotalPercentage,
userStake, const NodeListTable = forwardRef<AgGridReact>((_, ref) => {
userStakePercentage, const colDefs = useMemo(
epoch, () => [
scores: node.rankingScore, {
}; field: VALIDATOR,
}); headerName: t('validator').toString(),
cellRenderer: ValidatorRenderer,
return nodesWithPercentages; },
}, [data, epoch, party]); { field: STATUS, headerName: t('status').toString() },
{
if (error) { field: TOTAL_STAKE_THIS_EPOCH,
return ( headerName: t('totalStakeThisEpoch').toString(),
<Callout intent={Intent.Danger} title={t('Something went wrong')}> },
<pre>{error.message}</pre> { field: SHARE, headerName: t('share').toString() },
</Callout> { 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 ( return (
<div> <div data-testid="validators-grid">
<p>{t('Loading')}</p> <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> </div>
); );
} });
return ( return (
<> <AsyncRenderer loading={loading} error={error} data={nodes}>
{epoch && epoch.timestamps.start && epoch.timestamps.expiry && ( {epoch && epoch.timestamps.start && epoch.timestamps.expiry && (
<EpochCountdown <EpochCountdown
id={epoch.id} id={epoch.id}
@ -155,82 +230,7 @@ export const NodeList = ({ epoch, party }: NodeListProps) => {
endDate={new Date(epoch.timestamps.expiry)} endDate={new Date(epoch.timestamps.expiry)}
/> />
)} )}
<ul role="list" className="mt-24"> <NodeListTable ref={gridRef} />
{nodes.map((n, i) => { </AsyncRenderer>
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>
); );
}; };

View File

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