Skip to content

Commit

Permalink
Block relevant actions when a CVR sync is detected as necessary
Browse files Browse the repository at this point in the history
- Closing polls
- Switching from official mode to test mode (assuming ballots have
  been counted)
- Deleting election data
  • Loading branch information
arsalansufi committed Oct 4, 2023
1 parent 8f7d6e1 commit 9615ad3
Show file tree
Hide file tree
Showing 3 changed files with 167 additions and 78 deletions.
54 changes: 47 additions & 7 deletions apps/scan/frontend/src/components/cast_vote_record_sync_modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,29 @@ import {

import { exportCastVoteRecordsToUsbDrive, getUsbDriveStatus } from '../api';

type BlockedAction =
| 'close_polls'
| 'delete_election_data'
| 'switch_to_test_mode';

export const CAST_VOTE_RECORD_SYNC_MODAL_PROMPTS: Record<
BlockedAction | 'default',
string
> = {
default:
'The inserted USB drive does not contain up-to-date records of the votes cast at this scanner. ' +
'Cast vote records (CVRs) need to be synced to the USB drive.',
close_polls:
'Cast vote records (CVRs) need to be synced to the inserted USB drive before you can close polls. ' +
'Remove your poll worker card to sync.',
delete_election_data:
'Cast vote records (CVRs) need to be synced to the inserted USB drive before you can delete election data. ' +
'Remove your election manager card to sync.',
switch_to_test_mode:
'Cast vote records (CVRs) need to be synced to the inserted USB drive before you can switch to test mode. ' +
'Remove your election manager card to sync.',
};

type ModalState = 'closed' | 'prompt' | 'syncing' | 'success' | 'error';

export function CastVoteRecordSyncModal(): JSX.Element | null {
Expand Down Expand Up @@ -53,13 +76,7 @@ export function CastVoteRecordSyncModal(): JSX.Element | null {
return (
<Modal
title="CVR Sync Required"
content={
<P>
The inserted USB drive does not contain up-to-date records of the
votes cast at this scanner. Cast vote records (CVRs) need to be
synced to the USB drive.
</P>
}
content={<P>{CAST_VOTE_RECORD_SYNC_MODAL_PROMPTS.default}</P>}
actions={
<Button variant="primary" onPress={syncCastVoteRecords}>
Sync CVRs
Expand Down Expand Up @@ -95,3 +112,26 @@ export function CastVoteRecordSyncModal(): JSX.Element | null {
}
}
}

interface CastVoteRecordSyncReminderModalProps {
blockedAction: BlockedAction;
closeModal: () => void;
}

/**
* A secondary modal for explaining that an action is blocked until cast vote records have been
* synced. Nudges users toward the primary {@link CastVoteRecordSyncModal}.
*/
export function CastVoteRecordSyncReminderModal({
blockedAction,
closeModal,
}: CastVoteRecordSyncReminderModalProps): JSX.Element {
return (
<Modal
title="CVR Sync Required"
content={<P>{CAST_VOTE_RECORD_SYNC_MODAL_PROMPTS[blockedAction]}</P>}
actions={<Button onPress={closeModal}>Cancel</Button>}
onOverlayClick={closeModal}
/>
);
}
168 changes: 97 additions & 71 deletions apps/scan/frontend/src/screens/election_manager_screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
CurrentDateAndTime,
Loading,
Modal,
Prose,
SegmentedButton,
SetClockButton,
ChangePrecinctButton,
Expand All @@ -25,6 +24,7 @@ import { Screen } from '../components/layout';
import {
ejectUsbDrive,
getConfig,
getUsbDriveStatus,
logOut,
setIsSoundMuted,
setIsUltrasonicDisabled,
Expand All @@ -35,6 +35,7 @@ import {
} from '../api';
import { usePreviewContext } from '../preview_dashboard';
import { LiveCheckButton } from '../components/live_check_button';
import { CastVoteRecordSyncReminderModal } from '../components/cast_vote_record_sync_modal';

export const SELECT_PRECINCT_TEXT = 'Select a precinct for this device…';

Expand All @@ -55,6 +56,7 @@ export function ElectionManagerScreen({
}: ElectionManagerScreenProps): JSX.Element | null {
const supportsUltrasonicQuery = supportsUltrasonic.useQuery();
const configQuery = getConfig.useQuery();
const usbDriveStatusQuery = getUsbDriveStatus.useQuery();
const setPrecinctSelectionMutation = setPrecinctSelection.useMutation();
const setTestModeMutation = setTestMode.useMutation();
const setIsSoundMutedMutation = setIsSoundMuted.useMutation();
Expand All @@ -63,17 +65,17 @@ export function ElectionManagerScreen({
const ejectUsbDriveMutation = ejectUsbDrive.useMutation();
const logOutMutation = logOut.useMutation();

const [
isShowingToggleTestModeWarningModal,
setIsShowingToggleTestModeWarningModal,
] = useState(false);
const [isConfirmingSwitchToTestMode, setIsConfirmingSwitchToTestMode] =
useState(false);

const [isExportingResults, setIsExportingResults] = useState(false);

const [confirmUnconfigure, setConfirmUnconfigure] = useState(false);
const [isConfirmingUnconfigure, setIsConfirmingUnconfigure] = useState(false);
const [isUnconfiguring, setIsUnconfiguring] = useState(false);

if (!configQuery.isSuccess) return null;
if (!configQuery.isSuccess || !usbDriveStatusQuery.isSuccess) {
return null;
}

const { election } = electionDefinition;
const {
Expand All @@ -83,16 +85,22 @@ export function ElectionManagerScreen({
isUltrasonicDisabled,
pollsState,
} = configQuery.data;
const doesUsbDriveRequireCastVoteRecordSync = Boolean(
usbDriveStatusQuery.data.doesUsbDriveRequireCastVoteRecordSync
);

function handleTogglingTestMode() {
if (!isTestMode) {
setIsShowingToggleTestModeWarningModal(true);
} else {
setTestModeMutation.mutate({ isTestMode: !isTestMode });
}
function switchMode() {
setTestModeMutation.mutate(
{ isTestMode: !isTestMode },
{
onSuccess() {
setIsConfirmingSwitchToTestMode(false);
},
}
);
}

function handleUnconfigure() {
function unconfigure() {
setIsUnconfiguring(true);
// If there is a mounted usb eject it so that it doesn't auto reconfigure the machine.
// TODO move this to the backend?
Expand Down Expand Up @@ -140,7 +148,13 @@ export function ElectionManagerScreen({
disabled={setTestModeMutation.isLoading}
label="Ballot Mode:"
hideLabel
onChange={handleTogglingTestMode}
onChange={() => {
if (!isTestMode && scannerStatus.ballotsCounted > 0) {
setIsConfirmingSwitchToTestMode(true);
return;
}
switchMode();
}}
options={[
{ id: 'test', label: 'Test Ballot Mode' },
{ id: 'official', label: 'Official Ballot Mode' },
Expand Down Expand Up @@ -201,7 +215,7 @@ export function ElectionManagerScreen({

const unconfigureElectionButton = (
<P>
<Button onPress={() => setConfirmUnconfigure(true)}>
<Button onPress={() => setIsConfirmingUnconfigure(true)}>
Delete All Election Data from VxScan
</Button>
</P>
Expand Down Expand Up @@ -254,67 +268,79 @@ export function ElectionManagerScreen({
},
]}
/>
{isShowingToggleTestModeWarningModal && (
<Modal
title="Save Backup to switch to Test Ballot Mode"
content={
<Prose>
<P>
You must &quot;Save Backup&quot; before you may switch to Test
Ballot Mode.
</P>
</Prose>

{isConfirmingSwitchToTestMode &&
(() => {
if (doesUsbDriveRequireCastVoteRecordSync) {
return (
<CastVoteRecordSyncReminderModal
blockedAction="switch_to_test_mode"
closeModal={() => setIsConfirmingSwitchToTestMode(false)}
/>
);
}
actions={
<React.Fragment>
<Button
variant="primary"
onPress={() => {
setIsShowingToggleTestModeWarningModal(false);
}}
>
Save Backup
</Button>
<Button
onPress={() => setIsShowingToggleTestModeWarningModal(false)}
>
Cancel
</Button>
</React.Fragment>
return (
<Modal
title="Switch to Test Mode?"
content={
<P>
Do you want to switch to test mode and clear the ballots
scanned at this scanner?
</P>
}
actions={
<React.Fragment>
<Button onPress={switchMode} variant="danger">
Yes, Switch
</Button>
<Button
onPress={() => setIsConfirmingSwitchToTestMode(false)}
>
Cancel
</Button>
</React.Fragment>
}
onOverlayClick={() => setIsConfirmingSwitchToTestMode(false)}
/>
);
})()}

{isConfirmingUnconfigure &&
(() => {
if (isUnconfiguring) {
return <Modal content={<Loading />} />;
}
onOverlayClick={() => setIsShowingToggleTestModeWarningModal(false)}
/>
)}
{confirmUnconfigure && (
<Modal
title={isUnconfiguring ? undefined : 'Delete All Election Data?'}
content={
isUnconfiguring ? (
<Loading />
) : (
<Prose>
if (doesUsbDriveRequireCastVoteRecordSync && !isTestMode) {
return (
<CastVoteRecordSyncReminderModal
blockedAction="delete_election_data"
closeModal={() => setIsConfirmingUnconfigure(false)}
/>
);
}
return (
<Modal
title="Delete All Election Data?"
content={
<P>
Do you want to remove all election information and data from
this machine?
</P>
</Prose>
)
}
actions={
!isUnconfiguring && (
<React.Fragment>
<Button variant="danger" onPress={handleUnconfigure}>
Yes, Delete All
</Button>
<Button onPress={() => setConfirmUnconfigure(false)}>
Cancel
</Button>
</React.Fragment>
)
}
onOverlayClick={() => setConfirmUnconfigure(false)}
/>
)}
}
actions={
<React.Fragment>
<Button onPress={unconfigure} variant="danger">
Yes, Delete All
</Button>
<Button onPress={() => setIsConfirmingUnconfigure(false)}>
Cancel
</Button>
</React.Fragment>
}
onOverlayClick={() => setIsConfirmingUnconfigure(false)}
/>
);
})()}

{isExportingResults && (
<ExportResultsModal
Expand Down
23 changes: 23 additions & 0 deletions apps/scan/frontend/src/screens/poll_worker_screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,14 @@ import { rootDebug } from '../utils/debug';
import {
exportCastVoteRecordsToUsbDrive,
getScannerResultsByParty,
getUsbDriveStatus,
setPollsState,
} from '../api';
import { MachineConfig } from '../config/types';
import { FullScreenPromptLayout } from '../components/full_screen_prompt_layout';
import { LiveCheckButton } from '../components/live_check_button';
import { getPageCount } from '../utils/get_page_count';
import { CastVoteRecordSyncReminderModal } from '../components/cast_vote_record_sync_modal';

export const REPRINT_REPORT_TIMEOUT_SECONDS = 4;

Expand Down Expand Up @@ -122,6 +124,7 @@ export function PollWorkerScreen({
}: PollWorkerScreenProps): JSX.Element {
const { election } = electionDefinition;
const scannerResultsByPartyQuery = getScannerResultsByParty.useQuery();
const usbDriveStatusQuery = getUsbDriveStatus.useQuery();
const setPollsStateMutation = setPollsState.useMutation();
const exportCastVoteRecordsMutation =
exportCastVoteRecordsToUsbDrive.useMutation();
Expand All @@ -130,6 +133,10 @@ export function PollWorkerScreen({
isShowingBallotsAlreadyScannedScreen,
setIsShowingBallotsAlreadyScannedScreen,
] = useState(false);
const [
isCastVoteRecordSyncReminderModalOpen,
setIsCastVoteRecordSyncReminderModalOpen,
] = useState(false);
const needsToAttachPrinterToTransitionPolls = !printerInfo && !!window.kiosk;

function initialPollWorkerFlowState(): Optional<PollWorkerFlowState> {
Expand Down Expand Up @@ -287,6 +294,10 @@ export function PollWorkerScreen({
}

function closePolls() {
if (usbDriveStatusQuery.data?.doesUsbDriveRequireCastVoteRecordSync) {
setIsCastVoteRecordSyncReminderModalOpen(true);
return;
}
return transitionPolls('close_polls');
}

Expand Down Expand Up @@ -375,6 +386,12 @@ export function PollWorkerScreen({
<P>Attach printer to continue.</P>
)}
</CenteredLargeProse>
{isCastVoteRecordSyncReminderModalOpen && (
<CastVoteRecordSyncReminderModal
blockedAction="close_polls"
closeModal={() => setIsCastVoteRecordSyncReminderModalOpen(false)}
/>
)}
</ScreenMainCenterChild>
);
}
Expand Down Expand Up @@ -544,6 +561,12 @@ export function PollWorkerScreen({
<H1>Poll Worker Actions</H1>
{content}
</Prose>
{isCastVoteRecordSyncReminderModalOpen && (
<CastVoteRecordSyncReminderModal
blockedAction="close_polls"
closeModal={() => setIsCastVoteRecordSyncReminderModalOpen(false)}
/>
)}
</ScreenMainCenterChild>
);
}
Expand Down

0 comments on commit 9615ad3

Please sign in to comment.