Skip to content

Commit

Permalink
feat(explorer): add manual sorting to validators page (#5338)
Browse files Browse the repository at this point in the history
* feat(explorer) improve validators page sorting

* refactor(explorer): remove debug logs and improve table header cell styling

* fix(ui-kit, explorer): Refactor sorting logic in TableCard and TableHeaderCell components

* fix(ui-kit): expand click place if column is sortable

* refactor(explorer): remove custom sorting hook and use default sorting in TopValidatorsCard

* revert nextSortOrder

* refactor(explorer): rename getSortOrderForColumn to getColumnSortOrder for clarity

---------

Co-authored-by: Marc Espin <mespinsanz@gmail.com>
  • Loading branch information
panteleymonchuk and marc2332 authored Feb 26, 2025
1 parent fa71240 commit 0072f5b
Show file tree
Hide file tree
Showing 5 changed files with 161 additions and 51 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,12 @@ export function TopValidatorsCard({ limit, showIcon }: TopValidatorsCardProps):

{isSuccess && (
<ErrorBoundary>
<TableCard data={topActiveValidators} columns={tableColumns} />
<TableCard
sortTable
defaultSorting={[{ id: 'stakingPoolIotaBalance', desc: true }]}
data={topActiveValidators}
columns={tableColumns}
/>
</ErrorBoundary>
)}
</div>
Expand Down
28 changes: 25 additions & 3 deletions apps/explorer/src/components/ui/TableCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
TableRow,
TableActionButton,
type TablePaginationOptions,
TableHeaderCellSortOrder,
} from '@iota/apps-ui-kit';
import {
type ColumnDef,
Expand Down Expand Up @@ -67,6 +68,17 @@ export function TableCard<DataType extends object>({
},
});

function getColumnSortOrder(columnId: string, sortEnabled?: boolean) {
const sortState = sorting.find((sort) => sort.id === columnId);
if (!sortEnabled || !sortState) {
return undefined;
}

if (sortState) {
return sortState.desc ? TableHeaderCellSortOrder.Desc : TableHeaderCellSortOrder.Asc;
}
}

return (
<div className={clsx('w-full overflow-visible', refetching && 'opacity-50')}>
<Table
Expand All @@ -91,11 +103,21 @@ export function TableCard<DataType extends object>({
columnKey={id}
label={column.columnDef.header?.toString()}
hasSort={column.columnDef.enableSorting}
onSortClick={
sortOrder={getColumnSortOrder(
id,
column.columnDef.enableSorting,
)}
onSortClick={(key, sortOrder) => {
setSorting([
{
id: String(key),
desc: sortOrder === TableHeaderCellSortOrder.Desc,
},
]);
column.columnDef.enableSorting
? column.getToggleSortingHandler()
: undefined
}
: undefined;
}}
isContentCentered={areHeadersCentered}
/>
))}
Expand Down
127 changes: 97 additions & 30 deletions apps/explorer/src/lib/ui/utils/generateValidatorsTableColumns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// SPDX-License-Identifier: Apache-2.0

