diff --git a/apps/sv/frontend/src/__tests__/governance/action-required-section.test.tsx b/apps/sv/frontend/src/__tests__/governance/action-required-section.test.tsx index 8a72cf3096..b5bd2e3e19 100644 --- a/apps/sv/frontend/src/__tests__/governance/action-required-section.test.tsx +++ b/apps/sv/frontend/src/__tests__/governance/action-required-section.test.tsx @@ -11,24 +11,27 @@ import { VoteRequest } from '@daml.js/splice-dso-governance/lib/Splice/DsoRules' import { MemoryRouter } from 'react-router'; import dayjs from 'dayjs'; import { dateTimeFormatISO } from '@canton-network/splice-common-frontend-utils'; +import { svPartyId, voteRequests } from '../mocks/constants'; + +const sampleContractId = voteRequests.dso_rules_vote_requests[0] + .contract_id as ContractId; const requests: ActionRequiredData[] = [ { actionName: 'Feature Application', description: 'Test description for feature application', - contractId: '2abcde123456' as ContractId, + contractId: sampleContractId, votingCloses: '2024-09-25 11:00', createdAt: '2024-09-25 11:00', - requester: 'sv1', + requester: svPartyId, }, { actionName: 'Set DSO Rules Configuration', description: 'Test description for DSO rules configuration', - contractId: '2bcde123456' as ContractId, + contractId: voteRequests.dso_rules_vote_requests[1].contract_id as ContractId, votingCloses: '2024-09-25 11:00', createdAt: '2024-09-25 11:00', - requester: 'sv2', - isYou: true, + requester: svPartyId, }, ]; @@ -76,10 +79,10 @@ describe('Action Required', () => { const actionRequired = { actionName: 'Feature Application', description: 'Test description', - contractId: '2abcde123456' as ContractId, + contractId: sampleContractId, votingCloses: closesDate, createdAt: createdDate, - requester: 'sv1', + requester: svPartyId, }; render( @@ -104,23 +107,22 @@ describe('Action Required', () => { expect(votingCloses).toBeInTheDocument(); expect(votingCloses.textContent).toBe('10 days'); - const requester = screen.getByTestId('action-required-requester-identifier-value'); - expect(requester).toBeInTheDocument(); - expect(requester.textContent).toBe(actionRequired.requester); + const submittedBy = screen.getByTestId('action-required-submitted-by-identifier-value'); + expect(submittedBy).toBeInTheDocument(); + expect(submittedBy.textContent).toBe(svPartyId); const viewDetails = screen.getByTestId('action-required-view-details'); expect(viewDetails).toBeInTheDocument(); }); - test('should render isYou badge for requests created by viewing sv', () => { + test('should render submitted by with copy button and no You badge', () => { const actionRequired = { actionName: 'Feature Application', description: 'Test description', - contractId: '2abcde123456' as ContractId, + contractId: sampleContractId, votingCloses: '2029-09-25 11:00', createdAt: '2029-09-25 11:00', - requester: 'sv1', - isYou: true, + requester: svPartyId, }; render( @@ -129,18 +131,22 @@ describe('Action Required', () => { ); - const isYou = screen.getByTestId('action-required-requester-identifier-badge'); - expect(isYou).toBeInTheDocument(); + expect( + screen.getByTestId('action-required-submitted-by-identifier-copy-button') + ).toBeInTheDocument(); + expect( + screen.queryByTestId('action-required-submitted-by-identifier-badge') + ).not.toBeInTheDocument(); }); - test('should not render isYou badge for requests created by other svs', () => { + test('should render vote proposal contract id with full value', () => { const actionRequired = { actionName: 'Feature Application', description: 'Test description', - contractId: '2abcde123456' as ContractId, + contractId: sampleContractId, votingCloses: '2029-09-25 11:00', createdAt: '2029-09-25 11:00', - requester: 'sv1', + requester: svPartyId, }; render( @@ -149,8 +155,8 @@ describe('Action Required', () => { ); - const isYou = screen.queryByTestId('action-required-requester-identifier-badge'); - - expect(isYou).not.toBeInTheDocument(); + expect(screen.getByTestId('action-required-contract-id-value').textContent).toBe( + sampleContractId + ); }); }); 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 9ff8bb2236..fe1307b030 100644 --- a/apps/sv/frontend/src/__tests__/governance/governance-sorting.test.tsx +++ b/apps/sv/frontend/src/__tests__/governance/governance-sorting.test.tsx @@ -11,6 +11,7 @@ import { } from '../../components/governance/ActionRequiredSection'; import { ProposalListingSection } from '../../components/governance/ProposalListingSection'; import { ProposalListingData } from '../../utils/types'; +import { svPartyId } from '../mocks/constants'; describe('Governance Page Sorting', () => { describe('Action Required Section', () => { @@ -22,7 +23,7 @@ describe('Governance Page Sorting', () => { contractId: 'c' as ContractId, votingCloses: '2025-01-25 12:00', createdAt: '2025-01-10 12:00', - requester: 'sv1', + requester: svPartyId, }, { actionName: 'Action A - Earliest', @@ -30,7 +31,7 @@ describe('Governance Page Sorting', () => { contractId: 'a' as ContractId, votingCloses: '2025-01-15 10:00', createdAt: '2025-01-10 12:00', - requester: 'sv1', + requester: svPartyId, }, { actionName: 'Action B - Middle', @@ -38,7 +39,7 @@ describe('Governance Page Sorting', () => { contractId: 'b' as ContractId, votingCloses: '2025-01-15 18:00', createdAt: '2025-01-10 12:00', - requester: 'sv1', + requester: svPartyId, }, ]; @@ -69,6 +70,7 @@ describe('Governance Page Sorting', () => { yourVote: 'accepted', status: 'In Progress', acceptanceThreshold: BigInt(11), + requester: svPartyId, }; test('should sort with Threshold items first (by votes desc, then deadline asc), then dated items by effective date asc', () => { @@ -153,6 +155,7 @@ describe('Governance Page Sorting', () => { status: 'Implemented', voteStats: { accepted: 8, rejected: 2, 'no-vote': 1 }, acceptanceThreshold: BigInt(11), + requester: svPartyId, }; test('renders in backend order without client re-sorting', () => { diff --git a/apps/sv/frontend/src/__tests__/governance/proposal-listing.test.tsx b/apps/sv/frontend/src/__tests__/governance/proposal-listing.test.tsx index 98eaac5705..21fe59d81b 100644 --- a/apps/sv/frontend/src/__tests__/governance/proposal-listing.test.tsx +++ b/apps/sv/frontend/src/__tests__/governance/proposal-listing.test.tsx @@ -7,11 +7,16 @@ import { VoteRequest } from '@daml.js/splice-dso-governance/lib/Splice/DsoRules' import { ContractId } from '@daml/types'; import { ProposalListingData } from '../../utils/types'; import { MemoryRouter } from 'react-router'; +import { svPartyId, voteRequests } from '../mocks/constants'; + +const sampleContractId = voteRequests.dso_rules_vote_requests[0] + .contract_id as ContractId; const inflightVoteRequests: ProposalListingData[] = [ { actionName: 'Feature Application', - contractId: '2abcde123456' as ContractId, + contractId: sampleContractId, + requester: svPartyId, votingThresholdDeadline: '2025-09-25 11:00', voteTakesEffect: '2025-09-26 11:00', yourVote: 'no-vote', @@ -21,7 +26,8 @@ const inflightVoteRequests: ProposalListingData[] = [ }, { actionName: 'Set DSO Rules Configuration', - contractId: 'bcde123456' as ContractId, + contractId: voteRequests.dso_rules_vote_requests[1].contract_id as ContractId, + requester: svPartyId, votingThresholdDeadline: '2025-09-25 11:00', voteTakesEffect: '2025-09-26 11:00', yourVote: 'accepted', @@ -34,7 +40,8 @@ const inflightVoteRequests: ProposalListingData[] = [ const voteHistory: ProposalListingData[] = [ { actionName: 'Feature Application', - contractId: '2abcde123456' as ContractId, + contractId: sampleContractId, + requester: svPartyId, votingThresholdDeadline: '2025-09-25 11:00', voteTakesEffect: '2025-09-26 11:00', yourVote: 'no-vote', @@ -44,7 +51,8 @@ const voteHistory: ProposalListingData[] = [ }, { actionName: 'Set DSO Rules Configuration', - contractId: '2bcde123456' as ContractId, + contractId: voteRequests.dso_rules_vote_requests[1].contract_id as ContractId, + requester: svPartyId, votingThresholdDeadline: '2025-09-25 11:00', voteTakesEffect: '2025-09-26 11:00', yourVote: 'accepted', @@ -93,6 +101,8 @@ describe('Inflight Vote Requests', () => { const uniqueId = 'proposals-request'; const data = { actionName: 'Feature Application', + contractId: sampleContractId, + requester: svPartyId, votingThresholdDeadline: '2025-09-25 11:00', voteTakesEffect: '2025-09-26 11:00', yourVote: 'no-vote', @@ -136,10 +146,49 @@ describe('Inflight Vote Requests', () => { expect(yourVote.textContent).toMatch(/No Vote/); }); + test('should render submitted by column with full party id and copy button', () => { + const uniqueId = 'proposals-request'; + const data = { + actionName: 'Feature Application', + contractId: sampleContractId, + requester: svPartyId, + votingThresholdDeadline: '2025-09-25 11:00', + voteTakesEffect: '2025-09-26 11:00', + yourVote: 'accepted', + status: 'In Progress', + voteStats: { accepted: 0, rejected: 0, 'no-vote': 0 }, + acceptanceThreshold: BigInt(11), + } as ProposalListingData; + + render( + + + + ); + + expect(screen.getByText('SUBMITTED BY')).toBeInTheDocument(); + expect(screen.getByTestId(`${uniqueId}-row-submitted-by-identifier-value`).textContent).toBe( + svPartyId + ); + expect( + screen.getByTestId(`${uniqueId}-row-submitted-by-identifier-copy-button`) + ).toBeInTheDocument(); + expect( + screen.queryByTestId(`${uniqueId}-row-submitted-by-identifier-badge`) + ).not.toBeInTheDocument(); + }); + test('should render Accepted inflight vote request', () => { const uniqueId = 'proposals-request'; const data = { actionName: 'Feature Application', + contractId: sampleContractId, + requester: svPartyId, votingThresholdDeadline: '2025-09-25 11:00', voteTakesEffect: '2025-09-26 11:00', yourVote: 'accepted', @@ -171,6 +220,8 @@ describe('Inflight Vote Requests', () => { const uniqueId = 'proposals-request'; const data = { actionName: 'Feature Application', + contractId: sampleContractId, + requester: svPartyId, votingThresholdDeadline: '2025-09-25 11:00', voteTakesEffect: '2025-09-26 11:00', yourVote: 'rejected', @@ -255,6 +306,8 @@ describe('Vote history', () => { const uniqueId = 'vote-history'; const data = { actionName: 'Feature Application', + contractId: sampleContractId, + requester: svPartyId, votingThresholdDeadline: '2024-09-25 11:00', voteTakesEffect: '2024-09-26 11:00', yourVote: 'accepted', diff --git a/apps/sv/frontend/src/components/governance/ActionRequiredSection.tsx b/apps/sv/frontend/src/components/governance/ActionRequiredSection.tsx index 36e338bf49..1f97498ec8 100644 --- a/apps/sv/frontend/src/components/governance/ActionRequiredSection.tsx +++ b/apps/sv/frontend/src/components/governance/ActionRequiredSection.tsx @@ -5,7 +5,7 @@ import { ContractId } from '@daml/types'; import { East } from '@mui/icons-material'; import { Alert, Box, Stack, Typography } from '@mui/material'; import { Link as RouterLink } from 'react-router'; -import { CopyableIdentifier, MemberIdentifier, PageSectionHeader } from '../../components/beta'; +import { CopyableIdentifier, PageSectionHeader } from '../../components/beta'; import React from 'react'; import dayjs from 'dayjs'; import relativeTime from 'dayjs/plugin/relativeTime'; @@ -19,7 +19,6 @@ export interface ActionRequiredData { votingCloses: string; createdAt: string; requester: string; - isYou?: boolean; } export interface ActionRequiredProps { @@ -60,7 +59,6 @@ export const ActionRequiredSection: React.FC = ( contractId={ar.contractId} votingEnds={ar.votingCloses} requester={ar.requester} - isYou={ar.isYou} /> )) )} @@ -76,11 +74,13 @@ interface ActionCardProps { contractId: ContractId; votingEnds: string; requester: string; - isYou?: boolean; } +const actionRequiredGridTemplate = + 'minmax(0, 0.85fr) minmax(0, 1fr) minmax(0, 1.15fr) minmax(0, 0.75fr) minmax(0, 0.85fr) 270px auto'; + const ActionCard = (props: ActionCardProps) => { - const { action, description, createdAt, contractId, votingEnds, requester, isYou } = props; + const { action, description, createdAt, contractId, votingEnds, requester } = props; const remainingTime = dayjs(votingEnds).fromNow(true); return ( @@ -92,99 +92,87 @@ const ActionCard = (props: ActionCardProps) => { - - - - - - - {description} - - } - data-testid="action-required-description" - /> - - - - } - data-testid="action-required-contract-id-segment" - /> - - - - - - + + {description} + + } + data-testid="action-required-description" + /> + - - - - } - data-testid="action-required-requester" + } + data-testid="action-required-contract-id-segment" + /> + + + - - - - View Details - - - + } + data-testid="action-required-submitted-by" + /> + + + View Details + + @@ -202,14 +190,24 @@ const ActionCardSegment: React.FC = ({ content, 'data-testid': testId, }) => ( - + {title} @@ -220,13 +218,13 @@ const ActionCardSegment: React.FC = ({ color="text.light" fontWeight="medium" fontSize={14} - lineHeight={2} + lineHeight="26px" data-testid={`${testId}-content`} > {content} ) : ( - content + {content} )} ); diff --git a/apps/sv/frontend/src/components/governance/ProposalListingSection.tsx b/apps/sv/frontend/src/components/governance/ProposalListingSection.tsx index 8961a717df..51948f7c95 100644 --- a/apps/sv/frontend/src/components/governance/ProposalListingSection.tsx +++ b/apps/sv/frontend/src/components/governance/ProposalListingSection.tsx @@ -67,11 +67,85 @@ const sortProposals = ( .toSorted((a, b) => getEffectiveDate(a).diff(getEffectiveDate(b))); }; -const getColumnsCount = (...shown: (boolean | undefined)[]) => 4 + shown.filter(Boolean).length; +const getColumnsCount = (...shown: (boolean | undefined)[]) => 5 + shown.filter(Boolean).length; const getGridTemplate = (columnsCount: number) => `minmax(0, 1fr) minmax(0, 0.7fr) ${'1fr '.repeat(columnsCount - 2).trim()}`; +const governanceTableHeadCellSx = { + py: '10px', + px: '16px', + fontSize: 12, + fontWeight: 600, + textTransform: 'uppercase' as const, + color: '#E2E2E2', + borderBottom: 'none', + display: 'flex', + alignItems: 'center', +}; + +const governanceTableBodyCellSx = { + py: '15px', + px: '16px', + borderBottom: 'none', + display: 'flex', + alignItems: 'center', + alignSelf: 'stretch', + minWidth: 0, +}; + +interface SubmittedByCellProps { + requester: string; + uniqueId: string; +} + +const identifierCellSx = { + ...governanceTableBodyCellSx, + overflow: 'visible', +}; + +const SubmittedByCell: React.FC = ({ requester, uniqueId }) => ( + + + +); + +interface TableHeaderProps { + showThresholdDeadline?: boolean; + showStatus?: boolean; + showVoteStats?: boolean; +} + +const TableHeader: React.FC = ({ + showThresholdDeadline, + showStatus, + showVoteStats, +}) => ( + <> + PROPOSAL TYPE + VOTE PROPOSAL CONTRACT ID + {showThresholdDeadline ? ( + <> + THRESHOLD DEADLINE + SUBMITTED BY + EFFECTIVE AT + + ) : ( + <> + EFFECTIVE AT + SUBMITTED BY + {showStatus && STATUS} + + )} + {showVoteStats && VOTES} + YOUR VOTE + +); + export const ProposalListingSection: React.FC = props => { const { sectionTitle, @@ -121,13 +195,11 @@ export const ProposalListingSection: React.FC = pro - ACTION - VOTE PROPOSAL CONTRACT ID - {showThresholdDeadline && THRESHOLD DEADLINE} - EFFECTIVE AT - {showStatus && STATUS} - {showVoteStats && VOTES} - YOUR VOTE + @@ -137,6 +209,7 @@ export const ProposalListingSection: React.FC = pro actionName={vote.actionName} description={vote.description} contractId={vote.contractId} + requester={vote.requester} uniqueId={uniqueId} votingThresholdDeadline={vote.votingThresholdDeadline} voteTakesEffect={vote.voteTakesEffect} @@ -226,6 +299,7 @@ interface VoteRowProps { actionName: string; description?: string; contractId: ContractId; + requester: string; status: ProposalListingStatus; uniqueId: string; voteStats: Record; @@ -243,6 +317,7 @@ const VoteRow: React.FC = React.memo(props => { actionName, description, contractId, + requester, status, uniqueId, voteStats, @@ -272,10 +347,24 @@ const VoteRow: React.FC = React.memo(props => { }} data-testid={`${uniqueId}-row`} > - + {actionName} @@ -283,6 +372,7 @@ const VoteRow: React.FC = React.memo(props => { = React.memo(props => { WebkitBoxOrient: 'vertical', overflow: 'hidden', textOverflow: 'ellipsis', - lineHeight: 1.4, + lineHeight: '20px', }} > {description} )} - + - {showThresholdDeadline && ( - - {votingThresholdDeadline} - - )} - - {voteTakesEffect} - - - {showStatus && ( - - {status} - + {showThresholdDeadline ? ( + <> + + {votingThresholdDeadline} + + + + {voteTakesEffect} + + + ) : ( + <> + + {voteTakesEffect} + + + {showStatus && ( + + {status} + + )} + )} {showVoteStats && ( - + = React.memo(props => { /> )} - + { const svPartyId = dsoInfosQuery.data?.svPartyId; const votingThreshold = dsoInfosQuery.data?.votingThreshold; + const svs = dsoInfosQuery.data?.dsoRules.payload.svs; const alreadyVotedRequestIds: Set> = useMemo(() => { return svPartyId && votesQuery.data ? new Set(votesQuery.data.filter(v => v.voter === svPartyId).map(v => v.requestCid)) @@ -81,7 +83,7 @@ export const Governance: React.FC = () => { const votes = vr.request.votes.entriesArray().map(e => e[1]); return { - contractId: vr.request.trackingCid, + contractId: (vr.request.trackingCid ?? '') as ContractId, actionName: actionTagToTitle(amuletName)[getAction(vr.request.action) as SupportedActionTag], description: vr.request.reason.body, @@ -94,9 +96,10 @@ export const Governance: React.FC = () => { status: getVoteResultStatus(vr.outcome), voteStats: computeVoteStats(votes), acceptanceThreshold: votingThreshold, + requester: getRequesterPartyId(vr.request.requester, svs), } as ProposalListingData; }); - }, [voteResultsInfiniteQuery.data?.pages, amuletName, svPartyId, votingThreshold]); + }, [voteResultsInfiniteQuery.data?.pages, amuletName, svPartyId, votingThreshold, svs]); if ( dsoInfosQuery.isPending || @@ -128,8 +131,7 @@ export const Governance: React.FC = () => { description: vr.payload.reason.body, votingCloses: dayjs(vr.payload.voteBefore).format(dateTimeFormatISO), createdAt: dayjs(vr.createdAt).format(dateTimeFormatISO), - requester: vr.payload.requester, - isYou: vr.payload.requester === svPartyId, + requester: getRequesterPartyId(vr.payload.requester, svs), } as ActionRequiredData; }); @@ -152,6 +154,7 @@ export const Governance: React.FC = () => { status: 'In Progress', voteStats: computeVoteStats(votes), acceptanceThreshold: dsoInfosQuery.data.votingThreshold, + requester: getRequesterPartyId(v.payload.requester, svs), } as ProposalListingData; }); diff --git a/apps/sv/frontend/src/utils/governance.ts b/apps/sv/frontend/src/utils/governance.ts index 9432ee4312..dda3b6ef59 100644 --- a/apps/sv/frontend/src/utils/governance.ts +++ b/apps/sv/frontend/src/utils/governance.ts @@ -101,6 +101,20 @@ export function computeVoteStats(votes: Vote[]): { ); } +export function getRequesterPartyId( + requester: string, + svs: { entriesArray(): [string, SvInfo][] } | undefined +): string { + if (requester.includes('::')) { + return requester; + } + if (!svs) { + return requester; + } + const match = svs.entriesArray().find(([, info]) => info.name === requester); + return match?.[0] ?? requester; +} + export function computeYourVote(votes: Vote[], svPartyId: string | undefined): YourVoteStatus { if (svPartyId === undefined) { return 'no-vote'; diff --git a/apps/sv/frontend/src/utils/types.ts b/apps/sv/frontend/src/utils/types.ts index 09a4460252..cb954afc13 100644 --- a/apps/sv/frontend/src/utils/types.ts +++ b/apps/sv/frontend/src/utils/types.ts @@ -148,6 +148,7 @@ export interface ProposalListingData { contractId: ContractId; actionName: string; description?: string; + requester: string; votingThresholdDeadline: string; voteTakesEffect: string; yourVote: YourVoteStatus;