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:
parent
8186bc3948
commit
6f374264c0
@ -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 () {
|
||||
|
@ -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"
|
||||
}
|
||||
|
62
apps/token/src/routes/staking/__generated__/Nodes.ts
generated
62
apps/token/src/routes/staking/__generated__/Nodes.ts
generated
@ -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 {
|
||||
|
@ -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 { 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>) => ({
|
||||
id: 'ccc022b7e63a4d0a6d3a193c3940c88574060e58a184964c994998d86835a1b4',
|
||||
name: 'Skynet',
|
||||
pubkey: '6abc23391a9f888ab240415bf63d6844b03fc360be822f4a1d2cd832d87b2917',
|
||||
infoUrl: 'https://en.wikipedia.org/wiki/Skynet_(Terminator)',
|
||||
location: '',
|
||||
stakedByOperator: '3000000000000000000000',
|
||||
stakedByDelegates: '11182454495731682635157',
|
||||
stakedTotal: '14182454495731682635157',
|
||||
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),
|
||||
epochData: null,
|
||||
status: 'Validator',
|
||||
rankingScore: {
|
||||
rankingScore: '0.67845061012234727427532760837568',
|
||||
stakeScore: '0.3392701644525644',
|
||||
performanceScore: '0.9998677767864936',
|
||||
votingPower: '2407',
|
||||
__typename: 'RankingScore',
|
||||
},
|
||||
__typename: 'Node',
|
||||
...overrides,
|
||||
});
|
||||
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',
|
||||
pendingStakeFormatted: addDecimal('0', 18),
|
||||
epochData: null,
|
||||
status: 'Validator',
|
||||
rankingScore: {
|
||||
rankingScore: '0.67845061012234727427532760837568',
|
||||
stakeScore: '0.3392701644525644',
|
||||
performanceScore: '0.9998677767864936',
|
||||
votingPower: '2407',
|
||||
status: 'tendermint',
|
||||
__typename: 'RankingScore',
|
||||
},
|
||||
__typename: 'Node',
|
||||
};
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
@ -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) => {
|
||||
const stakedTotal = new BigNumber(
|
||||
data?.nodeData?.stakedTotalFormatted || 0
|
||||
);
|
||||
const stakedOnNode = new BigNumber(node.stakedTotalFormatted);
|
||||
const stakedTotalPercentage =
|
||||
stakedTotal.isEqualTo(0) || stakedOnNode.isEqualTo(0)
|
||||
? '-'
|
||||
: stakedOnNode.dividedBy(stakedTotal).times(100).dp(2).toString() +
|
||||
'%';
|
||||
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(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
|
||||
? 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() + '%';
|
||||
|
||||
return {
|
||||
id: node.id,
|
||||
name: node.name,
|
||||
pubkey: node.pubkey,
|
||||
stakedTotal,
|
||||
stakedOnNode,
|
||||
stakedTotalPercentage,
|
||||
userStake,
|
||||
userStakePercentage,
|
||||
epoch,
|
||||
scores: node.rankingScore,
|
||||
};
|
||||
});
|
||||
|
||||
return nodesWithPercentages;
|
||||
}, [data, epoch, party]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Callout intent={Intent.Danger} title={t('Something went wrong')}>
|
||||
<pre>{error.message}</pre>
|
||||
</Callout>
|
||||
return {
|
||||
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,
|
||||
};
|
||||
}
|
||||
);
|
||||
}, [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>
|
||||
);
|
||||
};
|
||||
|
@ -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} />
|
||||
);
|
||||
};
|
||||
|
@ -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));
|
||||
};
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user