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.
) : (
-
-
-
- Precinct |
- Ballot Style |
- {election.type === 'primary' && Party | }
- |
-
-
-
- {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) => (
-
- {precinct.name} |
- {ballotStyle.id} |
- {election.type === 'primary' && (
-
+
+
+
+
+ {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
-
- |
-
- ));
+ electionId,
+ finalizedAt: new Date(),
+ },
+ { onSuccess: () => setIsConfirmingFinalize(false) }
+ )
+ }
+ variant="primary"
+ >
+ Finalize Ballots
+
+
+
}
+ onOverlayClick={
+ /* istanbul ignore next - manually tested */
+ () => setIsConfirmingFinalize(false)
+ }
+ />
+ )}
- const precinctRow = (
-
- {precinct.name} |
- |
- {election.type === 'primary' && | }
- |
-
- );
+
+
+
+ Precinct |
+ Ballot Style |
+ {election.type === 'primary' && Party | }
+ |
+
+
+
+ {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) => (
+
+ {precinct.name} |
+ {ballotStyle.id} |
+ {election.type === 'primary' && (
+
+ {
+ assertDefined(
+ getPartyForBallotStyle({
+ election,
+ ballotStyleId: ballotStyle.id,
+ })
+ ).fullName
+ }
+ |
+ )}
+
+
+ View Ballot
+
+ |
+
+ ));
+ }
- const splitRows = precinct.splits.flatMap((split) => {
- const splitBallotStyles = ballotStyles.filter((ballotStyle) =>
- ballotStyle.precinctsOrSplits.some(
- ({ precinctId, splitId }) =>
- precinctId === precinct.id && splitId === split.id
- )
+ const precinctRow = (
+
+ {precinct.name} |
+ |
+ {election.type === 'primary' && | }
+ |
+
);
- return splitBallotStyles.map((ballotStyle) => (
-
- {split.name} |
- {ballotStyle.id} |
- {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) => (
+
+ {split.name} |
+ {ballotStyle.id} |
+ {election.type === 'primary' && (
+
+ {
+ getPartyForBallotStyle({
+ election,
+ ballotStyleId: ballotStyle.id,
+ })?.name
+ }
+ |
+ )}
- {
- getPartyForBallotStyle({
- election,
- ballotStyleId: ballotStyle.id,
- })?.name
- }
+
+ View Ballot
+
|
- )}
-
-
- View Ballot
-
- |
-
- ));
- });
+
+ ));
+ });
- return [precinctRow, ...splitRows];
- })}
-
-
+ return [precinctRow, ...splitRows];
+ })}
+
+
+
)}
);