diff --git a/apps/admin/frontend/src/api.ts b/apps/admin/frontend/src/api.ts index d9fd0dc425..7ede1ef881 100644 --- a/apps/admin/frontend/src/api.ts +++ b/apps/admin/frontend/src/api.ts @@ -424,9 +424,16 @@ export const getCardCounts = { queryKey(input?: GetCardCountsInput): QueryKey { return input ? ['getCardCounts', input] : ['getCardCounts']; }, - useQuery(input: GetCardCountsInput = { groupBy: {} }) { + useQuery( + input: GetCardCountsInput = { groupBy: {} }, + options: { enabled: boolean } = { enabled: true } + ) { const apiClient = useApiClient(); - return useQuery(this.queryKey(input), () => apiClient.getCardCounts(input)); + return useQuery( + this.queryKey(input), + () => apiClient.getCardCounts(input), + options + ); }, } as const; @@ -674,6 +681,13 @@ export const exportTallyReportCsv = { }, } as const; +export const exportBallotCountReportCsv = { + useMutation() { + const apiClient = useApiClient(); + return useMutation(apiClient.exportBallotCountReportCsv); + }, +} as const; + export const saveBallotPackageToUsb = { useMutation() { const apiClient = useApiClient(); diff --git a/apps/admin/frontend/src/components/ballot_counts_table.test.tsx b/apps/admin/frontend/src/components/ballot_counts_table.test.tsx index 3b1186b39d..fcc71effea 100644 --- a/apps/admin/frontend/src/components/ballot_counts_table.test.tsx +++ b/apps/admin/frontend/src/components/ballot_counts_table.test.tsx @@ -51,7 +51,7 @@ describe('Ballot Counts by Precinct', () => { ]; it('renders as expected when there is no tally data', async () => { - apiMock.expectGetCardCounts( + apiMock.deprecatedExpectGetCardCounts( mockBallotCountsTableGroupBy({ groupByPrecinct: true }), [] ); @@ -86,7 +86,7 @@ describe('Ballot Counts by Precinct', () => { }); it('renders as expected when there is tally data', async () => { - apiMock.expectGetCardCounts( + apiMock.deprecatedExpectGetCardCounts( mockBallotCountsTableGroupBy({ groupByPrecinct: true }), cardCountsByPrecinct ); @@ -144,7 +144,7 @@ describe('Ballot Counts by Scanner', () => { const scannerIds = ['scanner-1', 'scanner-2']; it('renders as expected when there is no tally data', async () => { - apiMock.expectGetCardCounts( + apiMock.deprecatedExpectGetCardCounts( mockBallotCountsTableGroupBy({ groupByScanner: true }), [] ); @@ -165,7 +165,7 @@ describe('Ballot Counts by Scanner', () => { }); it('renders as expected when there is tally data', async () => { - apiMock.expectGetCardCounts( + apiMock.deprecatedExpectGetCardCounts( mockBallotCountsTableGroupBy({ groupByScanner: true }), cardCountsByScanner ); @@ -206,7 +206,7 @@ describe('Ballot Counts by Scanner', () => { }); it('renders as expected when there is tally data and manual data', async () => { - apiMock.expectGetCardCounts( + apiMock.deprecatedExpectGetCardCounts( mockBallotCountsTableGroupBy({ groupByScanner: true }), cardCountsByScanner ); @@ -263,7 +263,7 @@ describe('Ballots Counts by Party', () => { ]; it('renders as expected when there is no data', async () => { - apiMock.expectGetCardCounts( + apiMock.deprecatedExpectGetCardCounts( mockBallotCountsTableGroupBy({ groupByParty: true }), [] ); @@ -303,7 +303,7 @@ describe('Ballots Counts by Party', () => { }); it('renders as expected when there is tally data', async () => { - apiMock.expectGetCardCounts( + apiMock.deprecatedExpectGetCardCounts( mockBallotCountsTableGroupBy({ groupByParty: true }), cardCountsByParty ); @@ -369,7 +369,7 @@ describe('Ballots Counts by VotingMethod', () => { ]; it('renders as expected when there is no data', async () => { - apiMock.expectGetCardCounts( + apiMock.deprecatedExpectGetCardCounts( mockBallotCountsTableGroupBy({ groupByVotingMethod: true }), [] ); @@ -402,7 +402,7 @@ describe('Ballots Counts by VotingMethod', () => { }); it('renders as expected when there is tally data', async () => { - apiMock.expectGetCardCounts( + apiMock.deprecatedExpectGetCardCounts( mockBallotCountsTableGroupBy({ groupByVotingMethod: true }), cardCountsByVotingMethod ); @@ -483,7 +483,7 @@ describe('Ballots Counts by Batch', () => { ]; it('renders as expected when there is no data', async () => { - apiMock.expectGetCardCounts( + apiMock.deprecatedExpectGetCardCounts( mockBallotCountsTableGroupBy({ groupByBatch: true }), [] ); @@ -521,7 +521,7 @@ describe('Ballots Counts by Batch', () => { ]; it('renders as expected when there is tally data', async () => { - apiMock.expectGetCardCounts( + apiMock.deprecatedExpectGetCardCounts( mockBallotCountsTableGroupBy({ groupByBatch: true }), cardCountsByBatch ); @@ -561,7 +561,7 @@ describe('Ballots Counts by Batch', () => { }); it('renders as expected where there is tally data and manual data', async () => { - apiMock.expectGetCardCounts( + apiMock.deprecatedExpectGetCardCounts( mockBallotCountsTableGroupBy({ groupByBatch: true }), cardCountsByBatch ); diff --git a/apps/admin/frontend/src/components/election_manager.tsx b/apps/admin/frontend/src/components/election_manager.tsx index e309d7d041..cc3313297d 100644 --- a/apps/admin/frontend/src/components/election_manager.tsx +++ b/apps/admin/frontend/src/components/election_manager.tsx @@ -48,6 +48,7 @@ import { checkPin } from '../api'; import { canViewAndPrintBallots } from '../utils/can_view_and_print_ballots'; import { WriteInsAdjudicationScreen } from '../screens/write_ins_adjudication_screen'; import { TallyReportBuilder } from '../screens/tally_report_builder'; +import { BallotCountReportBuilder } from '../screens/ballot_count_report_builder'; export function ElectionManager(): JSX.Element { const { electionDefinition, configuredAt, auth, hasCardReaderAttached } = @@ -206,6 +207,9 @@ export function ElectionManager(): JSX.Element { + + + = + [ + { + votingMethod: 'absentee', + ...getMockCardCounts(1, undefined, 3), + }, + { + votingMethod: 'precinct', + ...getMockCardCounts(0, undefined, 1), + }, + ]; + +beforeEach(() => { + apiMock = createApiMock(); + apiMock.expectGetCastVoteRecordFileMode('official'); + apiMock.expectGetScannerBatches([]); +}); + +afterEach(() => { + apiMock.assertComplete(); + window.kiosk = undefined; +}); + +test('disabled shows disabled buttons and no preview', () => { + const { electionDefinition } = electionFamousNames2021Fixtures; + renderInAppContext( + , + { apiMock, electionDefinition } + ); + + expect(screen.getButton('Print Report')).toBeDisabled(); + expect(screen.getButton('Export Report PDF')).toBeDisabled(); + expect(screen.getButton('Export Report CSV')).toBeDisabled(); +}); + +test('autoPreview loads preview automatically', async () => { + const { electionDefinition } = electionTwoPartyPrimaryFixtures; + apiMock.expectGetCardCounts( + { + filter: {}, + groupBy: { groupByVotingMethod: true }, + }, + MOCK_VOTING_METHOD_CARD_COUNTS + ); + + renderInAppContext( + , + { apiMock, electionDefinition } + ); + + await screen.findByText('Unofficial Full Election Ballot Count Report'); + expect(screen.getByTestId('footer-total')).toHaveTextContent('5'); +}); + +test('autoPreview = false does not load preview automatically', async () => { + const { electionDefinition } = electionFamousNames2021Fixtures; + + renderInAppContext( + , + { apiMock, electionDefinition } + ); + + await screen.findButton('Load Preview'); +}); + +test('print before loading preview', async () => { + const { electionDefinition } = electionFamousNames2021Fixtures; + const { resolve: resolveData } = apiMock.expectGetCardCounts( + { + filter: {}, + groupBy: { groupByVotingMethod: true }, + }, + MOCK_VOTING_METHOD_CARD_COUNTS, + true + ); + + renderInAppContext( + , + { apiMock, electionDefinition } + ); + + await screen.findButton('Load Preview'); + const { resolve: resolvePrint } = deferNextPrint(); + userEvent.click(screen.getButton('Print Report')); + const modal = await screen.findByRole('alertdialog'); + await within(modal).findByText('Generating Report'); + resolveData(); + await within(modal).findByText('Printing Report'); + resolvePrint(); + await waitForElementToBeRemoved(screen.queryByRole('alertdialog')); + await expectPrint((printResult) => { + printResult.getByText('Unofficial Full Election Ballot Count Report'); + expect(printResult.getByTestId('footer-total')).toHaveTextContent('5'); + }); + + // the preview will now show the report, because its available + screen.getByText('Unofficial Full Election Ballot Count Report'); + expect(screen.getByTestId('footer-total')).toHaveTextContent('5'); +}); + +test('print after preview loaded + test success logging', async () => { + const { electionDefinition } = electionFamousNames2021Fixtures; + apiMock.expectGetCardCounts( + { + filter: {}, + groupBy: { groupByVotingMethod: true }, + }, + MOCK_VOTING_METHOD_CARD_COUNTS + ); + + const logger = fakeLogger(); + renderInAppContext( + , + { apiMock, electionDefinition, logger } + ); + + await screen.findByText('Unofficial Full Election Ballot Count Report'); + + const { resolve: resolvePrint } = deferNextPrint(); + userEvent.click(screen.getButton('Print Report')); + const modal = await screen.findByRole('alertdialog'); + await within(modal).findByText('Printing Report'); + resolvePrint(); + await waitForElementToBeRemoved(screen.queryByRole('alertdialog')); + await expectPrint((printResult) => { + printResult.getByText('Unofficial Full Election Ballot Count Report'); + expect(printResult.getByTestId('footer-total')).toHaveTextContent('5'); + }); + expect(logger.log).toHaveBeenLastCalledWith( + LogEventId.TallyReportPrinted, + 'election_manager', + { + disposition: 'success', + message: 'User printed a ballot count report.', + filter: '{}', + groupBy: '{"groupByVotingMethod":true}', + } + ); +}); + +test('print while preview is loading', async () => { + const { electionDefinition } = electionFamousNames2021Fixtures; + const { resolve: resolveData } = apiMock.expectGetCardCounts( + { + filter: {}, + groupBy: { groupByVotingMethod: true }, + }, + MOCK_VOTING_METHOD_CARD_COUNTS, + true + ); + + renderInAppContext( + , + { apiMock, electionDefinition } + ); + + userEvent.click(await screen.findButton('Load Preview')); + await screen.findByText('Generating Report'); + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + const { resolve: resolvePrint } = deferNextPrint(); + userEvent.click(screen.getButton('Print Report')); + const modal = await screen.findByRole('alertdialog'); + await within(modal).findByText('Generating Report'); + expect(screen.getAllByText('Generating Report')).toHaveLength(2); + resolveData(); + await within(modal).findByText('Printing Report'); + resolvePrint(); + await expectPrint((printResult) => { + printResult.getByText('Unofficial Full Election Ballot Count Report'); + expect(printResult.getByTestId('footer-total')).toHaveTextContent('5'); + }); + + screen.getByText('Unofficial Full Election Ballot Count Report'); + expect(screen.getByTestId('footer-total')).toHaveTextContent('5'); +}); + +test('print failure logging', async () => { + const { electionDefinition } = electionFamousNames2021Fixtures; + apiMock.expectGetCardCounts( + { + filter: {}, + groupBy: { groupByVotingMethod: true }, + }, + MOCK_VOTING_METHOD_CARD_COUNTS + ); + + const logger = fakeLogger(); + renderInAppContext( + , + { apiMock, electionDefinition, logger } + ); + + await screen.findByText('Unofficial Full Election Ballot Count Report'); + + simulateErrorOnNextPrint(new Error('printer broken')); + userEvent.click(screen.getButton('Print Report')); + await waitFor(() => { + expect(logger.log).toHaveBeenCalledWith( + LogEventId.TallyReportPrinted, + 'election_manager', + { + disposition: 'failure', + message: + 'User attempted to print a ballot count report, but an error occurred: printer broken', + filter: '{}', + groupBy: '{"groupByVotingMethod":true}', + } + ); + }); +}); + +test('displays custom filter rather than specific title when necessary', async () => { + const { electionDefinition } = electionFamousNames2021Fixtures; + const filter: Tabulation.Filter = { + ballotStyleIds: ['1'], + precinctIds: ['23'], + votingMethods: ['absentee'], + }; + + apiMock.expectGetCardCounts( + { + filter, + groupBy: {}, + }, + [getMockCardCounts(5, undefined, 10)] + ); + + renderInAppContext( + , + { apiMock, electionDefinition } + ); + + await screen.findByText('Unofficial Custom Filter Ballot Count Report'); + screen.getByText(hasTextAcrossElements('Voting Method: Absentee')); + screen.getByText(hasTextAcrossElements('Ballot Style: 1')); + screen.getByText(hasTextAcrossElements('Precinct: North Lincoln')); +}); + +test('exporting report PDF', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2023-09-06T21:45:08Z')); + const mockKiosk = fakeKiosk(); + window.kiosk = mockKiosk; + + const { electionDefinition } = electionFamousNames2021Fixtures; + apiMock.expectGetCardCounts( + { + filter: {}, + groupBy: { + groupByVotingMethod: true, + }, + }, + MOCK_VOTING_METHOD_CARD_COUNTS + ); + + renderInAppContext( + , + { + apiMock, + electionDefinition, + usbDriveStatus: mockUsbDriveStatus('mounted'), + } + ); + + await screen.findButton('Load Preview'); + userEvent.click(screen.getButton('Export Report PDF')); + const modal = await screen.findByRole('alertdialog'); + within(modal).getByText('Save Unofficial Ballot Count Report'); + within(modal).getByText( + /ballot-count-report-by-voting-method__2023-09-06_21-45-08\.pdf/ + ); + userEvent.click(within(modal).getButton('Save')); + await screen.findByText('Saving Unofficial Ballot Count Report'); + act(() => { + jest.advanceTimersByTime(2000); + }); + await screen.findByText('Unofficial Ballot Count Report Saved'); + + expect(mockKiosk.writeFile).toHaveBeenCalledTimes(1); + await expectPrintToPdf((pdfResult) => { + pdfResult.getByText('Unofficial Full Election Ballot Count Report'); + expect(pdfResult.getByTestId('footer-total')).toHaveTextContent('5'); + }); + + jest.useRealTimers(); +}); diff --git a/apps/admin/frontend/src/components/reporting/ballot_count_report_viewer.tsx b/apps/admin/frontend/src/components/reporting/ballot_count_report_viewer.tsx new file mode 100644 index 0000000000..1b981c59bd --- /dev/null +++ b/apps/admin/frontend/src/components/reporting/ballot_count_report_viewer.tsx @@ -0,0 +1,376 @@ +import { ElectionDefinition, Tabulation } from '@votingworks/types'; +import { + BallotCountReport, + Button, + Caption, + Font, + H6, + Icons, + Loading, + Modal, + printElement, + printElementToPdf, +} from '@votingworks/ui'; +import React, { useContext, useMemo, useRef, useState } from 'react'; +import styled from 'styled-components'; +import { Optional, assert, assertDefined } from '@votingworks/basics'; +import { isElectionManagerAuth } from '@votingworks/utils'; +import type { ScannerBatch } from '@votingworks/admin-backend'; +import { LogEventId } from '@votingworks/logging'; +import { + getCardCounts, + getCastVoteRecordFileMode, + getScannerBatches, +} from '../../api'; +import { AppContext } from '../../contexts/app_context'; +import { PrintButton } from '../print_button'; +import { + generateBallotCountReportPdfFilename, + generateTitleForReport, +} from '../../utils/reporting'; +import { ExportReportPdfButton } from './export_report_pdf_button'; +import { FileType } from '../save_frontend_file_modal'; +import { ExportBallotCountReportCsvButton } from './export_ballot_count_report_csv_button'; + +const ExportActions = styled.div` + margin-top: 1rem; + margin-bottom: 0.5rem; + display: flex; + justify-content: start; + gap: 1rem; +`; + +const PreviewContainer = styled.div` + position: relative; + min-height: 11in; + margin-top: 0.5rem; + padding: 0.5rem; + background: rgba(0, 0, 0, 10%); + border-radius: 0.5rem; + display: flex; + flex-direction: column; + align-items: center; +`; + +const PreviewOverlay = styled.div` + position: absolute; + inset: 0; + z-index: 1; + background: black; + opacity: 0.3; +`; + +const PreviewReportPages = styled.div` + section { + background: white; + position: relative; + box-shadow: 0 3px 10px rgb(0, 0, 0, 20%); + margin-top: 1rem; + margin-bottom: 2rem; + width: 8.5in; + min-height: 11in; + padding: 0.5in; + } +`; + +const PreviewActionContainer = styled.div` + position: absolute; + inset: 0; + margin-left: auto; + margin-right: auto; + margin-top: 4rem; + display: flex; + justify-content: center; + align-items: start; + z-index: 2; +`; + +const LoadingTextContainer = styled.div` + background: white; + width: 35rem; + border-radius: 0.5rem; +`; + +function Report({ + electionDefinition, + scannerBatches, + isOfficialResults, + cardCountsList, + filter, + groupBy, + ballotCountBreakdown, + generatedAtTime, +}: { + electionDefinition: ElectionDefinition; + scannerBatches: ScannerBatch[]; + isOfficialResults: boolean; + cardCountsList: Tabulation.GroupList; + filter: Tabulation.Filter; + groupBy: Tabulation.GroupBy; + ballotCountBreakdown: Tabulation.BallotCountBreakdown; + generatedAtTime: Date; +}): JSX.Element { + const titleGeneration = generateTitleForReport({ + filter, + electionDefinition, + scannerBatches, + reportType: 'Ballot Count', + }); + const titleWithoutOfficiality = titleGeneration.isOk() + ? titleGeneration.ok() ?? `Full Election Ballot Count Report` + : 'Custom Filter Ballot Count Report'; + const title = `${ + isOfficialResults ? 'Official ' : 'Unofficial ' + }${titleWithoutOfficiality}`; + const customFilter = !titleGeneration.isOk() ? filter : undefined; + + return BallotCountReport({ + title, + testId: 'ballot-count-report', + electionDefinition, + customFilter, + scannerBatches, + generatedAtTime, + groupBy, + ballotCountBreakdown, + cardCountsList, + }); +} + +export interface BallotCountReportViewerProps { + filter: Tabulation.Filter; + groupBy: Tabulation.GroupBy; + ballotCountBreakdown: Tabulation.BallotCountBreakdown; + disabled: boolean; + autoPreview: boolean; +} + +export function BallotCountReportViewer({ + filter, + groupBy, + ballotCountBreakdown, + disabled: disabledFromProps, + autoPreview, +}: BallotCountReportViewerProps): JSX.Element { + const { electionDefinition, isOfficialResults, auth, logger } = + useContext(AppContext); + assert(electionDefinition); + const { election } = electionDefinition; + assert(isElectionManagerAuth(auth)); + const userRole = auth.user.role; + + const [isFetchingForPreview, setIsFetchingForPreview] = useState(false); + const [progressModalText, setProgressModalText] = useState(); + + const castVoteRecordFileModeQuery = getCastVoteRecordFileMode.useQuery(); + const scannerBatchesQuery = getScannerBatches.useQuery(); + + const disabled = + disabledFromProps || + !castVoteRecordFileModeQuery.isSuccess || + !scannerBatchesQuery.isSuccess; + + const cardCountsQuery = getCardCounts.useQuery( + { + filter, + groupBy, + }, + { enabled: !disabled && autoPreview } + ); + const reportResultsAreFresh = + cardCountsQuery.isSuccess && !cardCountsQuery.isStale; + + const previewReportRef = useRef>(); + const previewReport: Optional = useMemo(() => { + // Avoid populating the preview with cached data before the caller signals that the parameters are viable + if (disabled) { + return undefined; + } + + // If there's not current fresh data, return the previous preview report + if (!reportResultsAreFresh) { + return previewReportRef.current; + } + + return ( + + ); + }, [ + disabled, + reportResultsAreFresh, + electionDefinition, + filter, + groupBy, + ballotCountBreakdown, + cardCountsQuery.data, + cardCountsQuery.dataUpdatedAt, + isOfficialResults, + scannerBatchesQuery.data, + ]); + previewReportRef.current = previewReport; + const previewIsFresh = cardCountsQuery.isSuccess && !cardCountsQuery.isStale; + + async function refreshPreview() { + setIsFetchingForPreview(true); + await cardCountsQuery.refetch(); + setIsFetchingForPreview(false); + } + + async function getFreshQueryResult(): Promise { + if (reportResultsAreFresh) { + return cardCountsQuery; + } + + return cardCountsQuery.refetch({ cancelRefetch: false }); + } + + async function printReport() { + setProgressModalText('Generating Report'); + const queryResults = await getFreshQueryResult(); + assert(queryResults.isSuccess); + const reportToPrint = ( + + ); + + setProgressModalText('Printing Report'); + const reportProperties = { + filter: JSON.stringify(filter), + groupBy: JSON.stringify(groupBy), + } as const; + try { + await printElement(reportToPrint, { sides: 'one-sided' }); + await logger.log(LogEventId.TallyReportPrinted, userRole, { + message: `User printed a ballot count report.`, + disposition: 'success', + ...reportProperties, + }); + } catch (error) { + assert(error instanceof Error); + await logger.log(LogEventId.TallyReportPrinted, userRole, { + message: `User attempted to print a ballot count report, but an error occurred: ${error.message}`, + disposition: 'failure', + ...reportProperties, + }); + } finally { + setProgressModalText(undefined); + } + } + + async function generateReportPdf(): Promise { + const queryResults = await getFreshQueryResult(); + assert(queryResults.isSuccess); + const reportToSave = ( + + ); + + return printElementToPdf(reportToSave); + } + + const reportPdfFilename = generateBallotCountReportPdfFilename({ + election, + filter, + groupBy, + isTestMode: castVoteRecordFileModeQuery.data === 'test', + time: cardCountsQuery.dataUpdatedAt + ? new Date(cardCountsQuery.dataUpdatedAt) + : undefined, + }); + + return ( + + + + Print Report + + + + + + + Note: Printed reports may be + paginated to more than one piece of paper. + + + {!disabled && ( + + {previewReport && ( + {previewReport} + )} + {!previewIsFresh && } + {isFetchingForPreview && ( + + + Generating Report + + + )} + {!isFetchingForPreview && !previewIsFresh && ( + + {previewReport ? ( + + ) : ( + + )} + + )} + + )} + + {progressModalText && ( + + {progressModalText} + + } + /> + )} + + ); +} diff --git a/apps/admin/frontend/src/components/reporting/export_ballot_count_report_csv_button.test.tsx b/apps/admin/frontend/src/components/reporting/export_ballot_count_report_csv_button.test.tsx new file mode 100644 index 0000000000..6981c2ea1b --- /dev/null +++ b/apps/admin/frontend/src/components/reporting/export_ballot_count_report_csv_button.test.tsx @@ -0,0 +1,77 @@ +import userEvent from '@testing-library/user-event'; +import { Tabulation } from '@votingworks/types'; +import { renderInAppContext } from '../../../test/render_in_app_context'; +import { screen, within } from '../../../test/react_testing_library'; +import { ApiMock, createApiMock } from '../../../test/helpers/mock_api_client'; +import { mockUsbDriveStatus } from '../../../test/helpers/mock_usb_drive'; +import { ExportBallotCountReportCsvButton } from './export_ballot_count_report_csv_button'; + +let apiMock: ApiMock; + +beforeEach(() => { + apiMock = createApiMock(); + apiMock.expectGetCastVoteRecordFileMode('official'); +}); + +afterEach(() => { + apiMock.assertComplete(); +}); + +test('calls mutation in happy path', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2021-01-01T00:00:00Z')); + + const filter: Tabulation.Filter = { + votingMethods: ['absentee'], + }; + const groupBy: Tabulation.GroupBy = { + groupByPrecinct: true, + }; + + renderInAppContext( + , + { + apiMock, + usbDriveStatus: mockUsbDriveStatus('mounted'), + } + ); + + userEvent.click(screen.getButton('Export Report CSV')); + const modal = await screen.findByRole('alertdialog'); + await within(modal).findByText('Save Ballot Count Report'); + within(modal).getByText( + /absentee-ballots-ballot-count-report-by-precinct__2021-01-01_00-00-00\.csv/ + ); + + apiMock.expectExportBallotCountReportCsv({ + filter, + groupBy, + ballotCountBreakdown: 'none', + path: 'test-mount-point/choctaw-county_mock-general-election-choctaw-2020_d6806afc49/reports/absentee-ballots-ballot-count-report-by-precinct__2021-01-01_00-00-00.csv', + }); + userEvent.click(within(modal).getButton('Save')); + await screen.findByText('Ballot Count Report Saved'); + + userEvent.click(within(modal).getButton('Close')); + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + + jest.useRealTimers(); +}); + +test('disabled by disabled prop', () => { + renderInAppContext( + , + { apiMock } + ); + + expect(screen.getButton('Export Report CSV')).toBeDisabled(); +}); diff --git a/apps/admin/frontend/src/components/reporting/export_ballot_count_report_csv_button.tsx b/apps/admin/frontend/src/components/reporting/export_ballot_count_report_csv_button.tsx new file mode 100644 index 0000000000..6e6f29bce2 --- /dev/null +++ b/apps/admin/frontend/src/components/reporting/export_ballot_count_report_csv_button.tsx @@ -0,0 +1,93 @@ +import React, { useContext, useState } from 'react'; +import { Button } from '@votingworks/ui'; +import { assert } from '@votingworks/basics'; +import { Tabulation } from '@votingworks/types'; +import path from 'path'; +import { generateElectionBasedSubfolderName } from '@votingworks/utils'; +import { AppContext } from '../../contexts/app_context'; +import { + exportBallotCountReportCsv, + getCastVoteRecordFileMode, +} from '../../api'; +import { SaveBackendFileModal } from '../save_backend_file_modal'; +import { + REPORT_SUBFOLDER, + generateBallotCountReportCsvFilename, +} from '../../utils/reporting'; + +export function ExportBallotCountReportCsvButton({ + filter, + groupBy, + ballotCountBreakdown, + disabled, +}: { + filter: Tabulation.Filter; + groupBy: Tabulation.GroupBy; + ballotCountBreakdown: Tabulation.BallotCountBreakdown; + disabled?: boolean; +}): JSX.Element { + const { electionDefinition } = useContext(AppContext); + assert(electionDefinition); + const { election } = electionDefinition; + + const [isSaveModalOpen, setIsSaveModalOpen] = useState(false); + const [exportDate, setExportDate] = useState(); + + function openModal() { + setIsSaveModalOpen(true); + setExportDate(new Date()); + } + + function closeModal() { + setIsSaveModalOpen(false); + setExportDate(undefined); + } + + const exportBallotCountReportCsvMutation = + exportBallotCountReportCsv.useMutation(); + const castVoteRecordFileModeQuery = getCastVoteRecordFileMode.useQuery(); + const isTestMode = castVoteRecordFileModeQuery.data === 'test'; + + const defaultFilename = generateBallotCountReportCsvFilename({ + election, + filter, + groupBy, + isTestMode, + time: exportDate, + }); + const defaultFilePath = path.join( + generateElectionBasedSubfolderName( + electionDefinition.election, + electionDefinition.electionHash + ), + REPORT_SUBFOLDER, + defaultFilename + ); + + return ( + + + {isSaveModalOpen && ( + + exportBallotCountReportCsvMutation.mutate({ + path: savePath, + filter, + groupBy, + ballotCountBreakdown, + }) + } + saveFileResult={exportBallotCountReportCsvMutation.data} + resetSaveFileResult={exportBallotCountReportCsvMutation.reset} + onClose={closeModal} + fileType="ballot count report" + fileTypeTitle="Ballot Count Report" + defaultRelativePath={defaultFilePath} + /> + )} + + ); +} diff --git a/apps/admin/frontend/src/components/reporting/export_report_pdf_button.test.tsx b/apps/admin/frontend/src/components/reporting/export_report_pdf_button.test.tsx index a39e6615e3..1f74abb786 100644 --- a/apps/admin/frontend/src/components/reporting/export_report_pdf_button.test.tsx +++ b/apps/admin/frontend/src/components/reporting/export_report_pdf_button.test.tsx @@ -3,6 +3,7 @@ import { fakeKiosk } from '@votingworks/test-utils'; import { renderInAppContext } from '../../../test/render_in_app_context'; import { ExportReportPdfButton } from './export_report_pdf_button'; import { screen } from '../../../test/react_testing_library'; +import { FileType } from '../save_frontend_file_modal'; afterEach(() => { delete window.kiosk; @@ -17,6 +18,7 @@ test('disabled by disabled prop', () => { electionDefinition={electionTwoPartyPrimaryDefinition} generateReportPdf={() => Promise.resolve(new Uint8Array())} defaultFilename="some-file" + fileType={FileType.TallyReport} disabled /> ); @@ -30,6 +32,7 @@ test('disabled when window.kiosk is undefined', () => { electionDefinition={electionTwoPartyPrimaryDefinition} generateReportPdf={() => Promise.resolve(new Uint8Array())} defaultFilename="some-file" + fileType={FileType.TallyReport} disabled={false} /> ); diff --git a/apps/admin/frontend/src/components/reporting/export_report_pdf_button.tsx b/apps/admin/frontend/src/components/reporting/export_report_pdf_button.tsx index bf4b0a38ad..c0e583e808 100644 --- a/apps/admin/frontend/src/components/reporting/export_report_pdf_button.tsx +++ b/apps/admin/frontend/src/components/reporting/export_report_pdf_button.tsx @@ -11,11 +11,13 @@ export function ExportReportPdfButton({ generateReportPdf, defaultFilename, disabled, + fileType, }: { electionDefinition: ElectionDefinition; generateReportPdf: () => Promise; defaultFilename: string; disabled?: boolean; + fileType: FileType; }): JSX.Element { const [isModalOpen, setIsModalOpen] = React.useState(false); return ( @@ -38,7 +40,7 @@ export function ExportReportPdfButton({ ), REPORT_SUBFOLDER )} - fileType={FileType.TallyReport} + fileType={fileType} /> )} diff --git a/apps/admin/frontend/src/components/reporting/export_csv_button.test.tsx b/apps/admin/frontend/src/components/reporting/export_tally_report_csv_button.test.tsx similarity index 83% rename from apps/admin/frontend/src/components/reporting/export_csv_button.test.tsx rename to apps/admin/frontend/src/components/reporting/export_tally_report_csv_button.test.tsx index c51d790eae..9880395fd8 100644 --- a/apps/admin/frontend/src/components/reporting/export_csv_button.test.tsx +++ b/apps/admin/frontend/src/components/reporting/export_tally_report_csv_button.test.tsx @@ -2,7 +2,7 @@ import userEvent from '@testing-library/user-event'; import { Tabulation } from '@votingworks/types'; import { renderInAppContext } from '../../../test/render_in_app_context'; import { screen, within } from '../../../test/react_testing_library'; -import { ExportCsvResultsButton } from './export_csv_button'; +import { ExportTallyReportCsvButton } from './export_tally_report_csv_button'; import { ApiMock, createApiMock } from '../../../test/helpers/mock_api_client'; import { mockUsbDriveStatus } from '../../../test/helpers/mock_usb_drive'; @@ -29,14 +29,14 @@ test('calls mutation in happy path', async () => { }; renderInAppContext( - , + , { apiMock, usbDriveStatus: mockUsbDriveStatus('mounted'), } ); - userEvent.click(screen.getButton('Export CSV Results')); + userEvent.click(screen.getButton('Export Report CSV')); const modal = await screen.findByRole('alertdialog'); await within(modal).findByText('Save Results'); within(modal).getByText( @@ -59,9 +59,9 @@ test('calls mutation in happy path', async () => { test('disabled by disabled prop', () => { renderInAppContext( - , + , { apiMock } ); - expect(screen.getButton('Export CSV Results')).toBeDisabled(); + expect(screen.getButton('Export Report CSV')).toBeDisabled(); }); diff --git a/apps/admin/frontend/src/components/reporting/export_csv_button.tsx b/apps/admin/frontend/src/components/reporting/export_tally_report_csv_button.tsx similarity index 97% rename from apps/admin/frontend/src/components/reporting/export_csv_button.tsx rename to apps/admin/frontend/src/components/reporting/export_tally_report_csv_button.tsx index d5f7d31bbf..8581d61fc5 100644 --- a/apps/admin/frontend/src/components/reporting/export_csv_button.tsx +++ b/apps/admin/frontend/src/components/reporting/export_tally_report_csv_button.tsx @@ -12,7 +12,7 @@ import { generateTallyReportCsvFilename, } from '../../utils/reporting'; -export function ExportCsvResultsButton({ +export function ExportTallyReportCsvButton({ filter, groupBy, disabled, @@ -61,7 +61,7 @@ export function ExportCsvResultsButton({ return ( {isSaveModalOpen && ( { apiMock.assertComplete(); }); -test('general flow + precinct, voting method, ballot style selection', () => { +test('precinct, voting method, ballot style selection (general flow)', () => { const { election } = electionTwoPartyPrimaryDefinition; const onChange = jest.fn(); apiMock.expectGetScannerBatches([]); - renderInAppContext(, { - apiMock, - }); + renderInAppContext( + , + { + apiMock, + } + ); // Add filter row, precinct userEvent.click(screen.getButton('Add Filter')); @@ -94,7 +101,7 @@ test('general flow + precinct, voting method, ballot style selection', () => { }); }); -test('scanner + batch selection', async () => { +test('scanner, batch selection', async () => { const { election } = electionTwoPartyPrimaryDefinition; const onChange = jest.fn(); @@ -118,9 +125,16 @@ test('scanner + batch selection', async () => { electionId: 'id', }, ]); - renderInAppContext(, { - apiMock, - }); + renderInAppContext( + , + { + apiMock, + } + ); // add scanner filter userEvent.click(screen.getButton('Add Filter')); @@ -147,14 +161,49 @@ test('scanner + batch selection', async () => { }); }); +test('party selection', () => { + const { election } = electionTwoPartyPrimaryDefinition; + const onChange = jest.fn(); + + apiMock.expectGetScannerBatches([]); + renderInAppContext( + , + { + apiMock, + } + ); + + // add scanner filter + userEvent.click(screen.getButton('Add Filter')); + userEvent.click(screen.getByLabelText('Select New Filter Type')); + userEvent.click(screen.getByText('Party')); + expect(onChange).toHaveBeenNthCalledWith(1, { partyIds: [] }); + userEvent.click(screen.getByLabelText('Select Filter Values')); + screen.getByText('Mammal'); + screen.getByText('Fish'); + userEvent.click(screen.getByText('Mammal')); + expect(onChange).toHaveBeenNthCalledWith(2, { partyIds: ['0'] }); +}); + test('can cancel adding a filter', () => { const { election } = electionTwoPartyPrimaryDefinition; const onChange = jest.fn(); apiMock.expectGetScannerBatches([]); - renderInAppContext(, { - apiMock, - }); + renderInAppContext( + , + { + apiMock, + } + ); userEvent.click(screen.getButton('Add Filter')); userEvent.click(screen.getByLabelText('Cancel Add Filter')); diff --git a/apps/admin/frontend/src/components/reporting/filter_editor.tsx b/apps/admin/frontend/src/components/reporting/filter_editor.tsx index be08f8a082..dd9cadf952 100644 --- a/apps/admin/frontend/src/components/reporting/filter_editor.tsx +++ b/apps/admin/frontend/src/components/reporting/filter_editor.tsx @@ -10,11 +10,7 @@ import styled from 'styled-components'; import { SearchSelect, SelectOption, Icons, Button } from '@votingworks/ui'; import type { ScannerBatch } from '@votingworks/admin-backend'; import { getScannerBatches } from '../../api'; - -export interface FilterEditorProps { - onChange: (filter: Tabulation.Filter) => void; - election: Election; -} +import { getPartiesWithPrimaryElections } from '../../utils/election'; const FilterEditorContainer = styled.div` width: 100%; @@ -74,12 +70,9 @@ const FILTER_TYPES = [ 'ballot-style', 'scanner', 'batch', + 'party', ] as const; -type FilterType = (typeof FILTER_TYPES)[number]; - -function getAllowedFilterTypes(): FilterType[] { - return ['precinct', 'voting-method', 'ballot-style', 'scanner', 'batch']; -} +export type FilterType = (typeof FILTER_TYPES)[number]; interface FilterRow { rowId: number; @@ -94,6 +87,7 @@ const FILTER_TYPE_LABELS: Record = { 'ballot-style': 'Ballot Style', scanner: 'Scanner', batch: 'Batch', + party: 'Party', }; function getFilterTypeOption(filterType: FilterType): SelectOption { @@ -123,6 +117,11 @@ function generateOptionsForFilter({ value: bs.id, label: bs.id, })); + case 'party': + return getPartiesWithPrimaryElections(election).map((party) => ({ + value: party.id, + label: party.name, + })); case 'voting-method': return typedAs>>([ { @@ -172,6 +171,9 @@ function convertFilterRowsToTabulationFilter( case 'ballot-style': tabulationFilter.ballotStyleIds = filterValues; break; + case 'party': + tabulationFilter.partyIds = filterValues; + break; case 'scanner': tabulationFilter.scannerIds = filterValues; break; @@ -187,9 +189,16 @@ function convertFilterRowsToTabulationFilter( return tabulationFilter; } +export interface FilterEditorProps { + onChange: (filter: Tabulation.Filter) => void; + election: Election; + allowedFilters: FilterType[]; +} + export function FilterEditor({ onChange, election, + allowedFilters, }: FilterEditorProps): JSX.Element { const [rows, setRows] = useState([]); const [nextRowId, setNextRowId] = useState(0); @@ -236,7 +245,7 @@ export function FilterEditor({ } const activeFilters: FilterType[] = rows.map((row) => row.filterType); - const unusedFilters: FilterType[] = getAllowedFilterTypes().filter( + const unusedFilters: FilterType[] = allowedFilters.filter( (filterType) => !activeFilters.includes(filterType) ); diff --git a/apps/admin/frontend/src/components/reporting/group_by_editor.test.tsx b/apps/admin/frontend/src/components/reporting/group_by_editor.test.tsx index 9045cdf564..d00041f0fc 100644 --- a/apps/admin/frontend/src/components/reporting/group_by_editor.test.tsx +++ b/apps/admin/frontend/src/components/reporting/group_by_editor.test.tsx @@ -8,10 +8,22 @@ test('GroupByEditor', () => { const setGroupBy = jest.fn(); const groupBy: Tabulation.GroupBy = { groupByPrecinct: true, + groupByParty: true, }; renderInAppContext( - + ); const items: Array<[label: string, checked: boolean]> = [ @@ -20,6 +32,7 @@ test('GroupByEditor', () => { ['Ballot Style', false], ['Scanner', false], ['Batch', false], + ['Party', true], ]; for (const [label, checked] of items) { diff --git a/apps/admin/frontend/src/components/reporting/group_by_editor.tsx b/apps/admin/frontend/src/components/reporting/group_by_editor.tsx index 5f3fdb631d..d0abc566e4 100644 --- a/apps/admin/frontend/src/components/reporting/group_by_editor.tsx +++ b/apps/admin/frontend/src/components/reporting/group_by_editor.tsx @@ -2,19 +2,9 @@ import { Tabulation } from '@votingworks/types'; import styled from 'styled-components'; import { Checkbox, Font } from '@votingworks/ui'; -type Grouping = keyof Tabulation.GroupBy; +export type GroupByType = keyof Tabulation.GroupBy; -function getAllowedGroupings(): Grouping[] { - return [ - 'groupByPrecinct', - 'groupByVotingMethod', - 'groupByBallotStyle', - 'groupByScanner', - 'groupByBatch', - ]; -} - -const GROUPING_LABEL: Record = { +const GROUPING_LABEL: Record = { groupByParty: 'Party', groupByPrecinct: 'Precinct', groupByBallotStyle: 'Ballot Style', @@ -26,11 +16,12 @@ const GROUPING_LABEL: Record = { export interface GroupByEditorProps { groupBy: Tabulation.GroupBy; setGroupBy: (groupBy: Tabulation.GroupBy) => void; + allowedGroupings: GroupByType[]; } const Container = styled.div` display: grid; - grid-template-columns: repeat(5, min-content); + grid-template-columns: repeat(6, min-content); gap: 0.75rem; `; @@ -66,15 +57,15 @@ const CheckboxContainer = styled.div` export function GroupByEditor({ groupBy, setGroupBy, + allowedGroupings, }: GroupByEditorProps): JSX.Element { - function toggleGrouping(grouping: Grouping): void { + function toggleGrouping(grouping: GroupByType): void { setGroupBy({ ...groupBy, [grouping]: !groupBy[grouping], }); } - const allowedGroupings = getAllowedGroupings(); return ( {allowedGroupings.map((grouping) => { diff --git a/apps/admin/frontend/src/components/reporting/tally_report_viewer.test.tsx b/apps/admin/frontend/src/components/reporting/tally_report_viewer.test.tsx index 49e8936ab6..885d7e97f3 100644 --- a/apps/admin/frontend/src/components/reporting/tally_report_viewer.test.tsx +++ b/apps/admin/frontend/src/components/reporting/tally_report_viewer.test.tsx @@ -41,7 +41,7 @@ test('disabled shows disabled buttons and no preview', () => { expect(screen.getButton('Print Report')).toBeDisabled(); expect(screen.getButton('Export Report PDF')).toBeDisabled(); - expect(screen.getButton('Export CSV Results')).toBeDisabled(); + expect(screen.getButton('Export Report CSV')).toBeDisabled(); }); test('autoPreview loads preview automatically', async () => { @@ -184,7 +184,9 @@ test('print after preview loaded + test success logging', async () => { 'election_manager', { disposition: 'success', - message: 'User printed a custom tally report from the report builder.', + message: 'User printed a tally report.', + filter: '{}', + groupBy: '{}', } ); }); @@ -277,7 +279,9 @@ test('print failure logging', async () => { { disposition: 'failure', message: - 'User attempted to print a custom tally report from the report builder, but an error occurred: printer broken', + 'User attempted to print a tally report, but an error occurred: printer broken', + filter: '{}', + groupBy: '{}', } ); }); @@ -384,5 +388,7 @@ test('exporting report PDF', async () => { expect(pdfResult.getByTestId('total-ballot-count')).toHaveTextContent('10'); }); + userEvent.click(screen.getButton('Close')); + jest.useRealTimers(); }); diff --git a/apps/admin/frontend/src/components/reporting/tally_report_viewer.tsx b/apps/admin/frontend/src/components/reporting/tally_report_viewer.tsx index 957699acd0..13e18fe333 100644 --- a/apps/admin/frontend/src/components/reporting/tally_report_viewer.tsx +++ b/apps/admin/frontend/src/components/reporting/tally_report_viewer.tsx @@ -35,7 +35,8 @@ import { generateTitleForReport, } from '../../utils/reporting'; import { ExportReportPdfButton } from './export_report_pdf_button'; -import { ExportCsvResultsButton } from './export_csv_button'; +import { ExportTallyReportCsvButton } from './export_tally_report_csv_button'; +import { FileType } from '../save_frontend_file_modal'; const ExportActions = styled.div` margin-top: 1rem; @@ -252,17 +253,23 @@ export function TallyReportViewer({ ); setProgressModalText('Printing Report'); + const reportProperties = { + filter: JSON.stringify(filter), + groupBy: JSON.stringify(groupBy), + } as const; try { await printElement(reportToPrint, { sides: 'one-sided' }); await logger.log(LogEventId.TallyReportPrinted, userRole, { - message: `User printed a custom tally report from the report builder.`, + message: `User printed a tally report.`, disposition: 'success', + ...reportProperties, }); } catch (error) { assert(error instanceof Error); await logger.log(LogEventId.TallyReportPrinted, userRole, { - message: `User attempted to print a custom tally report from the report builder, but an error occurred: ${error.message}`, + message: `User attempted to print a tally report, but an error occurred: ${error.message}`, disposition: 'failure', + ...reportProperties, }); } finally { setProgressModalText(undefined); @@ -312,8 +319,9 @@ export function TallyReportViewer({ generateReportPdf={generateReportPdf} defaultFilename={reportPdfFilename} disabled={disabled} + fileType={FileType.TallyReport} /> - `/reports/tally-reports/batches/${batchId}`, tallyReportBuilder: `/reports/tally-reports/builder`, + ballotCountReportBuilder: `/reports/ballot-count-reports/builder`, tallyWriteInReport: '/reports/tally-reports/writein', logicAndAccuracy: '/logic-and-accuracy', testDecks: '/logic-and-accuracy/test-decks', diff --git a/apps/admin/frontend/src/screens/ballot_count_report_builder.test.tsx b/apps/admin/frontend/src/screens/ballot_count_report_builder.test.tsx new file mode 100644 index 0000000000..ddda04470d --- /dev/null +++ b/apps/admin/frontend/src/screens/ballot_count_report_builder.test.tsx @@ -0,0 +1,221 @@ +import { + electionFamousNames2021Fixtures, + electionTwoPartyPrimaryDefinition, + electionTwoPartyPrimaryFixtures, +} from '@votingworks/fixtures'; +import userEvent from '@testing-library/user-event'; +import { expectPrint } from '@votingworks/test-utils'; +import { Tabulation } from '@votingworks/types'; +import { ApiMock, createApiMock } from '../../test/helpers/mock_api_client'; +import { renderInAppContext } from '../../test/render_in_app_context'; +import { screen, within } from '../../test/react_testing_library'; +import { getMockCardCounts } from '../../test/helpers/mock_results'; +import { canonicalizeFilter, canonicalizeGroupBy } from '../utils/reporting'; +import { BallotCountReportBuilder } from './ballot_count_report_builder'; + +let apiMock: ApiMock; + +beforeEach(() => { + apiMock = createApiMock(); +}); + +afterEach(() => { + apiMock.assertComplete(); +}); + +test('happy path', async () => { + const electionDefinition = electionTwoPartyPrimaryDefinition; + + function getMockCardCountList( + multiplier: number + ): Tabulation.GroupList { + return [ + { + precinctId: 'precinct-1', + partyId: '0', + ...getMockCardCounts(1 * multiplier), + }, + { + precinctId: 'precinct-1', + partyId: '1', + ...getMockCardCounts(2 * multiplier), + }, + { + precinctId: 'precinct-2', + partyId: '0', + ...getMockCardCounts(3 * multiplier), + }, + { + precinctId: 'precinct-2', + partyId: '1', + ...getMockCardCounts(4 * multiplier), + }, + ]; + } + + apiMock.expectGetCastVoteRecordFileMode('test'); + apiMock.expectGetScannerBatches([]); + apiMock.expectGetManualResultsMetadata([]); + renderInAppContext(, { + electionDefinition, + apiMock, + }); + + expect(screen.queryByText('Load Preview')).not.toBeInTheDocument(); + expect(screen.getButton('Print Report')).toBeDisabled(); + + // Add Filter + userEvent.click(screen.getByText('Add Filter')); + userEvent.click(screen.getByLabelText('Select New Filter Type')); + expect( + within(screen.getByTestId('filter-editor')).queryByText('Party') + ).toBeInTheDocument(); // party should be option for primaries, although we don't select it now + userEvent.click( + within(screen.getByTestId('filter-editor')).getByText('Voting Method') + ); + screen.getByText('equals'); + userEvent.click(screen.getByLabelText('Select Filter Values')); + userEvent.click(screen.getByText('Absentee')); + + await screen.findButton('Load Preview'); + expect(screen.getButton('Print Report')).not.toBeDisabled(); + + // Add Group By + userEvent.click(screen.getButton('Report By Precinct')); + expect(screen.queryByLabelText('Report By Party')).toBeInTheDocument(); + userEvent.click(screen.getButton('Report By Party')); + + // Load Preview + apiMock.expectGetCardCounts( + { + filter: canonicalizeFilter({ + votingMethods: ['absentee'], + }), + groupBy: canonicalizeGroupBy({ + groupByPrecinct: true, + groupByParty: true, + }), + }, + getMockCardCountList(1) + ); + userEvent.click(screen.getButton('Load Preview')); + + await screen.findByText('Unofficial Absentee Ballot Ballot Count Report'); + expect(screen.getByTestId('footer-total')).toHaveTextContent('10'); + expect(screen.queryByTestId('footer-bmd')).not.toBeInTheDocument(); + + // Change Report Parameters + userEvent.click(screen.getByLabelText('Remove Absentee')); + userEvent.click(screen.getByLabelText('Select Filter Values')); + userEvent.click( + within(screen.getByTestId('filter-editor')).getByText('Precinct') + ); + + userEvent.click(screen.getByLabelText('Select Breakdown Type')); + // without any manual counts, we should not see the manual option + expect(screen.queryByText('Manual')).not.toBeInTheDocument(); + userEvent.click(screen.getByText('Full')); + + // Refresh Preview + apiMock.expectGetCardCounts( + { + filter: canonicalizeFilter({ + votingMethods: ['precinct'], + }), + groupBy: canonicalizeGroupBy({ + groupByPrecinct: true, + groupByParty: true, + }), + }, + getMockCardCountList(2) + ); + userEvent.click(screen.getByText('Refresh Preview')); + + await screen.findByText('Unofficial Precinct Ballot Ballot Count Report'); + expect(screen.getByTestId('footer-total')).toHaveTextContent('20'); + // we added the breakdown, so we should have a BMD subtotal too + expect(screen.getByTestId('footer-bmd')).toHaveTextContent('20'); + // we haven't added manual counts, so there should be no manual column + expect(screen.queryByTestId('footer-manual')).not.toBeInTheDocument(); + + // Print Report + userEvent.click(screen.getButton('Print Report')); + await expectPrint((printResult) => { + printResult.getByText('Unofficial Precinct Ballot Ballot Count Report'); + expect(printResult.getByTestId('footer-total')).toHaveTextContent('20'); + }); +}); + +test('does not show party options for non-primary elections', () => { + const { electionDefinition } = electionFamousNames2021Fixtures; + + apiMock.expectGetCastVoteRecordFileMode('test'); + apiMock.expectGetScannerBatches([]); + apiMock.expectGetManualResultsMetadata([]); + renderInAppContext(, { + electionDefinition, + apiMock, + }); + + expect(screen.queryByText('Load Preview')).not.toBeInTheDocument(); + + // no group by + expect(screen.queryByLabelText('Report By Party')).not.toBeInTheDocument(); + + // no filter + userEvent.click(screen.getByText('Add Filter')); + userEvent.click(screen.getByLabelText('Select New Filter Type')); + expect( + within(screen.getByTestId('filter-editor')).queryByText('Party') + ).not.toBeInTheDocument(); +}); + +test('shows manual breakdown option when manual data', async () => { + const { electionDefinition } = electionTwoPartyPrimaryFixtures; + + apiMock.expectGetCastVoteRecordFileMode('test'); + apiMock.expectGetScannerBatches([]); + apiMock.expectGetManualResultsMetadata([ + { + precinctId: 'precinct-1', + ballotStyleId: '1M', + votingMethod: 'absentee', + ballotCount: 7, + createdAt: 'mock', + }, + ]); + renderInAppContext(, { + electionDefinition, + apiMock, + }); + + expect(screen.queryByText('Load Preview')).not.toBeInTheDocument(); + + userEvent.click(screen.getButton('Report By Precinct')); + userEvent.click(screen.getByLabelText('Select Breakdown Type')); + userEvent.click(await screen.findByText('Manual')); + + apiMock.expectGetCardCounts( + { + filter: {}, + groupBy: canonicalizeGroupBy({ + groupByPrecinct: true, + }), + }, + [ + { + precinctId: 'precinct-1', + ...getMockCardCounts(1, 7), + }, + { + precinctId: 'precinct-2', + ...getMockCardCounts(2), + }, + ] + ); + + userEvent.click(screen.getByText('Load Preview')); + + expect(await screen.findByTestId('footer-manual')).toHaveTextContent('7'); + expect(screen.getByTestId('footer-total')).toHaveTextContent('10'); +}); diff --git a/apps/admin/frontend/src/screens/ballot_count_report_builder.tsx b/apps/admin/frontend/src/screens/ballot_count_report_builder.tsx new file mode 100644 index 0000000000..8f304d722f --- /dev/null +++ b/apps/admin/frontend/src/screens/ballot_count_report_builder.tsx @@ -0,0 +1,179 @@ +import { + Font, + H3, + Icons, + LinkButton, + P, + SearchSelect, + SelectOption, +} from '@votingworks/ui'; +import { useContext, useState } from 'react'; +import { assert } from '@votingworks/basics'; +import { + isElectionManagerAuth, + isFilterEmpty, + isGroupByEmpty, +} from '@votingworks/utils'; +import { Tabulation } from '@votingworks/types'; +import styled from 'styled-components'; +import { AppContext } from '../contexts/app_context'; +import { NavigationScreen } from '../components/navigation_screen'; +import { routerPaths } from '../router_paths'; +import { + FilterEditor, + FilterType, +} from '../components/reporting/filter_editor'; +import { + GroupByEditor, + GroupByType, +} from '../components/reporting/group_by_editor'; +import { canonicalizeFilter, canonicalizeGroupBy } from '../utils/reporting'; +import { BallotCountReportViewer } from '../components/reporting/ballot_count_report_viewer'; +import { getManualResultsMetadata } from '../api'; + +const SCREEN_TITLE = 'Ballot Count Report Builder'; + +const FilterEditorContainer = styled.div` + width: 80%; + margin-bottom: 2rem; +`; + +const GroupByEditorContainer = styled.div` + width: 80%; + margin-top: 0.5rem; + margin-bottom: 2rem; +`; + +const BreakdownSelectContainer = styled.div` + display: grid; + grid-template-columns: min-content 20%; + align-items: center; + gap: 1rem; + margin-top: 0.5rem; + margin-bottom: 2rem; + white-space: nowrap; +`; + +export function BallotCountReportBuilder(): JSX.Element { + const { electionDefinition, auth } = useContext(AppContext); + assert(electionDefinition); + assert(isElectionManagerAuth(auth)); // TODO(auth) check permissions for viewing reports. + const { election } = electionDefinition; + + const getManualResultsMetadataQuery = getManualResultsMetadata.useQuery(); + + const [filter, setFilter] = useState({}); + const [groupBy, setGroupBy] = useState({}); + const [breakdown, setBreakdown] = + useState('none'); + + function updateFilter(newFilter: Tabulation.Filter) { + setFilter(canonicalizeFilter(newFilter)); + } + + function updateGroupBy(newGroupBy: Tabulation.GroupBy) { + setGroupBy(canonicalizeGroupBy(newGroupBy)); + } + + const allowedFilters: FilterType[] = [ + 'ballot-style', + 'batch', + 'precinct', + 'scanner', + 'voting-method', + ]; + const allowedGroupBys: GroupByType[] = [ + 'groupByBallotStyle', + 'groupByBatch', + 'groupByPrecinct', + 'groupByScanner', + 'groupByVotingMethod', + ]; + if (electionDefinition.election.type === 'primary') { + allowedFilters.push('party'); + allowedGroupBys.push('groupByParty'); + } + + const breakdownOptions: Array> = + [ + { + value: 'none', + label: 'None', + }, + { + value: 'all', + label: 'Full', + }, + ]; + if ( + getManualResultsMetadataQuery.data && + getManualResultsMetadataQuery.data.length > 0 + ) { + breakdownOptions.push({ + value: 'manual', + label: 'Manual', + }); + } + + const hasMadeSelections = !isFilterEmpty(filter) || !isGroupByEmpty(groupBy); + return ( + +

+ + Back + +

+

+ Use the report builder to create custom reports for print or export. +

+
    +
  • + Filters restrict the report to ballots + matching the criteria +
  • +
  • + Report By organizes the ballot counts into + multiple rows +
  • +
+

Filters

+ + + +

Report By

+ + + +

Options

+ + + Ballot Count Breakdown: + + setBreakdown(value as Tabulation.BallotCountBreakdown) + } + ariaLabel="Select Breakdown Type" + /> + + +
+ ); +} diff --git a/apps/admin/frontend/src/screens/reports_screen.test.tsx b/apps/admin/frontend/src/screens/reports_screen.test.tsx index 8eafec442b..1f6d1c43a0 100644 --- a/apps/admin/frontend/src/screens/reports_screen.test.tsx +++ b/apps/admin/frontend/src/screens/reports_screen.test.tsx @@ -128,7 +128,7 @@ test('exporting batch results', async () => { usbDriveStatus: mockUsbDriveStatus('mounted'), }); - apiMock.expectGetCardCounts( + apiMock.deprecatedExpectGetCardCounts( mockBallotCountsTableGroupBy({ groupByBatch: true }), [] ); diff --git a/apps/admin/frontend/src/screens/reports_screen.tsx b/apps/admin/frontend/src/screens/reports_screen.tsx index aedbfb0aba..3649638969 100644 --- a/apps/admin/frontend/src/screens/reports_screen.tsx +++ b/apps/admin/frontend/src/screens/reports_screen.tsx @@ -207,6 +207,9 @@ export function ReportsScreen(): JSX.Element {

Tally Report Builder + {' '} + + Ballot Count Report Builder

{tallyResultsInfo} diff --git a/apps/admin/frontend/src/screens/tally_report_builder.tsx b/apps/admin/frontend/src/screens/tally_report_builder.tsx index 3389322983..5833abf410 100644 --- a/apps/admin/frontend/src/screens/tally_report_builder.tsx +++ b/apps/admin/frontend/src/screens/tally_report_builder.tsx @@ -69,11 +69,31 @@ export function TallyReportBuilder(): JSX.Element {

Filters

- +

Report By

- + { }, 'Absentee Ballot Tally Report', ], + [ + { + partyIds: ['0'], + }, + 'Mammal Party Tally Report', + ], [ { batchIds: ['12345678-0000-0000-0000-000000000000'], @@ -121,6 +127,27 @@ test('generateTitleForReport', () => { }, 'Ballot Style 1M Absentee Ballot Tally Report', ], + [ + { + ballotStyleIds: ['1M'], + partyIds: ['0'], + }, + 'Mammal Party Ballot Style 1M Tally Report', + ], + [ + { + partyIds: ['0'], + votingMethods: ['absentee'], + }, + 'Mammal Party Absentee Ballot Tally Report', + ], + [ + { + partyIds: ['0'], + precinctIds: ['precinct-1'], + }, + 'Mammal Party Precinct 1 Tally Report', + ], [ { scannerIds: ['VX-00-001'], @@ -142,6 +169,16 @@ test('generateTitleForReport', () => { generateTitleForReport({ filter, electionDefinition, scannerBatches }) ).toEqual(ok(title)); } + + // Ballot Count Report + expect( + generateTitleForReport({ + filter: { precinctIds: ['precinct-1'] }, + electionDefinition, + scannerBatches, + reportType: 'Ballot Count', + }) + ).toEqual(ok('Precinct 1 Ballot Count Report')); }); test('canonicalizeFilter', () => { diff --git a/apps/admin/frontend/src/utils/reporting.ts b/apps/admin/frontend/src/utils/reporting.ts index 307ac64153..1f26817124 100644 --- a/apps/admin/frontend/src/utils/reporting.ts +++ b/apps/admin/frontend/src/utils/reporting.ts @@ -1,6 +1,10 @@ import { Election, ElectionDefinition, Tabulation } from '@votingworks/types'; import { Optional, Result, err, find, ok } from '@votingworks/basics'; -import { getPrecinctById, sanitizeStringForFilename } from '@votingworks/utils'; +import { + getPartyById, + getPrecinctById, + sanitizeStringForFilename, +} from '@votingworks/utils'; import moment from 'moment'; import type { ScannerBatch } from '@votingworks/admin-backend'; @@ -41,16 +45,18 @@ function getFilterRank(filter: Tabulation.Filter): number { export const BATCH_ID_TRUNCATE_LENGTH = 8; /** - * Attempts to generate a title for an individual tally report based on its filter. + * Attempts to generate a title for an individual report based on its filter. */ export function generateTitleForReport({ filter, electionDefinition, scannerBatches, + reportType = 'Tally', }: { filter: Tabulation.Filter; electionDefinition: ElectionDefinition; scannerBatches: ScannerBatch[]; + reportType?: 'Tally' | 'Ballot Count'; }): Result, 'title-not-supported'> { if (isCompoundFilter(filter)) { return err('title-not-supported'); @@ -61,6 +67,7 @@ export function generateTitleForReport({ const votingMethod = filter.votingMethods?.[0]; const batchId = filter.batchIds?.[0]; const scannerId = filter.scannerIds?.[0]; + const partyId = filter.partyIds?.[0]; const reportRank = getFilterRank(filter); @@ -72,16 +79,20 @@ export function generateTitleForReport({ if (reportRank === 1) { if (precinctId) { return ok( - `${getPrecinctById(electionDefinition, precinctId).name} Tally Report` + `${ + getPrecinctById(electionDefinition, precinctId).name + } ${reportType} Report` ); } if (ballotStyleId) { - return ok(`Ballot Style ${ballotStyleId} Tally Report`); + return ok(`Ballot Style ${ballotStyleId} ${reportType} Report`); } if (votingMethod) { - return ok(`${VOTING_METHOD_LABELS[votingMethod]} Ballot Tally Report`); + return ok( + `${VOTING_METHOD_LABELS[votingMethod]} Ballot ${reportType} Report` + ); } if (batchId) { @@ -90,52 +101,92 @@ export function generateTitleForReport({ `Scanner ${batch.scannerId} Batch ${batch.batchId.slice( 0, BATCH_ID_TRUNCATE_LENGTH - )} Tally Report` + )} ${reportType} Report` ); } if (scannerId) { - return ok(`Scanner ${scannerId} Tally Report`); + return ok(`Scanner ${scannerId} ${reportType} Report`); } - } - if (reportRank === 2) { - if (precinctId && votingMethod) { + if (partyId) { return ok( - `${getPrecinctById(electionDefinition, precinctId).name} ${ - VOTING_METHOD_LABELS[votingMethod] - } Ballot Tally Report` + `${ + getPartyById(electionDefinition, partyId).fullName + } ${reportType} Report` ); } + } - if (ballotStyleId && votingMethod) { - return ok( - `Ballot Style ${ballotStyleId} ${VOTING_METHOD_LABELS[votingMethod]} Ballot Tally Report` - ); + if (reportRank === 2) { + // Party + Other + if (partyId) { + const partyFullName = getPartyById(electionDefinition, partyId).fullName; + if (precinctId) { + return ok( + `${partyFullName} ${ + getPrecinctById(electionDefinition, precinctId).name + } ${reportType} Report` + ); + } + + if (votingMethod) { + return ok( + `${partyFullName} ${VOTING_METHOD_LABELS[votingMethod]} Ballot ${reportType} Report` + ); + } + + if (ballotStyleId) { + return ok( + `${partyFullName} Ballot Style ${ballotStyleId} ${reportType} Report` + ); + } } - if (precinctId && ballotStyleId) { - return ok( - `Ballot Style ${ballotStyleId} ${ - getPrecinctById(electionDefinition, precinctId).name - } Tally Report` - ); + // Ballot Style + Other + if (ballotStyleId) { + if (precinctId) { + return ok( + `Ballot Style ${ballotStyleId} ${ + getPrecinctById(electionDefinition, precinctId).name + } ${reportType} Report` + ); + } + + if (votingMethod) { + return ok( + `Ballot Style ${ballotStyleId} ${VOTING_METHOD_LABELS[votingMethod]} Ballot ${reportType} Report` + ); + } } - if (precinctId && scannerId) { - return ok( - `${ - getPrecinctById(electionDefinition, precinctId).name - } Scanner ${scannerId} Tally Report` - ); + // Precinct + Other + if (precinctId) { + if (votingMethod) { + return ok( + `${getPrecinctById(electionDefinition, precinctId).name} ${ + VOTING_METHOD_LABELS[votingMethod] + } Ballot ${reportType} Report` + ); + } + + if (scannerId) { + return ok( + `${ + getPrecinctById(electionDefinition, precinctId).name + } Scanner ${scannerId} ${reportType} Report` + ); + } } + // Other Combinations + if (scannerId && batchId) { return ok( `Scanner ${scannerId} Batch ${batchId.slice( 0, BATCH_ID_TRUNCATE_LENGTH - )} Tally Report` + )} ${reportType} Report` ); } } @@ -415,3 +466,51 @@ export function generateTallyReportCsvFilename({ time, }); } + +export function generateBallotCountReportPdfFilename({ + election, + filter, + groupBy, + isTestMode, + time = new Date(), +}: { + election: Election; + filter: Tabulation.Filter; + groupBy: Tabulation.GroupBy; + isTestMode: boolean; + time?: Date; +}): string { + return generateReportFilename({ + election, + filter, + groupBy, + isTestMode, + extension: 'pdf', + type: 'ballot-count-report', + time, + }); +} + +export function generateBallotCountReportCsvFilename({ + election, + filter, + groupBy, + isTestMode, + time = new Date(), +}: { + election: Election; + filter: Tabulation.Filter; + groupBy: Tabulation.GroupBy; + isTestMode: boolean; + time?: Date; +}): string { + return generateReportFilename({ + election, + filter, + groupBy, + isTestMode, + extension: 'csv', + type: 'ballot-count-report', + time, + }); +} diff --git a/apps/admin/frontend/test/helpers/api_expect_helpers.ts b/apps/admin/frontend/test/helpers/api_expect_helpers.ts index fb9d6f9828..af681ea53c 100644 --- a/apps/admin/frontend/test/helpers/api_expect_helpers.ts +++ b/apps/admin/frontend/test/helpers/api_expect_helpers.ts @@ -35,23 +35,23 @@ export function expectReportsScreenCardCountQueries({ isPrimary: boolean; overallCardCount?: Tabulation.CardCounts; }): void { - apiMock.expectGetCardCounts( + apiMock.deprecatedExpectGetCardCounts( mockBallotCountsTableGroupBy({ groupByPrecinct: true }), [] ); - apiMock.expectGetCardCounts( + apiMock.deprecatedExpectGetCardCounts( mockBallotCountsTableGroupBy({ groupByVotingMethod: true }), [] ); if (isPrimary) { - apiMock.expectGetCardCounts( + apiMock.deprecatedExpectGetCardCounts( mockBallotCountsTableGroupBy({ groupByParty: true }), [] ); } - apiMock.expectGetCardCounts( + apiMock.deprecatedExpectGetCardCounts( mockBallotCountsTableGroupBy({ groupByScanner: true }), [] ); - apiMock.expectGetCardCounts({}, [overallCardCount]); + apiMock.deprecatedExpectGetCardCounts({}, [overallCardCount]); } diff --git a/apps/admin/frontend/test/helpers/mock_api_client.ts b/apps/admin/frontend/test/helpers/mock_api_client.ts index e5b0e2654e..2dcce64b7c 100644 --- a/apps/admin/frontend/test/helpers/mock_api_client.ts +++ b/apps/admin/frontend/test/helpers/mock_api_client.ts @@ -364,13 +364,53 @@ export function createApiMock( .resolves(ok([])); }, - expectGetCardCounts( + expectExportBallotCountReportCsv({ + path, + filter, + groupBy, + ballotCountBreakdown, + }: { + path: string; + filter?: Tabulation.Filter; + groupBy?: Tabulation.GroupBy; + ballotCountBreakdown: Tabulation.BallotCountBreakdown; + }) { + apiClient.exportBallotCountReportCsv + .expectCallWith({ path, groupBy, filter, ballotCountBreakdown }) + .resolves(ok([])); + }, + + deprecatedExpectGetCardCounts( groupBy: Tabulation.GroupBy, result: Array> ) { apiClient.getCardCounts.expectCallWith({ groupBy }).resolves(result); }, + expectGetCardCounts( + input: { + filter?: Tabulation.Filter; + groupBy?: Tabulation.GroupBy; + }, + results: Array>, + deferResult = false + ) { + const { promise, resolve } = + deferred>(); + + apiClient.getCardCounts.expectCallWith(input).returns(promise); + + if (!deferResult) { + resolve(results); + } + + return { + resolve: () => { + resolve(results); + }, + }; + }, + expectGetScannerBatches(result: ScannerBatch[]) { apiClient.getScannerBatches.expectCallWith().resolves(result); }, diff --git a/apps/admin/frontend/test/helpers/mock_results.ts b/apps/admin/frontend/test/helpers/mock_results.ts index 5eebe2049c..77951a0962 100644 --- a/apps/admin/frontend/test/helpers/mock_results.ts +++ b/apps/admin/frontend/test/helpers/mock_results.ts @@ -96,3 +96,15 @@ export function getSimpleMockTallyResults({ cardCounts: scannedResults.cardCounts, }; } + +export function getMockCardCounts( + bmd: number, + manual?: number, + ...hmpb: number[] +): Tabulation.CardCounts { + return { + bmd, + manual, + hmpb, + }; +} diff --git a/libs/ui/src/reports/ballot_count_report.test.tsx b/libs/ui/src/reports/ballot_count_report.test.tsx index b389b7ec3d..192701f0ee 100644 --- a/libs/ui/src/reports/ballot_count_report.test.tsx +++ b/libs/ui/src/reports/ballot_count_report.test.tsx @@ -155,7 +155,7 @@ test('can render all attribute columns', () => { { 'ballot-style': '1M', batch: 'batch-10', - party: 'Ma', + party: 'Mammal', precinct: 'Precinct 1', scanner: 'scanner-1', total: '5', @@ -164,7 +164,7 @@ test('can render all attribute columns', () => { { 'ballot-style': '2F', batch: 'batch-20', - party: 'F', + party: 'Fish', precinct: 'Precinct 2', scanner: 'scanner-2', total: '5', @@ -386,6 +386,7 @@ test('title, metadata, and custom filters', () => { ); screen.getByText('Custom Filter Ballot Count Report'); + screen.getByText('Example Primary Election'); expect(screen.getByTestId('custom-filter-summary').textContent).toEqual( 'Precinct: Precinct 1' ); diff --git a/libs/ui/src/reports/ballot_count_report.tsx b/libs/ui/src/reports/ballot_count_report.tsx index 4d97f2d2f5..d564ece089 100644 --- a/libs/ui/src/reports/ballot_count_report.tsx +++ b/libs/ui/src/reports/ballot_count_report.tsx @@ -319,7 +319,7 @@ function BallotCountTable({ content = getPartyById( electionDefinition, assertDefined(partyId) - ).abbrev; + ).name; break; case 'voting-method': content = @@ -438,6 +438,7 @@ export function BallotCountReport({

{title}

+

{electionDefinition.election.title}

{customFilter && (