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?