import { Badge, BadgeType, TableCellBase, TableCellText } from '@iota/apps-ui-kit';
import type { ColumnDef } from '@tanstack/react-table';
import type { ColumnDef, Row } from '@tanstack/react-table';
import { type ApyByValidator, formatPercentageDisplay, ImageIcon, ImageIconSize } from '@iota/core';
import { ampli, getValidatorMoveEvent, VALIDATOR_LOW_STAKE_GRACE_PERIOD } from '~/lib';
import { StakeColumn } from '~/components';
Expand Down Expand Up @@ -84,6 +84,13 @@ export function generateValidatorsTableColumns({
{
header: 'Name',
id: 'name',
accessorKey: 'name',
enableSorting: true,
sortingFn: (row1, row2, columnId) => {
const value1 = row1.getValue<string>(columnId);
const value2 = row2.getValue<string>(columnId);
return sortByString(value1, value2);
},
cell({ row: { original: validator } }) {
return (
<TableCellBase>
Expand Down Expand Up @@ -113,6 +120,9 @@ export function generateValidatorsTableColumns({
{
header: 'Stake',
accessorKey: 'stakingPoolIotaBalance',
enableSorting: true,
sortingFn: (rowA, rowB, columnId) =>
BigInt(rowA.getValue(columnId)) - BigInt(rowB.getValue(columnId)) > 0 ? 1 : -1,
cell({ getValue }) {
const stakingPoolIotaBalance = getValue<string>();
return (
Expand All @@ -137,6 +147,17 @@ export function generateValidatorsTableColumns({
{
header: 'APY',
accessorKey: 'iotaAddress',
enableSorting: true,
sortingFn: (rowA, rowB, columnId) => {
const apyA = rollingAverageApys?.[rowA.getValue<string>(columnId)]?.apy ?? null;
const apyB = rollingAverageApys?.[rowB.getValue<string>(columnId)]?.apy ?? null;

// Handle null values: move nulls to the bottom
if (apyA === null) return 1;
if (apyB === null) return -1;

return apyA - apyB;
},
cell({ getValue }) {
const iotaAddress = getValue<string>();
const { apy, isApyApproxZero } = rollingAverageApys?.[iotaAddress] ?? {
Expand All @@ -154,6 +175,8 @@ export function generateValidatorsTableColumns({
{
header: 'Commission',
accessorKey: 'commissionRate',
enableSorting: true,
sortingFn: sortByNumber,
cell({ getValue }) {
return (
<TableCellBase>
Expand All @@ -164,20 +187,24 @@ export function generateValidatorsTableColumns({
},
{
header: 'Last Epoch Rewards',
accessorKey: 'lastReward',
id: 'lastReward',
cell({ row: { original: validator } }) {
const event = getValidatorMoveEvent(validatorEvents, validator.iotaAddress) as {
pool_staking_reward?: string;
};
const lastReward = event?.pool_staking_reward ?? null;
enableSorting: true,
sortingFn: (rowA, rowB) => {
const lastRewardA = getLastReward(validatorEvents, rowA);
const lastRewardB = getLastReward(validatorEvents, rowB);

if (lastRewardA === null) return 1;
if (lastRewardB === null) return -1;

return lastRewardA > lastRewardB ? 1 : -1;
},
cell({ row }) {
const lastReward = getLastReward(validatorEvents, row);
return (
<TableCellBase>
<TableCellText>
{lastReward !== null ? (
<StakeColumn stake={Number(lastReward)} />
) : (
'--'
)}
{lastReward !== null ? <StakeColumn stake={lastReward} /> : '--'}
</TableCellText>
</TableCellBase>
);
Expand All @@ -186,6 +213,8 @@ export function generateValidatorsTableColumns({
{
header: 'Voting Power',
accessorKey: 'votingPower',
enableSorting: true,
sortingFn: sortByNumber,
cell({ getValue }) {
const votingPower = getValue<string>();
return (
Expand All @@ -200,28 +229,23 @@ export function generateValidatorsTableColumns({

{
header: 'Status',
accessorKey: 'atRisk',
id: 'atRisk',
cell({ row: { original: validator } }) {
const atRiskValidator = atRiskValidators.find(
([address]) => address === validator.iotaAddress,
);
const isAtRisk = !!atRiskValidator;
const atRisk = isAtRisk
? VALIDATOR_LOW_STAKE_GRACE_PERIOD - Number(atRiskValidator[1])
: null;

if (atRisk === null) {
return (
<TableCellBase>
<Badge type={BadgeType.PrimarySoft} label="Active" />
</TableCellBase>
);
}

const atRiskText = atRisk > 1 ? `in ${atRisk} epochs` : 'next epoch';
enableSorting: true,
sortingFn: (rowA, rowB) => {
const { label: labelA } = determineRisk(atRiskValidators, rowA);
const { label: labelB } = determineRisk(atRiskValidators, rowB);
return sortByString(labelA, labelB);
},
cell({ row }) {
const { atRisk, label } = determineRisk(atRiskValidators, row);

return (
<TableCellBase>
<Badge type={BadgeType.Neutral} label={`At Risk ${atRiskText}`} />
<Badge
type={atRisk === null ? BadgeType.PrimarySoft : BadgeType.Neutral}
label={label}
/>
</TableCellBase>
);
},
Expand All @@ -236,3 +260,46 @@ export function generateValidatorsTableColumns({

return columns;
}

function sortByString(value1: string, value2: string) {
return value1.localeCompare(value2, undefined, { sensitivity: 'base' });
}

function sortByNumber(
rowA: Row<IotaValidatorSummary>,
rowB: Row<IotaValidatorSummary>,
columnId: string,
) {
return Number(rowA.getValue(columnId)) - Number(rowB.getValue(columnId)) > 0 ? 1 : -1;
}

function getLastReward(
validatorEvents: IotaEvent[],
row: Row<IotaValidatorSummary>,
): number | null {
const { original: validator } = row;
const event = getValidatorMoveEvent(validatorEvents, validator.iotaAddress) as {
pool_staking_reward?: string;
};

return event?.pool_staking_reward ? Number(event.pool_staking_reward) : null;
}

function determineRisk(atRiskValidators: [string, string][], row: Row<IotaValidatorSummary>) {
const { original: validator } = row;
const atRiskValidator = atRiskValidators.find(([address]) => address === validator.iotaAddress);
const isAtRisk = !!atRiskValidator;
const atRisk = isAtRisk ? VALIDATOR_LOW_STAKE_GRACE_PERIOD - Number(atRiskValidator[1]) : null;

const label =
atRisk === null
? 'Active'
: atRisk > 1
? `At Risk in ${atRisk} epochs`
: 'At Risk next epoch';

return {
label,
atRisk,
};
}
10 changes: 6 additions & 4 deletions apps/explorer/src/pages/validators/Validators.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,6 @@ function ValidatorPageResult(): JSX.Element {
const lastEpochRewardOnAllValidators =
epochData?.data[0].endOfEpochInfo?.totalStakeRewardsDistributed;

const tableData = data ? [...data.activeValidators].sort(() => 0.5 - Math.random()) : [];

const tableColumns = useMemo(() => {
if (!data || !validatorEvents) return null;
return generateValidatorsTableColumns({
Expand Down Expand Up @@ -176,9 +174,13 @@ function ValidatorPageResult(): JSX.Element {
colHeadings={['Name', 'Address', 'Stake']}
/>
)}
{isSuccess && tableData && tableColumns && (
{isSuccess && data.activeValidators && tableColumns && (
<TableCard
data={tableData}
sortTable
defaultSorting={[
{ id: 'stakingPoolIotaBalance', desc: true },
]}
data={data.activeValidators}
columns={tableColumns}
areHeadersCentered={false}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
// Copyright (c) 2024 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0

import { useState } from 'react';
import { SortByDown, SortByUp } from '@iota/apps-ui-icons';
import cx from 'classnames';
import { Checkbox } from '@/lib';
Expand Down Expand Up @@ -52,6 +51,10 @@ export interface TableHeaderCellProps {
* Whether the cell content should be centered.
*/
isContentCentered?: boolean;
/**
* Sort order when cell is initialized
*/
sortOrder?: TableHeaderCellSortOrder;
}

export function TableHeaderCell({
Expand All @@ -64,17 +67,13 @@ export function TableHeaderCell({
isContentCentered,
onSortClick,
onCheckboxChange,
sortOrder,
}: TableHeaderCellProps): JSX.Element {
const [sortOrder, setSortOrder] = useState<TableHeaderCellSortOrder | null>(
TableHeaderCellSortOrder.Asc,
);

const handleSort = () => {
const newSortOrder =
sortOrder === TableHeaderCellSortOrder.Asc
? TableHeaderCellSortOrder.Desc
: TableHeaderCellSortOrder.Asc;
setSortOrder(newSortOrder);
if (onSortClick) {
onSortClick(columnKey, newSortOrder);
}
Expand All @@ -83,10 +82,30 @@ export function TableHeaderCell({
const textColorClass = 'text-neutral-10 dark:text-neutral-92';
const textSizeClass = 'text-label-lg';

const sortElement = (() => {
if (!hasSort) {
return null;
}

if (sortOrder === TableHeaderCellSortOrder.Asc) {
return <SortByUp className="shrink-0" />;
}

if (sortOrder === TableHeaderCellSortOrder.Desc) {
return <SortByDown className="shrink-0" />;
}

return <SortByUp className="invisible shrink-0 group-hover:visible" />;
})();

return (
<th
onClick={hasSort ? handleSort : undefined}
className={cx(
'state-layer relative h-14 border-b border-shader-neutral-light-8 px-md after:pointer-events-none dark:border-shader-neutral-dark-8',
'state-layer group relative h-14 border-b border-shader-neutral-light-8 px-md after:pointer-events-none dark:border-shader-neutral-dark-8',
{
'cursor-pointer': hasSort,
},
)}
>
<div
Expand Down Expand Up @@ -114,12 +133,7 @@ export function TableHeaderCell({
{label}
</span>
)}
{hasSort && sortOrder === TableHeaderCellSortOrder.Asc && (
<SortByUp className="cursor-pointer" onClick={handleSort} />
)}
{hasSort && sortOrder === TableHeaderCellSortOrder.Desc && (
<SortByDown className="cursor-pointer" onClick={handleSort} />
)}
{sortElement}
</div>
</th>
);
Expand Down

0 comments on commit 0072f5b

Please sign in to comment.