From d4c69e1fe79c1172b7af1b26363ff297e6576bb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Perek?= Date: Wed, 14 Jan 2026 10:03:12 +0000 Subject: [PATCH 1/3] implement proposals sorting [ci] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Paweł Perek --- .../governance/governance-sorting.test.tsx | 357 ++++++++++++++++++ .../governance/ActionRequiredSection.tsx | 11 +- .../governance/ProposalListingSection.tsx | 38 +- apps/sv/frontend/src/routes/governance.tsx | 39 +- 4 files changed, 420 insertions(+), 25 deletions(-) create mode 100644 apps/sv/frontend/src/__tests__/governance/governance-sorting.test.tsx 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..c91d531dab --- /dev/null +++ b/apps/sv/frontend/src/__tests__/governance/governance-sorting.test.tsx @@ -0,0 +1,357 @@ +// 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 render items sorted by voting closes date ascending (closest deadline first)', () => { + // Pass data intentionally in unsorted order + const unsortedRequests: ActionRequiredData[] = [ + { + actionName: 'Action C - Latest deadline', + contractId: 'c' as ContractId, + votingCloses: '2025-01-25 12:00', + createdAt: '2025-01-10 12:00', + requester: 'sv1', + }, + { + actionName: 'Action A - Earliest deadline', + contractId: 'a' as ContractId, + votingCloses: '2025-01-15 12:00', + createdAt: '2025-01-10 12:00', + requester: 'sv1', + }, + { + actionName: 'Action B - Middle deadline', + contractId: 'b' as ContractId, + votingCloses: '2025-01-20 12:00', + createdAt: '2025-01-10 12:00', + requester: 'sv1', + }, + ]; + + render( + + + + ); + + const cards = screen.getAllByTestId('action-required-card'); + expect(cards).toHaveLength(3); + + // Verify order: earliest deadline first + const actionNames = cards.map( + card => card.querySelector('[data-testid="action-required-action-content"]')?.textContent + ); + expect(actionNames[0]).toBe('Action A - Earliest deadline'); + expect(actionNames[1]).toBe('Action B - Middle deadline'); + expect(actionNames[2]).toBe('Action C - Latest deadline'); + }); + + test('should handle same-day deadlines with different times', () => { + const unsortedRequests: ActionRequiredData[] = [ + { + actionName: 'Action B - Later time', + contractId: 'b' as ContractId, + votingCloses: '2025-01-15 18:00', + createdAt: '2025-01-10 12:00', + requester: 'sv1', + }, + { + actionName: 'Action A - Earlier time', + contractId: 'a' as ContractId, + votingCloses: '2025-01-15 10: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[0]).toBe('Action A - Earlier time'); + expect(actionNames[1]).toBe('Action B - Later time'); + }); + }); + + describe('Inflight Votes Section', () => { + const baseData: Omit< + ProposalListingData, + 'actionName' | 'contractId' | 'voteTakesEffect' | 'votingThresholdDeadline' + > = { + yourVote: 'accepted', + status: 'In Progress', + voteStats: { accepted: 5, rejected: 2, 'no-vote': 1 }, + acceptanceThreshold: BigInt(11), + }; + + test('should render items sorted by effective date ascending (closest first)', () => { + // Pass data intentionally in unsorted order + const unsortedRequests: ProposalListingData[] = [ + { + ...baseData, + actionName: 'Action C - Latest effective', + contractId: 'c' as ContractId, + voteTakesEffect: '2025-01-30 12:00', + votingThresholdDeadline: '2025-01-25 12:00', + }, + { + ...baseData, + actionName: 'Action A - Earliest effective', + contractId: 'a' as ContractId, + voteTakesEffect: '2025-01-20 12:00', + votingThresholdDeadline: '2025-01-15 12:00', + }, + { + ...baseData, + actionName: 'Action B - Middle effective', + contractId: 'b' as ContractId, + voteTakesEffect: '2025-01-25 12:00', + votingThresholdDeadline: '2025-01-20 12:00', + }, + ]; + + render( + + + + ); + + const rows = screen.getAllByTestId('inflight-votes-row'); + expect(rows).toHaveLength(3); + + const actionNames = rows.map( + row => row.querySelector('[data-testid="inflight-votes-row-action-name"]')?.textContent + ); + expect(actionNames[0]).toBe('Action A - Earliest effective'); + expect(actionNames[1]).toBe('Action B - Middle effective'); + expect(actionNames[2]).toBe('Action C - Latest effective'); + }); + + test('should place Threshold items after items with specific effective dates', () => { + const unsortedRequests: ProposalListingData[] = [ + { + ...baseData, + actionName: 'Threshold Action', + contractId: 't' as ContractId, + voteTakesEffect: 'Threshold', + votingThresholdDeadline: '2025-01-15 12:00', + }, + { + ...baseData, + actionName: 'Dated Action', + contractId: 'd' as ContractId, + voteTakesEffect: '2025-01-30 12:00', + votingThresholdDeadline: '2025-01-25 12:00', + }, + ]; + + render( + + + + ); + + const rows = screen.getAllByTestId('inflight-votes-row'); + const actionNames = rows.map( + row => row.querySelector('[data-testid="inflight-votes-row-action-name"]')?.textContent + ); + + // Dated items should come before Threshold items + expect(actionNames[0]).toBe('Dated Action'); + expect(actionNames[1]).toBe('Threshold Action'); + }); + + test('should sort multiple Threshold items by voting deadline', () => { + const unsortedRequests: ProposalListingData[] = [ + { + ...baseData, + actionName: 'Threshold C - Latest deadline', + contractId: 'c' as ContractId, + voteTakesEffect: 'Threshold', + votingThresholdDeadline: '2025-01-25 12:00', + }, + { + ...baseData, + actionName: 'Threshold A - Earliest deadline', + contractId: 'a' as ContractId, + voteTakesEffect: 'Threshold', + votingThresholdDeadline: '2025-01-15 12:00', + }, + { + ...baseData, + actionName: 'Threshold B - Middle deadline', + contractId: 'b' as ContractId, + voteTakesEffect: 'Threshold', + votingThresholdDeadline: '2025-01-20 12:00', + }, + ]; + + 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[0]).toBe('Threshold A - Earliest deadline'); + expect(actionNames[1]).toBe('Threshold B - Middle deadline'); + expect(actionNames[2]).toBe('Threshold C - Latest deadline'); + }); + }); + + 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 render items sorted by effective date descending (most recent first)', () => { + // Pass data intentionally in unsorted order + 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 12: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'); + expect(rows).toHaveLength(3); + + const actionNames = rows.map( + row => row.querySelector('[data-testid="vote-history-row-action-name"]')?.textContent + ); + expect(actionNames[0]).toBe('Action C - Most recent'); + expect(actionNames[1]).toBe('Action B - Middle'); + expect(actionNames[2]).toBe('Action A - Oldest'); + }); + + test('should handle same-day effective dates with different times', () => { + const unsortedRequests: ProposalListingData[] = [ + { + ...baseData, + actionName: 'Action A - Earlier time', + contractId: 'a' as ContractId, + voteTakesEffect: '2025-01-15 10:00', + votingThresholdDeadline: '2025-01-10 12:00', + }, + { + ...baseData, + actionName: 'Action B - Later time', + contractId: 'b' as ContractId, + voteTakesEffect: '2025-01-15 18: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 + ); + + // Later time should come first (most recent) + expect(actionNames[0]).toBe('Action B - Later time'); + expect(actionNames[1]).toBe('Action A - Earlier time'); + }); + }); +}); 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 sortProposals = ( + data: ProposalListingData[], + sortOrder?: ProposalSortOrder +): ProposalListingData[] => { + if (!sortOrder) return data; + + return data.toSorted((a, b) => { + if (sortOrder === 'effectiveAtAsc') { + // For ascending sort: items with "Threshold" (no specific effective date) go after items with dates + const aIsThreshold = a.voteTakesEffect === 'Threshold'; + const bIsThreshold = b.voteTakesEffect === 'Threshold'; + + if (aIsThreshold && bIsThreshold) { + // Both are threshold-based, sort by voting deadline + return dayjs(a.votingThresholdDeadline).isBefore(dayjs(b.votingThresholdDeadline)) ? -1 : 1; + } + if (aIsThreshold) return 1; + if (bIsThreshold) return -1; + + return dayjs(a.voteTakesEffect).isBefore(dayjs(b.voteTakesEffect)) ? -1 : 1; + } else { + // effectiveAtDesc: most recent first + return dayjs(a.voteTakesEffect).isAfter(dayjs(b.voteTakesEffect)) ? -1 : 1; + } + }); +}; + export const ProposalListingSection: React.FC = props => { const { sectionTitle, @@ -45,8 +76,11 @@ export const ProposalListingSection: React.FC = pro showVoteStats, showAcceptanceThreshold, showStatus, + sortOrder, } = props; + const sortedData = sortProposals(data, sortOrder); + const columnsCount = getColumnsCount( 3, showThresholdDeadline, @@ -59,7 +93,7 @@ export const ProposalListingSection: React.FC = pro - {data.length === 0 ? ( + {sortedData.length === 0 ? ( ) : ( @@ -79,7 +113,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" /> )} From 93690e3b51d83250e8d1e8b6eba7f6729ab4135a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Perek?= Date: Thu, 15 Jan 2026 16:13:08 +0000 Subject: [PATCH 2/3] fix threshold sorting [ci] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Paweł Perek --- .../governance/governance-sorting.test.tsx | 12 ++----- .../governance/ProposalListingSection.tsx | 32 ++++++++----------- 2 files changed, 17 insertions(+), 27 deletions(-) diff --git a/apps/sv/frontend/src/__tests__/governance/governance-sorting.test.tsx b/apps/sv/frontend/src/__tests__/governance/governance-sorting.test.tsx index c91d531dab..f7e2dc0311 100644 --- a/apps/sv/frontend/src/__tests__/governance/governance-sorting.test.tsx +++ b/apps/sv/frontend/src/__tests__/governance/governance-sorting.test.tsx @@ -15,7 +15,6 @@ import { ProposalListingData } from '../../utils/types'; describe('Governance Page Sorting', () => { describe('Action Required Section', () => { test('should render items sorted by voting closes date ascending (closest deadline first)', () => { - // Pass data intentionally in unsorted order const unsortedRequests: ActionRequiredData[] = [ { actionName: 'Action C - Latest deadline', @@ -49,7 +48,6 @@ describe('Governance Page Sorting', () => { const cards = screen.getAllByTestId('action-required-card'); expect(cards).toHaveLength(3); - // Verify order: earliest deadline first const actionNames = cards.map( card => card.querySelector('[data-testid="action-required-action-content"]')?.textContent ); @@ -103,7 +101,6 @@ describe('Governance Page Sorting', () => { }; test('should render items sorted by effective date ascending (closest first)', () => { - // Pass data intentionally in unsorted order const unsortedRequests: ProposalListingData[] = [ { ...baseData, @@ -154,7 +151,7 @@ describe('Governance Page Sorting', () => { expect(actionNames[2]).toBe('Action C - Latest effective'); }); - test('should place Threshold items after items with specific effective dates', () => { + test('should sort Threshold items by their voting deadline alongside dated items', () => { const unsortedRequests: ProposalListingData[] = [ { ...baseData, @@ -192,9 +189,8 @@ describe('Governance Page Sorting', () => { row => row.querySelector('[data-testid="inflight-votes-row-action-name"]')?.textContent ); - // Dated items should come before Threshold items - expect(actionNames[0]).toBe('Dated Action'); - expect(actionNames[1]).toBe('Threshold Action'); + expect(actionNames[0]).toBe('Threshold Action'); + expect(actionNames[1]).toBe('Dated Action'); }); test('should sort multiple Threshold items by voting deadline', () => { @@ -260,7 +256,6 @@ describe('Governance Page Sorting', () => { }; test('should render items sorted by effective date descending (most recent first)', () => { - // Pass data intentionally in unsorted order const unsortedRequests: ProposalListingData[] = [ { ...baseData, @@ -349,7 +344,6 @@ describe('Governance Page Sorting', () => { row => row.querySelector('[data-testid="vote-history-row-action-name"]')?.textContent ); - // Later time should come first (most recent) expect(actionNames[0]).toBe('Action B - Later time'); expect(actionNames[1]).toBe('Action A - Earlier time'); }); diff --git a/apps/sv/frontend/src/components/governance/ProposalListingSection.tsx b/apps/sv/frontend/src/components/governance/ProposalListingSection.tsx index b9401ce851..f0792b24f7 100644 --- a/apps/sv/frontend/src/components/governance/ProposalListingSection.tsx +++ b/apps/sv/frontend/src/components/governance/ProposalListingSection.tsx @@ -45,25 +45,21 @@ const sortProposals = ( ): ProposalListingData[] => { if (!sortOrder) return data; - return data.toSorted((a, b) => { - if (sortOrder === 'effectiveAtAsc') { - // For ascending sort: items with "Threshold" (no specific effective date) go after items with dates - const aIsThreshold = a.voteTakesEffect === 'Threshold'; - const bIsThreshold = b.voteTakesEffect === 'Threshold'; - - if (aIsThreshold && bIsThreshold) { - // Both are threshold-based, sort by voting deadline - return dayjs(a.votingThresholdDeadline).isBefore(dayjs(b.votingThresholdDeadline)) ? -1 : 1; + return data + .map(item => ({ + item, + effectiveDate: dayjs( + item.voteTakesEffect === 'Threshold' ? item.votingThresholdDeadline : item.voteTakesEffect + ), + })) + .toSorted((a, b) => { + if (sortOrder === 'effectiveAtAsc') { + return a.effectiveDate.isBefore(b.effectiveDate) ? -1 : 1; + } else { + return a.effectiveDate.isAfter(b.effectiveDate) ? -1 : 1; } - if (aIsThreshold) return 1; - if (bIsThreshold) return -1; - - return dayjs(a.voteTakesEffect).isBefore(dayjs(b.voteTakesEffect)) ? -1 : 1; - } else { - // effectiveAtDesc: most recent first - return dayjs(a.voteTakesEffect).isAfter(dayjs(b.voteTakesEffect)) ? -1 : 1; - } - }); + }) + .map(({ item }) => item); }; export const ProposalListingSection: React.FC = props => { From ac4ff1804b31952e89d920f6c4441c6e49201eec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Perek?= Date: Fri, 16 Jan 2026 17:19:14 +0000 Subject: [PATCH 3/3] fix inflight votes sorting [ci] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Paweł Perek --- .../governance/governance-sorting.test.tsx | 241 ++++-------------- .../governance/ProposalListingSection.tsx | 30 ++- 2 files changed, 69 insertions(+), 202 deletions(-) diff --git a/apps/sv/frontend/src/__tests__/governance/governance-sorting.test.tsx b/apps/sv/frontend/src/__tests__/governance/governance-sorting.test.tsx index f7e2dc0311..f334b59f08 100644 --- a/apps/sv/frontend/src/__tests__/governance/governance-sorting.test.tsx +++ b/apps/sv/frontend/src/__tests__/governance/governance-sorting.test.tsx @@ -14,64 +14,29 @@ import { ProposalListingData } from '../../utils/types'; describe('Governance Page Sorting', () => { describe('Action Required Section', () => { - test('should render items sorted by voting closes date ascending (closest deadline first)', () => { + test('should sort by voting closes date ascending (closest deadline first)', () => { const unsortedRequests: ActionRequiredData[] = [ { - actionName: 'Action C - Latest deadline', + 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 deadline', + actionName: 'Action A - Earliest', contractId: 'a' as ContractId, - votingCloses: '2025-01-15 12:00', + votingCloses: '2025-01-15 10:00', createdAt: '2025-01-10 12:00', requester: 'sv1', }, { - actionName: 'Action B - Middle deadline', - contractId: 'b' as ContractId, - votingCloses: '2025-01-20 12:00', - createdAt: '2025-01-10 12:00', - requester: 'sv1', - }, - ]; - - render( - - - - ); - - const cards = screen.getAllByTestId('action-required-card'); - expect(cards).toHaveLength(3); - - const actionNames = cards.map( - card => card.querySelector('[data-testid="action-required-action-content"]')?.textContent - ); - expect(actionNames[0]).toBe('Action A - Earliest deadline'); - expect(actionNames[1]).toBe('Action B - Middle deadline'); - expect(actionNames[2]).toBe('Action C - Latest deadline'); - }); - - test('should handle same-day deadlines with different times', () => { - const unsortedRequests: ActionRequiredData[] = [ - { - actionName: 'Action B - Later time', + actionName: 'Action B - Middle', contractId: 'b' as ContractId, votingCloses: '2025-01-15 18:00', createdAt: '2025-01-10 12:00', requester: 'sv1', }, - { - actionName: 'Action A - Earlier time', - contractId: 'a' as ContractId, - votingCloses: '2025-01-15 10:00', - createdAt: '2025-01-10 12:00', - requester: 'sv1', - }, ]; render( @@ -84,137 +49,66 @@ describe('Governance Page Sorting', () => { const actionNames = cards.map( card => card.querySelector('[data-testid="action-required-action-content"]')?.textContent ); - expect(actionNames[0]).toBe('Action A - Earlier time'); - expect(actionNames[1]).toBe('Action B - Later time'); + + expect(actionNames).toEqual([ + 'Action A - Earliest', + 'Action B - Middle', + 'Action C - Latest', + ]); }); }); describe('Inflight Votes Section', () => { const baseData: Omit< ProposalListingData, - 'actionName' | 'contractId' | 'voteTakesEffect' | 'votingThresholdDeadline' + 'actionName' | 'contractId' | 'voteTakesEffect' | 'votingThresholdDeadline' | 'voteStats' > = { yourVote: 'accepted', status: 'In Progress', - voteStats: { accepted: 5, rejected: 2, 'no-vote': 1 }, acceptanceThreshold: BigInt(11), }; - test('should render items sorted by effective date ascending (closest first)', () => { + 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: 'Action C - Latest effective', - contractId: 'c' as ContractId, + 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: 'Action A - Earliest effective', - contractId: 'a' as ContractId, - voteTakesEffect: '2025-01-20 12:00', - votingThresholdDeadline: '2025-01-15 12:00', - }, - { - ...baseData, - actionName: 'Action B - Middle effective', - contractId: 'b' as ContractId, - voteTakesEffect: '2025-01-25 12:00', - votingThresholdDeadline: '2025-01-20 12:00', - }, - ]; - - render( - - - - ); - - const rows = screen.getAllByTestId('inflight-votes-row'); - expect(rows).toHaveLength(3); - - const actionNames = rows.map( - row => row.querySelector('[data-testid="inflight-votes-row-action-name"]')?.textContent - ); - expect(actionNames[0]).toBe('Action A - Earliest effective'); - expect(actionNames[1]).toBe('Action B - Middle effective'); - expect(actionNames[2]).toBe('Action C - Latest effective'); - }); - - test('should sort Threshold items by their voting deadline alongside dated items', () => { - const unsortedRequests: ProposalListingData[] = [ - { - ...baseData, - actionName: 'Threshold Action', - contractId: 't' as ContractId, + actionName: 'Threshold - 5 votes, later deadline', + contractId: 't2' as ContractId, voteTakesEffect: 'Threshold', - votingThresholdDeadline: '2025-01-15 12:00', - }, - { - ...baseData, - actionName: 'Dated Action', - contractId: 'd' as ContractId, - voteTakesEffect: '2025-01-30 12:00', votingThresholdDeadline: '2025-01-25 12:00', + voteStats: { accepted: 3, rejected: 2, '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[0]).toBe('Threshold Action'); - expect(actionNames[1]).toBe('Dated Action'); - }); - - test('should sort multiple Threshold items by voting deadline', () => { - const unsortedRequests: ProposalListingData[] = [ { ...baseData, - actionName: 'Threshold C - Latest deadline', - contractId: 'c' as ContractId, - voteTakesEffect: 'Threshold', - votingThresholdDeadline: '2025-01-25 12:00', + 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 A - Earliest deadline', - contractId: 'a' as ContractId, + actionName: 'Threshold - 10 votes', + contractId: 't1' as ContractId, voteTakesEffect: 'Threshold', - votingThresholdDeadline: '2025-01-15 12:00', + votingThresholdDeadline: '2025-01-30 12:00', + voteStats: { accepted: 7, rejected: 3, 'no-vote': 0 }, }, { ...baseData, - actionName: 'Threshold B - Middle deadline', - contractId: 'b' as ContractId, + actionName: 'Threshold - 5 votes, earlier deadline', + contractId: 't3' as ContractId, voteTakesEffect: 'Threshold', - votingThresholdDeadline: '2025-01-20 12:00', + votingThresholdDeadline: '2025-01-15 12:00', + voteStats: { accepted: 4, rejected: 1, 'no-vote': 3 }, }, ]; @@ -238,9 +132,13 @@ describe('Governance Page Sorting', () => { row => row.querySelector('[data-testid="inflight-votes-row-action-name"]')?.textContent ); - expect(actionNames[0]).toBe('Threshold A - Earliest deadline'); - expect(actionNames[1]).toBe('Threshold B - Middle deadline'); - expect(actionNames[2]).toBe('Threshold C - Latest deadline'); + 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', + ]); }); }); @@ -255,7 +153,7 @@ describe('Governance Page Sorting', () => { acceptanceThreshold: BigInt(11), }; - test('should render items sorted by effective date descending (most recent first)', () => { + test('should sort by effective date descending (most recent first)', () => { const unsortedRequests: ProposalListingData[] = [ { ...baseData, @@ -268,58 +166,21 @@ describe('Governance Page Sorting', () => { ...baseData, actionName: 'Action C - Most recent', contractId: 'c' as ContractId, - voteTakesEffect: '2025-01-20 12:00', + voteTakesEffect: '2025-01-20 18: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'); - expect(rows).toHaveLength(3); - - const actionNames = rows.map( - row => row.querySelector('[data-testid="vote-history-row-action-name"]')?.textContent - ); - expect(actionNames[0]).toBe('Action C - Most recent'); - expect(actionNames[1]).toBe('Action B - Middle'); - expect(actionNames[2]).toBe('Action A - Oldest'); - }); - - test('should handle same-day effective dates with different times', () => { - const unsortedRequests: ProposalListingData[] = [ - { - ...baseData, - actionName: 'Action A - Earlier time', - contractId: 'a' as ContractId, - voteTakesEffect: '2025-01-15 10:00', - votingThresholdDeadline: '2025-01-10 12:00', + 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 - Later time', + actionName: 'Action B - Middle', contractId: 'b' as ContractId, - voteTakesEffect: '2025-01-15 18:00', + voteTakesEffect: '2025-01-15 12:00', votingThresholdDeadline: '2025-01-10 12:00', }, ]; @@ -344,8 +205,12 @@ describe('Governance Page Sorting', () => { row => row.querySelector('[data-testid="vote-history-row-action-name"]')?.textContent ); - expect(actionNames[0]).toBe('Action B - Later time'); - expect(actionNames[1]).toBe('Action A - Earlier time'); + 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/ProposalListingSection.tsx b/apps/sv/frontend/src/components/governance/ProposalListingSection.tsx index f0792b24f7..0da990ac40 100644 --- a/apps/sv/frontend/src/components/governance/ProposalListingSection.tsx +++ b/apps/sv/frontend/src/components/governance/ProposalListingSection.tsx @@ -39,27 +39,29 @@ const getColumnsCount = (alwaysShown: number, ...sometimesShown: (boolean | unde 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 - .map(item => ({ - item, - effectiveDate: dayjs( - item.voteTakesEffect === 'Threshold' ? item.votingThresholdDeadline : item.voteTakesEffect - ), - })) - .toSorted((a, b) => { - if (sortOrder === 'effectiveAtAsc') { - return a.effectiveDate.isBefore(b.effectiveDate) ? -1 : 1; - } else { - return a.effectiveDate.isAfter(b.effectiveDate) ? -1 : 1; - } - }) - .map(({ item }) => item); + .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 => {