From ac093404f90ef5d2bd89548e56e4cf811cac6326 Mon Sep 17 00:00:00 2001 From: Adam Masiarek Date: Wed, 19 Aug 2026 17:19:47 -0400 Subject: [PATCH] Enforce max_rankings on the draggable RCV ballot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The draggable ranked (RCV) ballot let a voter rank any number of candidates, but server-side validation (packages/shared/src/domain_model/Ballot.ts) rejects any ranked ballot with a score above election.settings.max_rankings — so a voter could build a ballot that submit then rejected, with no hint while they were building it. The classic ranked ballot already enforces the limit by only offering that many rank columns. - Extract the classic ballot's limit resolution (max_rankings capped at REACT_APP_MAX_BALLOT_RANKS, defaulting to REACT_APP_DEFAULT_BALLOT_RANKS when the setting is unset) into getMaxRankings() in components/util.tsx and use it from both ranked ballot views, so the two cannot drift. - DraggableIRVBallotView now refuses a drop into "Your Rankings" once the limit is reached (reordering within the list and removing candidates stay allowed), shows a warning Alert explaining the limit, and displays an "N of M rankings used" counter next to the "Your Rankings" heading. - New i18n strings ballot.rankings_used and ballot.max_rankings_reached in en.yaml; other locales fall back to English. - Move RankedBallotView's draggable early-return below its hooks. It returned before them, so paging from a draggable IRV race to another ranked race in the same election changed the hook count between renders and crashed with "Rendered more hooks than during the previous render" (white page). Verified manually with a local dev server against the production API (election cxrf8v, with the limit temporarily lowered to exercise the refusal): drops beyond the limit are refused with the alert shown, the counter tracks, reorder/remove still work at the limit, and paging between the draggable IRV race and the Ranked Robin race no longer crashes. npx tsc --noEmit passes; eslint reports no new issues in the touched files. Co-Authored-By: Claude Fable 5 --- .../Voting/DraggableIRVBallotView.tsx | 35 +++++++++++++++++-- .../Election/Voting/RankedBallotView.tsx | 27 ++++++-------- packages/frontend/src/components/util.tsx | 14 ++++++++ packages/frontend/src/i18n/en.yaml | 2 ++ 4 files changed, 60 insertions(+), 18 deletions(-) diff --git a/packages/frontend/src/components/Election/Voting/DraggableIRVBallotView.tsx b/packages/frontend/src/components/Election/Voting/DraggableIRVBallotView.tsx index 434af77b3..326c1a3c2 100644 --- a/packages/frontend/src/components/Election/Voting/DraggableIRVBallotView.tsx +++ b/packages/frontend/src/components/Election/Voting/DraggableIRVBallotView.tsx @@ -1,8 +1,8 @@ import React, { useContext, useMemo, useState } from 'react'; -import { Box, Paper, Typography, FormGroup, FormControlLabel, Checkbox } from '@mui/material'; +import { Alert, Box, Paper, Typography, FormGroup, FormControlLabel, Checkbox } from '@mui/material'; import { DragDropContext, Droppable, Draggable, DropResult } from '@hello-pangea/dnd'; import { BallotContext } from './VotePage'; -import { useSubstitutedTranslation } from '~/components/util'; +import { getMaxRankings, useSubstitutedTranslation } from '~/components/util'; import useElection from '../../ElectionContextProvider'; import { BallotCandidate } from './VotePage'; import { FormattedDescription } from '~/components/FormattedDescription'; @@ -56,6 +56,20 @@ export default function DraggableIRVBallotView() { const rankedIds = useMemo(() => rankedCandidates.map(c => c.candidate_id.toString()), [rankedCandidates]); + // The ranking limit the server validates against (shared/src/domain_model/Ballot.ts + // rejects scores above max_rankings) — resolved exactly like the classic ranked + // ballot resolves its number of rank columns, and never more than the number of + // candidates on this ballot. + const maxRankings = useMemo( + () => Math.min(getMaxRankings(ballotContext.maxRankings), ballotContext.candidates.length), + [ballotContext.maxRankings, ballotContext.candidates.length] + ); + + // Set when a drop was refused because the ranking limit was reached; the alert + // hides itself again once the voter removes a candidate from their rankings. + const [limitHit, setLimitHit] = useState(false); + const showLimitAlert = limitHit && rankedCandidates.length >= maxRankings; + // Track display order of unranked candidates independently so reordering is preserved const [unrankedOrder, setUnrankedOrder] = useState(() => ballotContext.candidates @@ -83,6 +97,15 @@ export default function DraggableIRVBallotView() { const to = destination.droppableId; const id = draggableId; + // Refuse a drop that would rank more candidates than the election allows — + // the server would reject the ballot (Ballot.ts caps scores at max_rankings). + // Reordering within the ranked list stays allowed. + if (to === 'ranked' && from !== 'ranked' && rankedIds.length >= maxRankings) { + setLimitHit(true); + return; + } + setLimitHit(false); + // update unranked order setUnrankedOrder(prev => { const next = prev.filter(x => x !== id); @@ -224,6 +247,9 @@ export default function DraggableIRVBallotView() { {t('ballot.yourRankings', 'Your Rankings')} + + {t('ballot.rankings_used', { n: rankedCandidates.length, max: maxRankings })} + {(provided, snapshot) => ( @@ -292,6 +318,11 @@ export default function DraggableIRVBallotView() { )} + {showLimitAlert && ( + + {t('ballot.max_rankings_reached', { max: maxRankings })} + + )} diff --git a/packages/frontend/src/components/Election/Voting/RankedBallotView.tsx b/packages/frontend/src/components/Election/Voting/RankedBallotView.tsx index 91debaa41..38c6d26e4 100644 --- a/packages/frontend/src/components/Election/Voting/RankedBallotView.tsx +++ b/packages/frontend/src/components/Election/Voting/RankedBallotView.tsx @@ -2,7 +2,7 @@ import { useContext, useMemo, useCallback } from 'react'; import { BallotContext } from './VotePage'; import GenericBallotView from './GenericBallotView/GenericBallotView'; import DraggableIRVBallotView from './DraggableIRVBallotView'; -import { useSubstitutedTranslation } from '~/components/util'; +import { getMaxRankings, useSubstitutedTranslation } from '~/components/util'; import useElection from '../../ElectionContextProvider'; @@ -11,12 +11,6 @@ export default function RankedBallotView({ onlyGrid = false }: { onlyGrid?: bool const { election } = useElection(); const { t } = useSubstitutedTranslation(); - // Use draggable component for IRV when draggable_ballot setting is enabled - if (ballotContext.race.voting_method === 'IRV' && election.settings.draggable_ballot && !onlyGrid) { - return ; - } - - // disabling warnings until we have a better solution, see slack convo // https://starvoting.slack.com/archives/C01EBAT283H/p1677023113477139 // if(race.voting_method == 'IRV' && scoresAreOverVote({scores: scores})){ @@ -28,15 +22,7 @@ export default function RankedBallotView({ onlyGrid = false }: { onlyGrid?: bool // ) // } - const maxRankings = useMemo(() => { - const MAX_BALLOT_RANKS = Number(process.env.REACT_APP_MAX_BALLOT_RANKS) ? Number(process.env.REACT_APP_MAX_BALLOT_RANKS) : 8; - const DEFAULT_BALLOT_RANKS = Number(process.env.REACT_APP_DEFAULT_BALLOT_RANKS) ? Number(process.env.REACT_APP_DEFAULT_BALLOT_RANKS) : 6; - if (ballotContext.maxRankings) { - return Math.min(ballotContext.maxRankings, MAX_BALLOT_RANKS); - } else { - return DEFAULT_BALLOT_RANKS; - } - }, [ballotContext.maxRankings]); + const maxRankings = useMemo(() => getMaxRankings(ballotContext.maxRankings), [ballotContext.maxRankings]); const findSkippedColumns = useCallback((scores: number[]): number[] | undefined => { const skippedColumns: number[] = []; for (let i = 1; i <= maxRankings; i++) { @@ -108,6 +94,15 @@ export default function RankedBallotView({ onlyGrid = false }: { onlyGrid?: bool return columnValues.map(v => t('number.rank', { count: v, ordinal: true })); }, [columnValues, t]); + // Use draggable component for IRV when draggable_ballot setting is enabled. + // NOTE: this early return must come after all the hooks above — when a voter + // pages between a draggable IRV race and another ranked race in the same + // election, this component re-renders with the other race, and returning + // before the hooks would change the hook count between renders and crash. + if (ballotContext.race.voting_method === 'IRV' && election.settings.draggable_ballot && !onlyGrid) { + return ; + } + return ( { + const MAX_BALLOT_RANKS = Number(process.env.REACT_APP_MAX_BALLOT_RANKS) ? Number(process.env.REACT_APP_MAX_BALLOT_RANKS) : 8; + const DEFAULT_BALLOT_RANKS = Number(process.env.REACT_APP_DEFAULT_BALLOT_RANKS) ? Number(process.env.REACT_APP_DEFAULT_BALLOT_RANKS) : 6; + if (maxRankingsSetting) { + return Math.min(maxRankingsSetting, MAX_BALLOT_RANKS); + } + return DEFAULT_BALLOT_RANKS; +} + export const formatPercent = (f: number): string => { if(0 < f && f < .01) return '<1%'; return `${Math.round(100*f)}%` diff --git a/packages/frontend/src/i18n/en.yaml b/packages/frontend/src/i18n/en.yaml index 06de872f0..ec00ff03d 100644 --- a/packages/frontend/src/i18n/en.yaml +++ b/packages/frontend/src/i18n/en.yaml @@ -53,6 +53,8 @@ ballot: no_available: No available candidates instructions: Drag candidates from the left to the right list. Candidates on the right are your ranked order; left means unranked. instructions_rcv_draggable: Drag candidates left → right. Rank 1 is your top choice. Unranked means no preference. + rankings_used: '{{n}} of {{max}} rankings used' + max_rankings_reached: You can rank up to {{max}} candidates on this ballot. To rank this candidate, first drag another one out of your rankings. dialog_submit_title: Submit {{capital_ballot}}? dialog_send_receipt: Send Ballot Receipt Email?