diff --git a/apps/admin/frontend/src/components/navigation_screen.tsx b/apps/admin/frontend/src/components/navigation_screen.tsx index 0a12b45eddd..ba2151a37bc 100644 --- a/apps/admin/frontend/src/components/navigation_screen.tsx +++ b/apps/admin/frontend/src/components/navigation_screen.tsx @@ -2,12 +2,11 @@ import React, { useContext } from 'react'; import { BatteryStatus, - Button, DateTimeDisplay, - IconName, Icons, Toolbar, LockMachineButton, + UsbEjectButton, MainHeader, MainContent, Screen, @@ -17,7 +16,6 @@ import { Route, Breadcrumbs, } from '@votingworks/ui'; -import type { UsbDriveStatus } from '@votingworks/usb-drive'; import { BooleanEnvironmentVariableName, isElectionManagerAuth, @@ -166,48 +164,6 @@ function shouldShowToolbar( } } -const ToolbarButton = styled(Button)` - font-size: 0.8rem; - padding: 0.25rem 0.75rem; -`; - -type ExtendedUsbDriveStatus = UsbDriveStatus['status'] | 'ejecting'; -const USB_BUTTON_ICON_AND_TEXT: Record< - ExtendedUsbDriveStatus, - [IconName, string] -> = { - no_drive: ['Disabled', 'No USB'], - error: ['Disabled', 'No USB'], - mounted: ['Eject', 'Eject USB'], - ejecting: ['Eject', 'Ejecting...'], - ejected: ['Disabled', 'USB Ejected'], -}; - -function UsbEjectButton({ - usbDriveStatus, - onEject, - isEjecting, -}: { - usbDriveStatus: UsbDriveStatus; - onEject: () => void; - isEjecting: boolean; -}): JSX.Element { - const extendedStatus: ExtendedUsbDriveStatus = isEjecting - ? 'ejecting' - : usbDriveStatus.status; - const [icon, text] = USB_BUTTON_ICON_AND_TEXT[extendedStatus]; - return ( - - {text} - - ); -} - export const Header = styled(MainHeader)` display: flex; align-items: center; diff --git a/apps/central-scan/frontend/src/components/network_section.test.tsx b/apps/central-scan/frontend/src/components/network_section.test.tsx index 3863531a244..4f30bce9a26 100644 --- a/apps/central-scan/frontend/src/components/network_section.test.tsx +++ b/apps/central-scan/frontend/src/components/network_section.test.tsx @@ -6,19 +6,23 @@ import { NetworkSection } from './network_section.js'; const testCases: Array<{ connection: NetworkConnectionInfo; expectedText: string; + expectedIcon: string; }> = [ { connection: { status: 'offline' }, expectedText: 'Offline', + expectedIcon: 'triangle-exclamation', }, { connection: { status: 'online-waiting-for-host' }, expectedText: 'Online — no VxAdmin detected on the network', + expectedIcon: 'triangle-exclamation', }, { connection: { status: 'online-multiple-hosts-detected' }, expectedText: 'Multiple VxAdmins detected on the network. Ensure only one VxAdmin is connected.', + expectedIcon: 'circle-exclamation', }, { connection: { @@ -26,6 +30,7 @@ const testCases: Array<{ hostMachineId: '0002', }, expectedText: 'VxAdmin (0002) is running a different software version', + expectedIcon: 'circle-exclamation', }, { connection: { @@ -34,11 +39,13 @@ const testCases: Array<{ }, expectedText: 'VxAdmin (0002) detected on the network. Configure this machine with an election to connect.', + expectedIcon: 'triangle-exclamation', }, { connection: { status: 'online-host-unconfigured', hostMachineId: '0002' }, expectedText: 'VxAdmin (0002) detected on the network, but it is not configured with an election.', + expectedIcon: 'triangle-exclamation', }, { connection: { @@ -46,22 +53,28 @@ const testCases: Array<{ hostMachineId: '0002', }, expectedText: 'VxAdmin (0002) is configured for a different election', + expectedIcon: 'triangle-exclamation', }, { connection: { status: 'online-host-detected', hostMachineId: '0002' }, expectedText: 'Online — VxAdmin (0002) detected on the network', + expectedIcon: 'square-check', }, ]; test.each(testCases)( 'renders $connection.status', - ({ connection, expectedText }) => { + ({ connection, expectedText, expectedIcon }) => { const { unmount } = render(); screen.getByText('Network'); + const message = screen.getByText( + (_, element) => element?.textContent?.trim() === expectedText + ); + expect(message).toBeInTheDocument(); + // The icon severity matches the top-bar network status indicator's + // bucket for this status expect( - screen.getByText( - (_, element) => element?.textContent?.trim() === expectedText - ) + message.querySelector(`[data-icon='${expectedIcon}']`) ).toBeInTheDocument(); unmount(); } diff --git a/apps/central-scan/frontend/src/components/network_section.tsx b/apps/central-scan/frontend/src/components/network_section.tsx index 7eff1084e54..9c30e636d1b 100644 --- a/apps/central-scan/frontend/src/components/network_section.tsx +++ b/apps/central-scan/frontend/src/components/network_section.tsx @@ -18,41 +18,43 @@ function ConnectionStatusMessage({ case 'offline': return (

- Offline + Offline

); case 'online-waiting-for-host': return (

- Online — no VxAdmin detected on the network + Online — no VxAdmin detected + on the network

); case 'online-multiple-hosts-detected': return (

- Multiple VxAdmins detected on the + Multiple VxAdmins detected on the network. Ensure only one VxAdmin is connected.

); case 'online-code-version-mismatch': return (

- VxAdmin ({connection.hostMachineId}) + VxAdmin ({connection.hostMachineId}) is running a different software version

); case 'online-machine-unconfigured': return (

- VxAdmin ({connection.hostMachineId}) detected on the - network. Configure this machine with an election to connect. + VxAdmin ({connection.hostMachineId}) + detected on the network. Configure this machine with an election to + connect.

); case 'online-host-unconfigured': return (

- VxAdmin ({connection.hostMachineId}) detected on the - network, but it is not configured with an election. + VxAdmin ({connection.hostMachineId}) + detected on the network, but it is not configured with an election.

); case 'online-ballot-hash-mismatch': diff --git a/apps/central-scan/frontend/src/components/network_status_indicator.test.tsx b/apps/central-scan/frontend/src/components/network_status_indicator.test.tsx new file mode 100644 index 00000000000..286b0ddceef --- /dev/null +++ b/apps/central-scan/frontend/src/components/network_status_indicator.test.tsx @@ -0,0 +1,132 @@ +import { expect, test } from 'vitest'; +import { createMemoryHistory } from 'history'; +import userEvent from '@testing-library/user-event'; +import type { NetworkConnectionInfo } from '@votingworks/central-scan-backend'; +import { renderInAppContext } from '../../test/render_in_app_context.js'; +import { createApiMock } from '../../test/api.js'; +import { screen, waitFor } from '../../test/react_testing_library.js'; +import { NetworkStatusIndicator } from './network_status_indicator.js'; + +test('renders nothing when networking is disabled', async () => { + const apiMock = createApiMock(); + const { container } = renderInAppContext(, { + apiMock, + }); + await waitFor(() => + expect(apiMock.apiClient.getNetworkStatus).toHaveBeenCalled() + ); + expect(container).toBeEmptyDOMElement(); +}); + +const testCases: Array<{ + connection: NetworkConnectionInfo; + expectedLabel: string; + expectedTreatment: 'connected' | 'warning' | 'error'; +}> = [ + { + connection: { status: 'offline' }, + expectedLabel: 'No Network', + expectedTreatment: 'warning', + }, + { + connection: { status: 'online-waiting-for-host' }, + expectedLabel: 'No VxAdmin Connected', + expectedTreatment: 'warning', + }, + { + connection: { + status: 'online-machine-unconfigured', + hostMachineId: '0002', + }, + expectedLabel: 'No VxAdmin Connected', + expectedTreatment: 'warning', + }, + { + connection: { status: 'online-host-unconfigured', hostMachineId: '0002' }, + expectedLabel: 'No VxAdmin Connected', + expectedTreatment: 'warning', + }, + { + connection: { + status: 'online-ballot-hash-mismatch', + hostMachineId: '0002', + }, + expectedLabel: 'No VxAdmin Connected', + expectedTreatment: 'warning', + }, + { + connection: { status: 'online-multiple-hosts-detected' }, + expectedLabel: 'Network Error', + expectedTreatment: 'error', + }, + { + connection: { + status: 'online-code-version-mismatch', + hostMachineId: '0002', + }, + expectedLabel: 'Network Error', + expectedTreatment: 'error', + }, + { + connection: { status: 'online-host-detected', hostMachineId: '0002' }, + expectedLabel: 'Connected', + expectedTreatment: 'connected', + }, +]; + +test.each(testCases)( + 'renders $connection.status', + async ({ connection, expectedLabel, expectedTreatment }) => { + const apiMock = createApiMock(); + apiMock.setNetworkStatus({ isEnabled: true, connection }); + const { unmount } = renderInAppContext(, { + apiMock, + }); + const indicator = await screen.findByTestId('network-status'); + expect(indicator).toHaveTextContent(expectedLabel); + switch (expectedTreatment) { + // Connected states show the plain network icon + case 'connected': + expect( + indicator.querySelector(`[data-icon='sitemap']`) + ).toBeInTheDocument(); + expect(indicator.querySelectorAll('[data-icon]')).toHaveLength(1); + expect( + indicator.querySelector(`[data-testid='network-off-icon']`) + ).not.toBeInTheDocument(); + break; + // Warning states show the slashed network icon + case 'warning': + expect( + indicator.querySelector(`[data-testid='network-off-icon']`) + ).toBeInTheDocument(); + expect(indicator.querySelectorAll('[data-icon]')).toHaveLength(0); + break; + // Error states show the slashed network icon with a danger icon next + // to it + case 'error': + expect( + indicator.querySelector(`[data-testid='network-off-icon']`) + ).toBeInTheDocument(); + expect( + indicator.querySelector(`[data-icon='circle-exclamation']`) + ).toBeInTheDocument(); + break; + default: + throw new Error('unreachable'); + } + unmount(); + } +); + +test('clicking the status navigates to the diagnostics page', async () => { + const apiMock = createApiMock(); + apiMock.setNetworkStatus({ + isEnabled: true, + connection: { status: 'online-host-detected', hostMachineId: '0002' }, + }); + const history = createMemoryHistory(); + renderInAppContext(, { apiMock, history }); + userEvent.click(await screen.findByTestId('network-status')); + expect(history.location.pathname).toEqual('/hardware-diagnostics'); +}); diff --git a/apps/central-scan/frontend/src/components/network_status_indicator.tsx b/apps/central-scan/frontend/src/components/network_status_indicator.tsx new file mode 100644 index 00000000000..5c52c4d1fe6 --- /dev/null +++ b/apps/central-scan/frontend/src/components/network_status_indicator.tsx @@ -0,0 +1,46 @@ +import { throwIllegalValue } from '@votingworks/basics'; +import type { NetworkConnectionInfo } from '@votingworks/central-scan-backend'; +import { + NetworkIndicatorStatus, + NetworkStatusIndicator as NetworkStatusIndicatorView, +} from '@votingworks/ui'; +import { useHistory } from 'react-router-dom'; +import { getNetworkStatus } from '../api.js'; + +function indicatorStatus( + connection: NetworkConnectionInfo +): NetworkIndicatorStatus { + const { status } = connection; + switch (status) { + case 'online-host-detected': + return 'connected'; + case 'offline': + return 'no-network'; + case 'online-waiting-for-host': + case 'online-machine-unconfigured': + case 'online-host-unconfigured': + case 'online-ballot-hash-mismatch': + return 'no-host-connected'; + case 'online-multiple-hosts-detected': + case 'online-code-version-mismatch': + return 'error'; + // istanbul ignore next -- compile-time check + default: + return throwIllegalValue(status); + } +} + +export function NetworkStatusIndicator(): JSX.Element | null { + const history = useHistory(); + const networkStatusQuery = getNetworkStatus.useQuery(); + if (!networkStatusQuery.isSuccess || !networkStatusQuery.data.isEnabled) { + return null; + } + + return ( + history.push('/hardware-diagnostics')} + /> + ); +} diff --git a/apps/central-scan/frontend/src/navigation_screen.tsx b/apps/central-scan/frontend/src/navigation_screen.tsx index 2e3be0cfeb1..2b0c37d8891 100644 --- a/apps/central-scan/frontend/src/navigation_screen.tsx +++ b/apps/central-scan/frontend/src/navigation_screen.tsx @@ -1,9 +1,10 @@ import { AppLogo, - BatteryDisplay, - Button, + BatteryStatus, + DateTimeDisplay, H1, LeftNav, + LockMachineButton, Main, MainContent, MainHeader, @@ -13,7 +14,8 @@ import { Screen, SessionTimeLimitTimer, TestModeBanner, - UsbControllerButton, + Toolbar, + UsbEjectButton, VerticalElectionInfoBar, } from '@votingworks/ui'; import styled from 'styled-components'; @@ -25,7 +27,8 @@ import { import { DippedSmartCardAuth, ElectionDefinition } from '@votingworks/types'; import { Link, useRouteMatch } from 'react-router-dom'; import { AppContext } from './contexts/app_context.js'; -import { ejectUsbDrive, logOut } from './api.js'; +import { ejectUsbDrive, logOut, systemCallApi } from './api.js'; +import { NetworkStatusIndicator } from './components/network_status_indicator.js'; interface Props { children: React.ReactNode; @@ -39,13 +42,6 @@ export const Header = styled(MainHeader)` gap: 0.5rem; `; -const HeaderActions = styled.div` - display: flex; - gap: 0.5rem; - align-items: center; - flex-shrink: 0; -`; - // Because the VxCentralScan is such a long app name, we have to resize the app // name and logo image to fit in the left nav const CentralScanAppLogo = styled(AppLogo)` @@ -98,8 +94,11 @@ export function NavigationScreen({ children, title }: Props): JSX.Element { } = useContext(AppContext); const logOutMutation = logOut.useMutation(); const ejectUsbDriveMutation = ejectUsbDrive.useMutation(); + const batteryInfoQuery = systemCallApi.getBatteryInfo.useQuery(); const currentRoute = useRouteMatch(); const navItems = getNavItems(auth, electionDefinition); + const showToolbar = + isSystemAdministratorAuth(auth) || isElectionManagerAuth(auth); function isActivePath(path: string): boolean { return currentRoute.path.startsWith(path); @@ -132,28 +131,27 @@ export function NavigationScreen({ children, title }: Props): JSX.Element {
+ {showToolbar && ( + + + {batteryInfoQuery.isSuccess && batteryInfoQuery.data && ( + + )} + + ejectUsbDriveMutation.mutate()} + isEjecting={ejectUsbDriveMutation.isLoading} + /> + logOutMutation.mutate()} /> + + )} {isTestMode && isElectionManagerAuth(auth) && electionDefinition && ( )}

{title}

- - {(isSystemAdministratorAuth(auth) || - isElectionManagerAuth(auth)) && ( - - ejectUsbDriveMutation.mutate()} - usbDriveStatus={usbDriveStatus} - usbDriveIsEjecting={ejectUsbDriveMutation.isLoading} - /> - - - - )} -
{children}
diff --git a/apps/central-scan/frontend/test/api.tsx b/apps/central-scan/frontend/test/api.tsx index d11b90ab9d0..39ee141f2ce 100644 --- a/apps/central-scan/frontend/test/api.tsx +++ b/apps/central-scan/frontend/test/api.tsx @@ -32,12 +32,13 @@ import { screen } from './react_testing_library.js'; export type MockApiClient = Omit< MockClient, - 'getBatteryInfo' | 'getDiskSpaceSummary' + 'getBatteryInfo' | 'getDiskSpaceSummary' | 'getNetworkStatus' > & { // Because these are polled so frequently, we opt for a standard vitest mock instead of a // libs/test-utils mock since the latter requires every call to be explicitly mocked getBatteryInfo: Mock; getDiskSpaceSummary: Mock; + getNetworkStatus: Mock; }; export function createMockApiClient(): MockApiClient { @@ -47,6 +48,12 @@ export function createMockApiClient(): MockApiClient { (mockApiClient.getBatteryInfo as unknown as Mock) = vi.fn(() => Promise.resolve({ level: 1, discharging: false }) ); + (mockApiClient.getNetworkStatus as unknown as Mock) = vi.fn(() => + Promise.resolve({ + isEnabled: false, + connection: { status: 'offline' }, + }) + ); (mockApiClient.getDiskSpaceSummary as unknown as Mock) = vi.fn(() => Promise.resolve({ total: 3, used: 2, available: 1 }) ); @@ -119,9 +126,7 @@ export function createApiMock( connection: { status: 'offline' }, } ) { - apiClient.getNetworkStatus - .expectRepeatedCallsWith() - .resolves(networkStatus); + apiClient.getNetworkStatus.mockResolvedValue(networkStatus); }, expectGetElectionRecord(electionDefinition: ElectionDefinition | null) { diff --git a/libs/types/src/ui_theme.ts b/libs/types/src/ui_theme.ts index cb4b856f106..070396b99dd 100644 --- a/libs/types/src/ui_theme.ts +++ b/libs/types/src/ui_theme.ts @@ -78,6 +78,7 @@ export interface ColorTheme { readonly inverseContainer: ColorString; readonly inversePrimary: ColorString; readonly inverseWarningAccent: ColorString; + readonly inverseDangerAccent: ColorString; readonly dangerAccent: ColorString; readonly warningAccent: ColorString; diff --git a/libs/ui/src/icons.test.tsx b/libs/ui/src/icons.test.tsx index 5c5f08710f2..ce94986e52e 100644 --- a/libs/ui/src/icons.test.tsx +++ b/libs/ui/src/icons.test.tsx @@ -28,6 +28,7 @@ test(`Icon renders with color`, () => { inverse: theme.colors.onInverse, inversePrimary: theme.colors.inversePrimary, inverseWarning: theme.colors.inverseWarningAccent, + inverseDanger: theme.colors.inverseDangerAccent, }; for (const [color, expectedColor] of Object.entries(expectedColors)) { diff --git a/libs/ui/src/icons.tsx b/libs/ui/src/icons.tsx index 348ec4fc310..90ea6296d2b 100644 --- a/libs/ui/src/icons.tsx +++ b/libs/ui/src/icons.tsx @@ -123,6 +123,7 @@ export const ICON_COLORS = [ 'inverse', 'inversePrimary', 'inverseWarning', + 'inverseDanger', ] as const; export type IconColor = (typeof ICON_COLORS)[number]; @@ -171,6 +172,7 @@ function iconColor(theme: UiTheme, color?: IconColor) { inverse: colors.onInverse, inversePrimary: colors.inversePrimary, inverseWarning: colors.inverseWarningAccent, + inverseDanger: colors.inverseDangerAccent, default: undefined, }[color]; } diff --git a/libs/ui/src/index.ts b/libs/ui/src/index.ts index 667d13eaa77..5e1f5d3e23f 100644 --- a/libs/ui/src/index.ts +++ b/libs/ui/src/index.ts @@ -128,6 +128,7 @@ export * from './fonts/roboto'; export * from './battery_display'; export * from './battery_low_alert'; export * from './toolbar'; +export * from './network_status_indicator'; export * from './fonts/font_awesome_styles'; export * from './save_readiness_report_button'; export * from './tabs'; diff --git a/libs/ui/src/network_status_indicator.test.tsx b/libs/ui/src/network_status_indicator.test.tsx new file mode 100644 index 00000000000..bfcea2e418e --- /dev/null +++ b/libs/ui/src/network_status_indicator.test.tsx @@ -0,0 +1,91 @@ +import { expect, test, vi } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { render, screen } from '../test/react_testing_library'; + +import { + NetworkIndicatorStatus, + NetworkStatusIndicator, +} from './network_status_indicator'; + +const testCases: Array<{ + status: NetworkIndicatorStatus; + expectedLabel: string; + expectedTreatment: 'connected' | 'warning' | 'error'; +}> = [ + { + status: 'connected', + expectedLabel: 'Connected', + expectedTreatment: 'connected', + }, + { + status: 'no-host-connected', + expectedLabel: 'No VxAdmin Connected', + expectedTreatment: 'warning', + }, + { + status: 'no-network', + expectedLabel: 'No Network', + expectedTreatment: 'warning', + }, + { + status: 'error', + expectedLabel: 'Network Error', + expectedTreatment: 'error', + }, +]; + +test.each(testCases)( + 'renders $status', + ({ status, expectedLabel, expectedTreatment }) => { + const { unmount } = render(); + const indicator = screen.getByTestId('network-status'); + expect(indicator).toHaveTextContent(expectedLabel); + switch (expectedTreatment) { + // Connected states show the plain network icon + case 'connected': + expect( + indicator.querySelector(`[data-icon='sitemap']`) + ).toBeInTheDocument(); + expect(indicator.querySelectorAll('[data-icon]')).toHaveLength(1); + expect( + indicator.querySelector(`[data-testid='network-off-icon']`) + ).not.toBeInTheDocument(); + break; + // Warning states show the slashed network icon + case 'warning': + expect( + indicator.querySelector(`[data-testid='network-off-icon']`) + ).toBeInTheDocument(); + expect(indicator.querySelectorAll('[data-icon]')).toHaveLength(0); + break; + // Error states show the slashed network icon with a danger icon next + // to it + case 'error': + expect( + indicator.querySelector(`[data-testid='network-off-icon']`) + ).toBeInTheDocument(); + expect( + indicator.querySelector(`[data-icon='circle-exclamation']`) + ).toBeInTheDocument(); + break; + /* istanbul ignore next - compile-time check */ + default: + throw new Error('unreachable'); + } + unmount(); + } +); + +test('host machines label the connected state as network online', () => { + render(); + const indicator = screen.getByTestId('network-status'); + expect(indicator).toHaveTextContent('Network Online'); + expect(indicator.querySelector(`[data-icon='sitemap']`)).toBeInTheDocument(); +}); + +test('calls onPress when clicked', () => { + const onPress = vi.fn(); + render(); + userEvent.click(screen.getByTestId('network-status')); + expect(onPress).toHaveBeenCalledTimes(1); +}); diff --git a/libs/ui/src/network_status_indicator.tsx b/libs/ui/src/network_status_indicator.tsx new file mode 100644 index 00000000000..10b6ebfead2 --- /dev/null +++ b/libs/ui/src/network_status_indicator.tsx @@ -0,0 +1,154 @@ +import React, { useId } from 'react'; +import styled from 'styled-components'; +import { Icons } from './icons'; + +/** + * The network statuses a machine's toolbar indicator can display, grouped by + * severity: + * - `connected` — neutral; on the network and connected to a host (or, for + * the host itself, online). + * - `no-host-connected` — warning; online but not connected to a compatible + * VxAdmin host (none detected, or one detected but not connectable, e.g. + * configured for a different election). Not applicable to the host itself. + * - `no-network` — warning; no network connection. + * - `error` — danger; a network conflict that blocks all connections (e.g. + * multiple hosts detected or an incompatible software version). + */ +export type NetworkIndicatorStatus = + | 'connected' + | 'no-host-connected' + | 'no-network' + | 'error'; + +/** + * Network statuses applicable to the VxAdmin host machine itself, which is + * never waiting on a VxAdmin connection. + */ +export type HostNetworkIndicatorStatus = Exclude< + NetworkIndicatorStatus, + 'no-host-connected' +>; + +const IndicatorButton = styled.button` + display: flex; + flex-direction: row; + gap: 0.4rem; + align-items: center; + white-space: nowrap; + background: none; + border: none; + padding: 0; + font: inherit; + color: inherit; + cursor: pointer; +`; + +/** + * A slashed variant of the network (sitemap) icon, used for warning and error + * states. Not part of FontAwesome, so it's inlined here. Sized and aligned to + * match the FontAwesome icons rendered by `Icons`. + */ +const NetworkOffSvg = styled.svg` + height: 1em; + width: 1em; + vertical-align: -0.125em; + color: ${(p) => p.theme.colors.onInverse}; +`; + +function NetworkOffIcon(): JSX.Element { + const maskId = useId(); + return ( + + ); +} + +export type NetworkStatusIndicatorProps = { + onPress?: () => void; +} & ( + | { isHost: true; status: HostNetworkIndicatorStatus } + | { isHost?: false; status: NetworkIndicatorStatus } +); + +/** + * A toolbar indicator showing a machine's network status. Rendered on inverse + * (dark) toolbar backgrounds. + */ +export function NetworkStatusIndicator( + props: NetworkStatusIndicatorProps +): JSX.Element { + const { onPress, isHost, status } = props; + + const contents: Record< + NetworkIndicatorStatus, + { icon: JSX.Element; label: string } + > = { + connected: { + icon: , + label: isHost ? 'Network Online' : 'Connected', + }, + 'no-host-connected': { + icon: , + label: 'No VxAdmin Connected', + }, + 'no-network': { + icon: , + label: 'No Network', + }, + error: { + icon: ( + + + + + ), + label: 'Network Error', + }, + }; + const { icon, label } = contents[status]; + + return ( + + {icon} + {label} + + ); +} diff --git a/libs/ui/src/themes/color_theme.stories.tsx b/libs/ui/src/themes/color_theme.stories.tsx index f821238fd8f..5705fb17ae0 100644 --- a/libs/ui/src/themes/color_theme.stories.tsx +++ b/libs/ui/src/themes/color_theme.stories.tsx @@ -487,6 +487,20 @@ export function ColorThemes(): JSX.Element { + + + + + Inverse Danger Accent + + + + = { inversePrimary: DesktopPalette.Purple30, inverseContainer: DesktopPalette.Gray80, inverseWarningAccent: DesktopPalette.Orange30, + inverseDangerAccent: DesktopPalette.Red40, successAccent: DesktopPalette.Green60, warningAccent: DesktopPalette.Orange50, diff --git a/libs/ui/src/toolbar.test.tsx b/libs/ui/src/toolbar.test.tsx index 3e1ba510230..d424cb6e9a2 100644 --- a/libs/ui/src/toolbar.test.tsx +++ b/libs/ui/src/toolbar.test.tsx @@ -2,11 +2,13 @@ import { expect, test, vi } from 'vitest'; import userEvent from '@testing-library/user-event'; import { render, screen } from '../test/react_testing_library'; +import { mockUsbDriveStatus } from './test-utils/mock_usb_drive'; import { BatteryStatus, DateTimeDisplay, LockMachineButton, Toolbar, + UsbEjectButton, } from './toolbar'; vi.useFakeTimers({ @@ -71,3 +73,51 @@ test('Toolbar renders children', () => { ); screen.getByText('test content'); }); + +test('UsbEjectButton ejects a mounted drive', () => { + const onEject = vi.fn(); + render( + + ); + const button = screen.getByRole('button', { name: /Eject USB/ }); + expect(button).toBeEnabled(); + userEvent.click(button); + expect(onEject).toHaveBeenCalledTimes(1); +}); + +test('UsbEjectButton is disabled while ejecting', () => { + render( + + ); + expect(screen.getByRole('button', { name: /Ejecting/ })).toBeDisabled(); +}); + +test('UsbEjectButton is disabled without a drive', () => { + render( + + ); + expect(screen.getByRole('button', { name: /No USB/ })).toBeDisabled(); +}); + +test('UsbEjectButton shows ejected state', () => { + render( + + ); + expect(screen.getByRole('button', { name: /USB Ejected/ })).toBeDisabled(); +}); diff --git a/libs/ui/src/toolbar.tsx b/libs/ui/src/toolbar.tsx index 6eff511b649..f81a67e378e 100644 --- a/libs/ui/src/toolbar.tsx +++ b/libs/ui/src/toolbar.tsx @@ -1,10 +1,11 @@ import { useState, useEffect } from 'react'; import styled from 'styled-components'; import type { BatteryInfo } from '@votingworks/backend'; +import type { UsbDriveStatus } from '@votingworks/usb-drive'; import { format } from '@votingworks/utils'; import { Button } from './button'; import { getBatteryIcon } from './battery_display'; -import { Icons } from './icons'; +import { IconName, Icons } from './icons'; export const Toolbar = styled.div` display: flex; @@ -70,6 +71,43 @@ export function DateTimeDisplay(): JSX.Element { return {format.clockDateAndTime(currentDate)}; } +type ExtendedUsbDriveStatus = UsbDriveStatus['status'] | 'ejecting'; +const USB_BUTTON_ICON_AND_TEXT: Record< + ExtendedUsbDriveStatus, + [IconName, string] +> = { + no_drive: ['Disabled', 'No USB'], + error: ['Disabled', 'No USB'], + mounted: ['Eject', 'Eject USB'], + ejecting: ['Eject', 'Ejecting...'], + ejected: ['Disabled', 'USB Ejected'], +}; + +export function UsbEjectButton({ + usbDriveStatus, + onEject, + isEjecting, +}: { + usbDriveStatus: UsbDriveStatus; + onEject: () => void; + isEjecting: boolean; +}): JSX.Element { + const extendedStatus: ExtendedUsbDriveStatus = isEjecting + ? 'ejecting' + : usbDriveStatus.status; + const [icon, text] = USB_BUTTON_ICON_AND_TEXT[extendedStatus]; + return ( + + {text} + + ); +} + export function LockMachineButton({ onLock, }: {