Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
216 changes: 216 additions & 0 deletions apps/sv/frontend/src/__tests__/governance/governance-sorting.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
import { render, screen } from '@testing-library/react';
import { describe, expect, test } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import { ContractId } from '@daml/types';
import { VoteRequest } from '@daml.js/splice-dso-governance/lib/Splice/DsoRules';
import {
ActionRequiredSection,
ActionRequiredData,
} from '../../components/governance/ActionRequiredSection';
import { ProposalListingSection } from '../../components/governance/ProposalListingSection';
import { ProposalListingData } from '../../utils/types';

describe('Governance Page Sorting', () => {
describe('Action Required Section', () => {
test('should sort by voting closes date ascending (closest deadline first)', () => {
const unsortedRequests: ActionRequiredData[] = [
{
actionName: 'Action C - Latest',
contractId: 'c' as ContractId<VoteRequest>,
votingCloses: '2025-01-25 12:00',
createdAt: '2025-01-10 12:00',
requester: 'sv1',
},
{
actionName: 'Action A - Earliest',
contractId: 'a' as ContractId<VoteRequest>,
votingCloses: '2025-01-15 10:00',
createdAt: '2025-01-10 12:00',
requester: 'sv1',
},
{
actionName: 'Action B - Middle',
contractId: 'b' as ContractId<VoteRequest>,
votingCloses: '2025-01-15 18:00',
createdAt: '2025-01-10 12:00',
requester: 'sv1',
},
];

render(
<MemoryRouter>
<ActionRequiredSection actionRequiredRequests={unsortedRequests} />
</MemoryRouter>
);

const cards = screen.getAllByTestId('action-required-card');
const actionNames = cards.map(
card => card.querySelector('[data-testid="action-required-action-content"]')?.textContent
);

expect(actionNames).toEqual([
'Action A - Earliest',
'Action B - Middle',
'Action C - Latest',
]);
});
});

describe('Inflight Votes Section', () => {
const baseData: Omit<
ProposalListingData,
'actionName' | 'contractId' | 'voteTakesEffect' | 'votingThresholdDeadline' | 'voteStats'
> = {
yourVote: 'accepted',
status: 'In Progress',
acceptanceThreshold: BigInt(11),
};

test('should sort with Threshold items first (by votes desc, then deadline asc), then dated items by effective date asc', () => {
const unsortedRequests: ProposalListingData[] = [
{
...baseData,
actionName: 'Dated - Later',
contractId: 'd2' as ContractId<VoteRequest>,
voteTakesEffect: '2025-01-30 12:00',
votingThresholdDeadline: '2025-01-25 12:00',
voteStats: { accepted: 5, rejected: 2, 'no-vote': 1 },
},
{
...baseData,
actionName: 'Threshold - 5 votes, later deadline',
contractId: 't2' as ContractId<VoteRequest>,
voteTakesEffect: 'Threshold',
votingThresholdDeadline: '2025-01-25 12:00',
voteStats: { accepted: 3, rejected: 2, 'no-vote': 3 },
},
{
...baseData,
actionName: 'Dated - Earlier',
contractId: 'd1' as ContractId<VoteRequest>,
voteTakesEffect: '2025-01-20 12:00',
votingThresholdDeadline: '2025-01-15 12:00',
voteStats: { accepted: 5, rejected: 2, 'no-vote': 1 },
},
{
...baseData,
actionName: 'Threshold - 10 votes',
contractId: 't1' as ContractId<VoteRequest>,
voteTakesEffect: 'Threshold',
votingThresholdDeadline: '2025-01-30 12:00',
voteStats: { accepted: 7, rejected: 3, 'no-vote': 0 },
},
{
...baseData,
actionName: 'Threshold - 5 votes, earlier deadline',
contractId: 't3' as ContractId<VoteRequest>,
voteTakesEffect: 'Threshold',
votingThresholdDeadline: '2025-01-15 12:00',
voteStats: { accepted: 4, rejected: 1, 'no-vote': 3 },
},
];

render(
<MemoryRouter>
<ProposalListingSection
sectionTitle="Inflight Votes"
data={unsortedRequests}
noDataMessage="No data"
uniqueId="inflight-votes"
showVoteStats
showAcceptanceThreshold
showThresholdDeadline
sortOrder="effectiveAtAsc"
/>
</MemoryRouter>
);

const rows = screen.getAllByTestId('inflight-votes-row');
const actionNames = rows.map(
row => row.querySelector('[data-testid="inflight-votes-row-action-name"]')?.textContent
);

expect(actionNames).toEqual([
'Threshold - 10 votes', // Threshold first, most votes
'Threshold - 5 votes, earlier deadline', // Same 5 votes, earlier deadline wins
'Threshold - 5 votes, later deadline', // Same 5 votes, later deadline
'Dated - Earlier', // Dated items sorted by effective date asc
'Dated - Later',
]);
});
});

describe('Vote History Section', () => {
const baseData: Omit<
ProposalListingData,
'actionName' | 'contractId' | 'voteTakesEffect' | 'votingThresholdDeadline'
> = {
yourVote: 'accepted',
status: 'Implemented',
voteStats: { accepted: 8, rejected: 2, 'no-vote': 1 },
acceptanceThreshold: BigInt(11),
};

test('should sort by effective date descending (most recent first)', () => {
const unsortedRequests: ProposalListingData[] = [
{
...baseData,
actionName: 'Action A - Oldest',
contractId: 'a' as ContractId<VoteRequest>,
voteTakesEffect: '2025-01-10 12:00',
votingThresholdDeadline: '2025-01-05 12:00',
},
{
...baseData,
actionName: 'Action C - Most recent',
contractId: 'c' as ContractId<VoteRequest>,
voteTakesEffect: '2025-01-20 18:00',
votingThresholdDeadline: '2025-01-15 12:00',
},
{
...baseData,
actionName: 'Action D - Same day, earlier time',
contractId: 'd' as ContractId<VoteRequest>,
voteTakesEffect: '2025-01-20 10:00',
votingThresholdDeadline: '2025-01-15 12:00',
},
{
...baseData,
actionName: 'Action B - Middle',
contractId: 'b' as ContractId<VoteRequest>,
voteTakesEffect: '2025-01-15 12:00',
votingThresholdDeadline: '2025-01-10 12:00',
},
];

render(
<MemoryRouter>
<ProposalListingSection
sectionTitle="Vote History"
data={unsortedRequests}
noDataMessage="No data"
uniqueId="vote-history"
showStatus
showVoteStats
showAcceptanceThreshold
sortOrder="effectiveAtDesc"
/>
</MemoryRouter>
);

const rows = screen.getAllByTestId('vote-history-row');
const actionNames = rows.map(
row => row.querySelector('[data-testid="vote-history-row-action-name"]')?.textContent
);

expect(actionNames).toEqual([
'Action C - Most recent',
'Action D - Same day, earlier time',
'Action B - Middle',
'Action A - Oldest',
]);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -30,21 +30,26 @@ export const ActionRequiredSection: React.FC<ActionRequiredProps> = (
) => {
const { actionRequiredRequests } = props;

// Sort by voting closes date ascending (closest deadline first)
const sortedRequests = actionRequiredRequests.toSorted((a, b) =>
dayjs(a.votingCloses).isBefore(dayjs(b.votingCloses)) ? -1 : 1
);

return (
<Box sx={{ mb: 4 }} data-testid="action-required-section">
<PageSectionHeader
title="Action Required"
badgeCount={actionRequiredRequests.length}
badgeCount={sortedRequests.length}
data-testid="action-required"
/>

<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, mb: 3 }}>
{actionRequiredRequests.length === 0 ? (
{sortedRequests.length === 0 ? (
<Alert severity="info" data-testid={'action-required-section-no-items'}>
No Action Required items available
</Alert>
) : (
actionRequiredRequests.map((ar, index) => (
sortedRequests.map((ar, index) => (
<ActionCard
key={index}
action={ar.actionName}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ import { useNavigate } from 'react-router-dom';
import { PageSectionHeader, VoteStats } from '../../components/beta';
import { ProposalListingData, ProposalListingStatus, YourVoteStatus } from '../../utils/types';
import { InfoOutlined } from '@mui/icons-material';
import dayjs from 'dayjs';

export type ProposalSortOrder = 'effectiveAtAsc' | 'effectiveAtDesc';

interface ProposalListingSectionProps {
sectionTitle: string;
Expand All @@ -29,12 +32,38 @@ interface ProposalListingSectionProps {
showVoteStats?: boolean;
showAcceptanceThreshold?: boolean;
showStatus?: boolean;
sortOrder?: ProposalSortOrder;
}

const getColumnsCount = (alwaysShown: number, ...sometimesShown: (boolean | undefined)[]) =>
alwaysShown +
sometimesShown.reduce((columnsCount, isShown) => columnsCount + (isShown ? 1 : 0), 0);

const getTotalVotes = (item: ProposalListingData): number =>
item.voteStats['accepted'] + item.voteStats['rejected'];

const getEffectiveDate = (item: ProposalListingData): dayjs.Dayjs =>
item.voteTakesEffect === 'Threshold' ? dayjs(0) : dayjs(item.voteTakesEffect);

// Using stable sort: chain sorts from least to most significant criterion
const sortProposals = (
data: ProposalListingData[],
sortOrder?: ProposalSortOrder
): ProposalListingData[] => {
if (!sortOrder) return data;

if (sortOrder === 'effectiveAtDesc') {
return data.toSorted((a, b) => dayjs(b.voteTakesEffect).diff(dayjs(a.voteTakesEffect)));
}

// For effectiveAtAsc (Inflight Votes):
// Threshold items first (by votes desc, then deadline asc), then dated items (by effective date asc)
return data
.toSorted((a, b) => dayjs(a.votingThresholdDeadline).diff(dayjs(b.votingThresholdDeadline)))
.toSorted((a, b) => getTotalVotes(b) - getTotalVotes(a))
.toSorted((a, b) => getEffectiveDate(a).diff(getEffectiveDate(b)));
};

export const ProposalListingSection: React.FC<ProposalListingSectionProps> = props => {
const {
sectionTitle,
Expand All @@ -45,8 +74,11 @@ export const ProposalListingSection: React.FC<ProposalListingSectionProps> = pro
showVoteStats,
showAcceptanceThreshold,
showStatus,
sortOrder,
} = props;

const sortedData = sortProposals(data, sortOrder);

const columnsCount = getColumnsCount(
3,
showThresholdDeadline,
Expand All @@ -59,7 +91,7 @@ export const ProposalListingSection: React.FC<ProposalListingSectionProps> = pro
<Box sx={{ mb: 6 }} data-testid={`${uniqueId}-section`}>
<PageSectionHeader title={sectionTitle} data-testid={`${uniqueId}-section`} />

{data.length === 0 ? (
{sortedData.length === 0 ? (
<InfoBox info={noDataMessage} data-testid={`${uniqueId}-section-info`} />
) : (
<TableContainer data-testid={`${uniqueId}-section-table`}>
Expand All @@ -79,7 +111,7 @@ export const ProposalListingSection: React.FC<ProposalListingSectionProps> = pro
</TableRow>
</TableHead>
<TableBody sx={{ display: 'contents' }}>
{data.map((vote, index) => (
{sortedData.map((vote, index) => (
<VoteRow
key={index}
actionName={vote.actionName}
Expand Down
39 changes: 19 additions & 20 deletions apps/sv/frontend/src/routes/governance.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,26 +140,23 @@ export const Governance: React.FC = () => {

const allRequests = [...acceptedRequests, ...notAcceptedRequests];

const voteHistory = allRequests
.map(vr => {
const votes = vr.request.votes.entriesArray().map(e => e[1]);

return {
contractId: vr.request.trackingCid,
actionName:
actionTagToTitle(amuletName)[getAction(vr.request.action) as SupportedActionTag],
votingThresholdDeadline: dayjs(vr.request.voteBefore).format(dateTimeFormatISO),
voteTakesEffect:
(vr.outcome.tag === 'VRO_Accepted' &&
dayjs(vr.outcome.value.effectiveAt).format(dateTimeFormatISO)) ||
dayjs(vr.completedAt).format(dateTimeFormatISO),
yourVote: computeYourVote(votes, svPartyId),
status: getVoteResultStatus(vr.outcome),
voteStats: computeVoteStats(votes),
acceptanceThreshold: votingThreshold,
} as ProposalListingData;
})
.sort((a, b) => (dayjs(a.voteTakesEffect).isAfter(dayjs(b.voteTakesEffect)) ? -1 : 1));
const voteHistory = allRequests.map(vr => {
const votes = vr.request.votes.entriesArray().map(e => e[1]);

return {
contractId: vr.request.trackingCid,
actionName: actionTagToTitle(amuletName)[getAction(vr.request.action) as SupportedActionTag],
votingThresholdDeadline: dayjs(vr.request.voteBefore).format(dateTimeFormatISO),
voteTakesEffect:
(vr.outcome.tag === 'VRO_Accepted' &&
dayjs(vr.outcome.value.effectiveAt).format(dateTimeFormatISO)) ||
dayjs(vr.completedAt).format(dateTimeFormatISO),
yourVote: computeYourVote(votes, svPartyId),
status: getVoteResultStatus(vr.outcome),
voteStats: computeVoteStats(votes),
acceptanceThreshold: votingThreshold,
} as ProposalListingData;
});

return (
<Box sx={{ p: 4 }}>
Expand Down Expand Up @@ -194,6 +191,7 @@ export const Governance: React.FC = () => {
showVoteStats
showAcceptanceThreshold
showThresholdDeadline
sortOrder="effectiveAtAsc"
/>

<ProposalListingSection
Expand All @@ -204,6 +202,7 @@ export const Governance: React.FC = () => {
showStatus
showVoteStats
showAcceptanceThreshold
sortOrder="effectiveAtDesc"
/>
</>
)}
Expand Down