diff --git a/apps/sv/frontend/src/__tests__/governance/governance-sorting.test.tsx b/apps/sv/frontend/src/__tests__/governance/governance-sorting.test.tsx new file mode 100644 index 0000000000..f334b59f08 --- /dev/null +++ b/apps/sv/frontend/src/__tests__/governance/governance-sorting.test.tsx @@ -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, + votingCloses: '2025-01-25 12:00', + createdAt: '2025-01-10 12:00', + requester: 'sv1', + }, + { + actionName: 'Action A - Earliest', + contractId: 'a' as ContractId, + votingCloses: '2025-01-15 10:00', + createdAt: '2025-01-10 12:00', + requester: 'sv1', + }, + { + actionName: 'Action B - Middle', + contractId: 'b' as ContractId, + votingCloses: '2025-01-15 18:00', + createdAt: '2025-01-10 12:00', + requester: 'sv1', + }, + ]; + + render( + + + + ); + + 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, + 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, + voteTakesEffect: 'Threshold', + votingThresholdDeadline: '2025-01-25 12:00', + voteStats: { accepted: 3, rejected: 2, 'no-vote': 3 }, + }, + { + ...baseData, + actionName: 'Dated - Earlier', + contractId: 'd1' as ContractId, + 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, + 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, + voteTakesEffect: 'Threshold', + votingThresholdDeadline: '2025-01-15 12:00', + voteStats: { accepted: 4, rejected: 1, 'no-vote': 3 }, + }, + ]; + + render( + + + + ); + + 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, + voteTakesEffect: '2025-01-10 12:00', + votingThresholdDeadline: '2025-01-05 12:00', + }, + { + ...baseData, + actionName: 'Action C - Most recent', + contractId: 'c' as ContractId, + voteTakesEffect: '2025-01-20 18:00', + votingThresholdDeadline: '2025-01-15 12:00', + }, + { + ...baseData, + actionName: 'Action D - Same day, earlier time', + contractId: 'd' as ContractId, + voteTakesEffect: '2025-01-20 10:00', + votingThresholdDeadline: '2025-01-15 12:00', + }, + { + ...baseData, + actionName: 'Action B - Middle', + contractId: 'b' as ContractId, + voteTakesEffect: '2025-01-15 12:00', + votingThresholdDeadline: '2025-01-10 12:00', + }, + ]; + + render( + + + + ); + + 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', + ]); + }); + }); +}); diff --git a/apps/sv/frontend/src/components/governance/ActionRequiredSection.tsx b/apps/sv/frontend/src/components/governance/ActionRequiredSection.tsx index eedf9d56dc..22a1b1d245 100644 --- a/apps/sv/frontend/src/components/governance/ActionRequiredSection.tsx +++ b/apps/sv/frontend/src/components/governance/ActionRequiredSection.tsx @@ -30,21 +30,26 @@ export const ActionRequiredSection: React.FC = ( ) => { 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 ( - {actionRequiredRequests.length === 0 ? ( + {sortedRequests.length === 0 ? ( No Action Required items available ) : ( - actionRequiredRequests.map((ar, index) => ( + sortedRequests.map((ar, index) => ( 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 = props => { const { sectionTitle, @@ -45,8 +74,11 @@ export const ProposalListingSection: React.FC = pro showVoteStats, showAcceptanceThreshold, showStatus, + sortOrder, } = props; + const sortedData = sortProposals(data, sortOrder); + const columnsCount = getColumnsCount( 3, showThresholdDeadline, @@ -59,7 +91,7 @@ export const ProposalListingSection: React.FC = pro - {data.length === 0 ? ( + {sortedData.length === 0 ? ( ) : ( @@ -79,7 +111,7 @@ export const ProposalListingSection: React.FC = pro - {data.map((vote, index) => ( + {sortedData.map((vote, index) => ( { 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 ( @@ -194,6 +191,7 @@ export const Governance: React.FC = () => { showVoteStats showAcceptanceThreshold showThresholdDeadline + sortOrder="effectiveAtAsc" /> { showStatus showVoteStats showAcceptanceThreshold + sortOrder="effectiveAtDesc" /> )}