Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<string[]>(() =>
ballotContext.candidates
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -224,6 +247,9 @@ export default function DraggableIRVBallotView() {
<Box sx={{ minWidth: 0 }}>
<Typography variant="h6" gutterBottom>
{t('ballot.yourRankings', 'Your Rankings')}
<Typography component="span" variant="body2" sx={{ color: 'text.secondary', ml: 1 }}>
{t('ballot.rankings_used', { n: rankedCandidates.length, max: maxRankings })}
</Typography>
</Typography>
<Droppable droppableId="ranked">
{(provided, snapshot) => (
Expand Down Expand Up @@ -292,6 +318,11 @@ export default function DraggableIRVBallotView() {
</Paper>
)}
</Droppable>
{showLimitAlert && (
<Alert severity="warning" sx={{ mt: 1 }}>
{t('ballot.max_rankings_reached', { max: maxRankings })}
</Alert>
)}
</Box>
</Box>
</Box>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';


Expand All @@ -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 <DraggableIRVBallotView />;
}


// 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})){
Expand All @@ -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++) {
Expand Down Expand Up @@ -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 <DraggableIRVBallotView />;
}

return (
<GenericBallotView
key="rankedBallot"
Expand Down
14 changes: 14 additions & 0 deletions packages/frontend/src/components/util.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,20 @@ export function hashString(inputString: string) {
return createHash('sha256').update(inputString).digest('hex')
}

// Resolves the number of rankings a voter may use on a ranked ballot.
// Mirrors the semantics the server validates against (shared/src/domain_model/Ballot.ts
// rejects scores above election.settings.max_rankings): the election's max_rankings
// setting capped at REACT_APP_MAX_BALLOT_RANKS (default 8), falling back to
// REACT_APP_DEFAULT_BALLOT_RANKS (default 6) when the setting is unset.
export const getMaxRankings = (maxRankingsSetting?: number): number => {
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)}%`
Expand Down
2 changes: 2 additions & 0 deletions packages/frontend/src/i18n/en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
Loading