diff --git a/apps/design/backend/schema.sql b/apps/design/backend/schema.sql index 9aee160256..25a19cffdd 100644 --- a/apps/design/backend/schema.sql +++ b/apps/design/backend/schema.sql @@ -16,7 +16,8 @@ create table elections ( created_at timestamp not null default current_timestamp, election_package_task_id text constraint fk_background_tasks references background_tasks(id) on delete set null, - election_package_url text + election_package_url text, + ballots_finalized_at timestamptz ); create table translation_cache ( diff --git a/apps/design/backend/src/app.test.ts b/apps/design/backend/src/app.test.ts index 94071e11be..721f1cfa58 100644 --- a/apps/design/backend/src/app.test.ts +++ b/apps/design/backend/src/app.test.ts @@ -295,6 +295,28 @@ test('Update system settings', async () => { }); }); +test('Finalize ballots', async () => { + const { apiClient } = await setupApp(); + const electionId = ( + await apiClient.loadElection({ + electionData: electionFamousNames2021Fixtures.electionJson.asText(), + }) + ).unsafeUnwrap(); + + expect(await apiClient.getBallotsFinalizedAt({ electionId })).toEqual(null); + + const finalizedAt = new Date(); + await apiClient.setBallotsFinalizedAt({ electionId, finalizedAt }); + + expect(await apiClient.getBallotsFinalizedAt({ electionId })).toEqual( + finalizedAt + ); + + await apiClient.setBallotsFinalizedAt({ electionId, finalizedAt: null }); + + expect(await apiClient.getBallotsFinalizedAt({ electionId })).toEqual(null); +}); + test('Election package management', async () => { const baseElectionDefinition = electionFamousNames2021Fixtures.readElectionDefinition(); diff --git a/apps/design/backend/src/app.ts b/apps/design/backend/src/app.ts index f228415fea..e2dc6b6c0e 100644 --- a/apps/design/backend/src/app.ts +++ b/apps/design/backend/src/app.ts @@ -177,6 +177,17 @@ function buildApi({ workspace, translator }: AppContext) { return store.deleteElection(input.electionId); }, + getBallotsFinalizedAt(input: { electionId: Id }): Promise { + return store.getBallotsFinalizedAt(input.electionId); + }, + + setBallotsFinalizedAt(input: { + electionId: Id; + finalizedAt: Date | null; + }): Promise { + return store.setBallotsFinalizedAt(input); + }, + async exportAllBallots(input: { electionId: Id; electionSerializationFormat: ElectionSerializationFormat; diff --git a/apps/design/backend/src/store.ts b/apps/design/backend/src/store.ts index f9f883844c..28ca0c1163 100644 --- a/apps/design/backend/src/store.ts +++ b/apps/design/backend/src/store.ts @@ -360,6 +360,42 @@ export class Store { ); } + async getBallotsFinalizedAt(electionId: Id): Promise { + const { ballots_finalized_at: ballotsFinalizedAt } = ( + await this.db.withClient((client) => + client.query( + ` + select ballots_finalized_at + from elections + where id = $1 + `, + electionId + ) + ) + ).rows[0]; + return ballotsFinalizedAt; + } + + async setBallotsFinalizedAt({ + electionId, + finalizedAt, + }: { + electionId: Id; + finalizedAt: Date | null; + }): Promise { + await this.db.withClient((client) => + client.query( + ` + update elections + set ballots_finalized_at = $1 + where id = $2 + `, + finalizedAt ? finalizedAt.toISOString() : null, + electionId + ) + ); + } + // // Language and audio management // diff --git a/apps/design/frontend/src/api.ts b/apps/design/frontend/src/api.ts index c39403f8fa..ac0602bc58 100644 --- a/apps/design/frontend/src/api.ts +++ b/apps/design/frontend/src/api.ts @@ -150,6 +150,32 @@ export const deleteElection = { }, } as const; +export const getBallotsFinalizedAt = { + queryKey(electionId: Id): QueryKey { + return ['getBallotsFinalizedAt', electionId]; + }, + useQuery(electionId: Id) { + const apiClient = useApiClient(); + return useQuery(this.queryKey(electionId), () => + apiClient.getBallotsFinalizedAt({ electionId }) + ); + }, +} as const; + +export const setBallotsFinalizedAt = { + useMutation() { + const apiClient = useApiClient(); + const queryClient = useQueryClient(); + return useMutation(apiClient.setBallotsFinalizedAt, { + async onSuccess(_, { electionId }) { + await queryClient.invalidateQueries( + getBallotsFinalizedAt.queryKey(electionId) + ); + }, + }); + }, +} as const; + export const exportAllBallots = { useMutation() { const apiClient = useApiClient(); diff --git a/apps/design/frontend/src/ballots_screen.test.tsx b/apps/design/frontend/src/ballots_screen.test.tsx index 817ef50f7d..3f0e368834 100644 --- a/apps/design/frontend/src/ballots_screen.test.tsx +++ b/apps/design/frontend/src/ballots_screen.test.tsx @@ -7,7 +7,7 @@ import { MockApiClient, } from '../test/api_helpers'; import { generalElectionRecord, primaryElectionRecord } from '../test/fixtures'; -import { render, screen, within } from '../test/react_testing_library'; +import { render, screen, waitFor, within } from '../test/react_testing_library'; import { withRoute } from '../test/routing_helpers'; import { BallotsScreen } from './ballots_screen'; import { routes } from './routes'; @@ -43,6 +43,7 @@ describe('Ballot styles tab', () => { apiMock.getElection .expectCallWith({ electionId }) .resolves(generalElectionRecord); + apiMock.getBallotsFinalizedAt.expectCallWith({ electionId }).resolves(null); renderScreen(electionId); await screen.findByRole('heading', { name: 'Ballots' }); @@ -77,6 +78,7 @@ describe('Ballot styles tab', () => { apiMock.getElection .expectCallWith({ electionId }) .resolves(primaryElectionRecord); + apiMock.getBallotsFinalizedAt.expectCallWith({ electionId }).resolves(null); renderScreen(electionId); await screen.findByRole('heading', { name: 'Ballots' }); @@ -113,6 +115,49 @@ describe('Ballot styles tab', () => { ['Precinct 4 - Split 2', '4-F_en', 'Fish', 'View Ballot'], ]); }); + + test('Finalizing ballots', async () => { + const electionId = generalElectionRecord.election.id; + apiMock.getElection + .expectCallWith({ electionId }) + .resolves(generalElectionRecord); + apiMock.getBallotsFinalizedAt.expectCallWith({ electionId }).resolves(null); + renderScreen(electionId); + await screen.findByRole('heading', { name: 'Ballots' }); + + screen.getByRole('heading', { name: 'Ballots are Not Finalized' }); + + userEvent.click(screen.getByRole('button', { name: 'Finalize Ballots' })); + let modal = await screen.findByRole('alertdialog'); + within(modal).getByRole('heading', { name: 'Confirm Finalize Ballots' }); + userEvent.click(within(modal).getByRole('button', { name: 'Cancel' })); + await waitFor(() => + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() + ); + + jest.useFakeTimers(); + const finalizedAt = new Date(); + jest.setSystemTime(finalizedAt); + apiMock.setBallotsFinalizedAt + .expectCallWith({ electionId, finalizedAt }) + .resolves(); + apiMock.getBallotsFinalizedAt + .expectCallWith({ electionId }) + .resolves(finalizedAt); + userEvent.click(screen.getByRole('button', { name: 'Finalize Ballots' })); + modal = await screen.findByRole('alertdialog'); + userEvent.click( + within(modal).getByRole('button', { name: 'Finalize Ballots' }) + ); + await waitFor(() => + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() + ); + jest.useRealTimers(); + screen.getByRole('heading', { name: 'Ballots are Finalized' }); + expect( + screen.getByRole('button', { name: 'Finalize Ballots' }) + ).toBeDisabled(); + }); }); test('Ballot layout tab; NH-specific features', async () => { @@ -122,6 +167,7 @@ test('Ballot layout tab; NH-specific features', async () => { }; const electionId = nhElection.election.id; apiMock.getElection.expectCallWith({ electionId }).resolves(nhElection); + apiMock.getBallotsFinalizedAt.expectCallWith({ electionId }).resolves(null); renderScreen(electionId); await screen.findByRole('heading', { name: 'Ballots' }); @@ -159,6 +205,7 @@ test('Ballot layout tab', async () => { apiMock.getElection .expectCallWith({ electionId }) .resolves(generalElectionRecord); + apiMock.getBallotsFinalizedAt.expectCallWith({ electionId }).resolves(null); renderScreen(electionId); await screen.findByRole('heading', { name: 'Ballots' }); diff --git a/apps/design/frontend/src/ballots_screen.tsx b/apps/design/frontend/src/ballots_screen.tsx index 7705353112..99847a58d2 100644 --- a/apps/design/frontend/src/ballots_screen.tsx +++ b/apps/design/frontend/src/ballots_screen.tsx @@ -12,6 +12,10 @@ import { TabPanel, RouterTabBar, SegmentedButton, + H3, + Card, + Icons, + Modal, } from '@votingworks/ui'; import { Redirect, Route, Switch, useParams } from 'react-router-dom'; import { assertDefined } from '@votingworks/basics'; @@ -20,9 +24,15 @@ import { Election, getPartyForBallotStyle, } from '@votingworks/types'; -import { useState } from 'react'; -import { getElection, updateElection } from './api'; -import { Form, FormActionsRow, NestedTr } from './layout'; +import React, { useState } from 'react'; +import styled from 'styled-components'; +import { + getElection, + getBallotsFinalizedAt, + setBallotsFinalizedAt, + updateElection, +} from './api'; +import { Column, Form, FormActionsRow, NestedTr, Row } from './layout'; import { ElectionNavScreen } from './nav_screen'; import { ElectionIdParams, electionParamRoutes, routes } from './routes'; import { hasSplits } from './utils'; @@ -123,15 +133,34 @@ function BallotDesignForm({ ); } +const FinalizeBallotsCallout = styled(Card).attrs({ color: 'neutral' })` + h3 { + margin: 0 !important; + line-height: 0.8; + } +`; + +const BallotStylesTable = styled(Table)` + td:last-child { + text-align: right; + padding-right: 1rem; + } +`; + function BallotStylesTab(): JSX.Element | null { const { electionId } = useParams(); const getElectionQuery = getElection.useQuery(electionId); + const getBallotsFinalizedAtQuery = getBallotsFinalizedAt.useQuery(electionId); + const setIsBallotProofingCompleteMutation = + setBallotsFinalizedAt.useMutation(); + const [isConfirmingFinalize, setIsConfirmingFinalize] = useState(false); - if (!getElectionQuery.isSuccess) { + if (!(getElectionQuery.isSuccess && getBallotsFinalizedAtQuery.isSuccess)) { return null; } const { election, precincts, ballotStyles } = getElectionQuery.data; + const ballotsFinalizedAt = getBallotsFinalizedAtQuery.data; const ballotRoutes = routes.election(electionId).ballots; return ( @@ -142,104 +171,180 @@ function BallotStylesTab(): JSX.Element | null { created districts, precincts, and contests.

) : ( - - - - - - {election.type === 'primary' && } - - - - {precincts.flatMap((precinct) => { - if (!hasSplits(precinct)) { - const precinctBallotStyles = ballotStyles.filter( - (ballotStyle) => - ballotStyle.precinctsOrSplits.some( - ({ precinctId, splitId }) => - precinctId === precinct.id && splitId === undefined - ) - ); - return precinctBallotStyles.map((ballotStyle) => ( - - - - {election.type === 'primary' && ( - - )} - - - )); + electionId, + finalizedAt: new Date(), + }, + { onSuccess: () => setIsConfirmingFinalize(false) } + ) + } + variant="primary" + > + Finalize Ballots + + + } + onOverlayClick={ + /* istanbul ignore next - manually tested */ + () => setIsConfirmingFinalize(false) + } + /> + )} - const precinctRow = ( - - - - ); + + + + + + {election.type === 'primary' && } + + + + {precincts.flatMap((precinct) => { + if (!hasSplits(precinct)) { + const precinctBallotStyles = ballotStyles.filter( + (ballotStyle) => + ballotStyle.precinctsOrSplits.some( + ({ precinctId, splitId }) => + precinctId === precinct.id && splitId === undefined + ) + ); + return precinctBallotStyles.map((ballotStyle) => ( + + + + {election.type === 'primary' && ( + + )} + + + )); + } - const splitRows = precinct.splits.flatMap((split) => { - const splitBallotStyles = ballotStyles.filter((ballotStyle) => - ballotStyle.precinctsOrSplits.some( - ({ precinctId, splitId }) => - precinctId === precinct.id && splitId === split.id - ) + const precinctRow = ( + + + ); - return splitBallotStyles.map((ballotStyle) => ( - - - - {election.type === 'primary' && ( + const splitRows = precinct.splits.flatMap((split) => { + const splitBallotStyles = ballotStyles.filter((ballotStyle) => + ballotStyle.precinctsOrSplits.some( + ({ precinctId, splitId }) => + precinctId === precinct.id && splitId === split.id + ) + ); + + return splitBallotStyles.map((ballotStyle) => ( + + + + {election.type === 'primary' && ( + + )} - )} - - - )); - }); + + )); + }); - return [precinctRow, ...splitRows]; - })} - -
PrecinctBallot StyleParty -
{precinct.name}{ballotStyle.id} + + + + + {ballotsFinalizedAt ? ( + + +

Ballots are Finalized

+
+ ) : ( + + +
+

Ballots are Not Finalized

+
+ Proof each ballot style, then finalize ballots. +
+
+
+ )} +
+ +
+
+ {isConfirmingFinalize && ( + + Once ballots are finalized, the election may not be edited + further. +

+ } + actions={ + +
- - View Ballot - -
{precinct.name} - {election.type === 'primary' && } - -
PrecinctBallot StyleParty +
{precinct.name}{ballotStyle.id} + { + assertDefined( + getPartyForBallotStyle({ + election, + ballotStyleId: ballotStyle.id, + }) + ).fullName + } + + + View Ballot + +
{precinct.name} + {election.type === 'primary' && } + +
{split.name}{ballotStyle.id}{split.name}{ballotStyle.id} + { + getPartyForBallotStyle({ + election, + ballotStyleId: ballotStyle.id, + })?.name + } + - { - getPartyForBallotStyle({ - election, - ballotStyleId: ballotStyle.id, - })?.name - } + + View Ballot + - - View Ballot - -
+ return [precinctRow, ...splitRows]; + })} + + + )} );