From b1ce1f20bebf825abf4491bf43dd57941f2ed4a3 Mon Sep 17 00:00:00 2001 From: "adghayes@gmail.com" Date: Tue, 3 Oct 2023 18:13:54 +0300 Subject: [PATCH] add ballot count report builder screen --- apps/admin/frontend/src/api.ts | 11 +- .../ballot_count_report_builder.test.tsx | 221 ++++++++++++++++++ .../screens/ballot_count_report_builder.tsx | 179 ++++++++++++++ 3 files changed, 409 insertions(+), 2 deletions(-) create mode 100644 apps/admin/frontend/src/screens/ballot_count_report_builder.test.tsx create mode 100644 apps/admin/frontend/src/screens/ballot_count_report_builder.tsx diff --git a/apps/admin/frontend/src/api.ts b/apps/admin/frontend/src/api.ts index 20b6f9b7075..7ede1ef8812 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; 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 00000000000..ddda04470d6 --- /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 00000000000..8f304d722f6 --- /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" + /> + + +
+ ); +}