Skip to content

Commit

Permalink
VxDesign: Button to finalize ballots (#5820)
Browse files Browse the repository at this point in the history
  • Loading branch information
jonahkagan authored Jan 16, 2025
1 parent 83029c6 commit 7385b02
Show file tree
Hide file tree
Showing 7 changed files with 343 additions and 95 deletions.
3 changes: 2 additions & 1 deletion apps/design/backend/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
22 changes: 22 additions & 0 deletions apps/design/backend/src/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
11 changes: 11 additions & 0 deletions apps/design/backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,17 @@ function buildApi({ workspace, translator }: AppContext) {
return store.deleteElection(input.electionId);
},

getBallotsFinalizedAt(input: { electionId: Id }): Promise<Date | null> {
return store.getBallotsFinalizedAt(input.electionId);
},

setBallotsFinalizedAt(input: {
electionId: Id;
finalizedAt: Date | null;
}): Promise<void> {
return store.setBallotsFinalizedAt(input);
},

async exportAllBallots(input: {
electionId: Id;
electionSerializationFormat: ElectionSerializationFormat;
Expand Down
36 changes: 36 additions & 0 deletions apps/design/backend/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,42 @@ export class Store {
);
}

async getBallotsFinalizedAt(electionId: Id): Promise<Date | null> {
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<void> {
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
//
Expand Down
26 changes: 26 additions & 0 deletions apps/design/frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
49 changes: 48 additions & 1 deletion apps/design/frontend/src/ballots_screen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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' });

Expand Down Expand Up @@ -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' });

Expand Down Expand Up @@ -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 () => {
Expand All @@ -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' });
Expand Down Expand Up @@ -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' });

Expand Down
Loading

0 comments on commit 7385b02

Please sign in to comment.