diff --git a/apps/mark-scan/frontend/src/api.ts b/apps/mark-scan/frontend/src/api.ts index d75e4c7938..b29888fc6b 100644 --- a/apps/mark-scan/frontend/src/api.ts +++ b/apps/mark-scan/frontend/src/api.ts @@ -11,6 +11,7 @@ import { import { AUTH_STATUS_POLLING_INTERVAL_MS, QUERY_CLIENT_DEFAULT_OPTIONS, + createUiStringsApi, } from '@votingworks/ui'; import { STATE_MACHINE_POLLING_INTERVAL_MS } from './constants'; @@ -193,6 +194,8 @@ export const endCardlessVoterSession = { }, } as const; +export const uiStringsApi = createUiStringsApi(useApiClient); + export const configureBallotPackageFromUsb = { useMutation() { const apiClient = useApiClient(); @@ -201,6 +204,7 @@ export const configureBallotPackageFromUsb = { async onSuccess() { await queryClient.invalidateQueries(getElectionDefinition.queryKey()); await queryClient.invalidateQueries(getSystemSettings.queryKey()); + await uiStringsApi.onMachineConfigurationChange(queryClient); }, }); }, @@ -215,6 +219,7 @@ export const unconfigureMachine = { await queryClient.invalidateQueries(getElectionDefinition.queryKey()); await queryClient.invalidateQueries(getSystemSettings.queryKey()); await queryClient.invalidateQueries(getPrecinctSelection.queryKey()); + await uiStringsApi.onMachineConfigurationChange(queryClient); }, }); }, diff --git a/apps/mark-scan/frontend/src/api_machine_configuration.test.tsx b/apps/mark-scan/frontend/src/api_machine_configuration.test.tsx new file mode 100644 index 0000000000..aef3c762c6 --- /dev/null +++ b/apps/mark-scan/frontend/src/api_machine_configuration.test.tsx @@ -0,0 +1,73 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ok } from '@votingworks/basics'; +import { electionGeneralDefinition } from '@votingworks/fixtures'; +import { renderHook, waitFor } from '../test/react_testing_library'; +import { + ApiClient, + ApiClientContext, + configureBallotPackageFromUsb, + createApiClient, + uiStringsApi, + unconfigureMachine, +} from './api'; + +const queryClient = new QueryClient(); +const mockBackendApi: ApiClient = { + ...createApiClient(), + configureBallotPackageFromUsb: jest.fn(), + unconfigureMachine: jest.fn(), +}; + +function QueryWrapper(props: { children: React.ReactNode }) { + const { children } = props; + + return ( + + {children} + + ); +} + +const mockOnConfigurationChange = jest.spyOn( + uiStringsApi, + 'onMachineConfigurationChange' +); + +afterAll(() => { + jest.restoreAllMocks(); +}); + +test('configureBallotPackageFromUsb', async () => { + jest + .mocked(mockBackendApi) + .configureBallotPackageFromUsb.mockResolvedValueOnce( + ok(electionGeneralDefinition) + ); + + const { result } = renderHook( + () => configureBallotPackageFromUsb.useMutation(), + { wrapper: QueryWrapper } + ); + + expect(mockOnConfigurationChange).not.toHaveBeenCalled(); + + result.current.mutate(); + await waitFor(() => expect(result.current.isSuccess).toEqual(true)); + + expect(mockOnConfigurationChange).toHaveBeenCalled(); +}); + +test('unconfigureMachine', async () => { + jest.mocked(mockBackendApi).unconfigureMachine.mockResolvedValueOnce(); + + const { result } = renderHook(() => unconfigureMachine.useMutation(), { + wrapper: QueryWrapper, + }); + + expect(mockOnConfigurationChange).not.toHaveBeenCalled(); + + result.current.mutate(); + await waitFor(() => expect(result.current.isSuccess).toEqual(true)); + + expect(mockOnConfigurationChange).toHaveBeenCalled(); +}); diff --git a/apps/mark/frontend/src/api.ts b/apps/mark/frontend/src/api.ts index 4bb39c26c3..f21268cc9e 100644 --- a/apps/mark/frontend/src/api.ts +++ b/apps/mark/frontend/src/api.ts @@ -11,6 +11,7 @@ import { import { AUTH_STATUS_POLLING_INTERVAL_MS, QUERY_CLIENT_DEFAULT_OPTIONS, + createUiStringsApi, } from '@votingworks/ui'; export type ApiClient = grout.Client; @@ -150,6 +151,8 @@ export const endCardlessVoterSession = { }, } as const; +export const uiStringsApi = createUiStringsApi(useApiClient); + export const configureBallotPackageFromUsb = { useMutation() { const apiClient = useApiClient(); @@ -158,6 +161,7 @@ export const configureBallotPackageFromUsb = { async onSuccess() { await queryClient.invalidateQueries(getElectionDefinition.queryKey()); await queryClient.invalidateQueries(getSystemSettings.queryKey()); + await uiStringsApi.onMachineConfigurationChange(queryClient); }, }); }, @@ -171,6 +175,7 @@ export const unconfigureMachine = { async onSuccess() { await queryClient.invalidateQueries(getElectionDefinition.queryKey()); await queryClient.invalidateQueries(getSystemSettings.queryKey()); + await uiStringsApi.onMachineConfigurationChange(queryClient); }, }); }, diff --git a/apps/mark/frontend/src/api_machine_configuration.test.tsx b/apps/mark/frontend/src/api_machine_configuration.test.tsx new file mode 100644 index 0000000000..aef3c762c6 --- /dev/null +++ b/apps/mark/frontend/src/api_machine_configuration.test.tsx @@ -0,0 +1,73 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ok } from '@votingworks/basics'; +import { electionGeneralDefinition } from '@votingworks/fixtures'; +import { renderHook, waitFor } from '../test/react_testing_library'; +import { + ApiClient, + ApiClientContext, + configureBallotPackageFromUsb, + createApiClient, + uiStringsApi, + unconfigureMachine, +} from './api'; + +const queryClient = new QueryClient(); +const mockBackendApi: ApiClient = { + ...createApiClient(), + configureBallotPackageFromUsb: jest.fn(), + unconfigureMachine: jest.fn(), +}; + +function QueryWrapper(props: { children: React.ReactNode }) { + const { children } = props; + + return ( + + {children} + + ); +} + +const mockOnConfigurationChange = jest.spyOn( + uiStringsApi, + 'onMachineConfigurationChange' +); + +afterAll(() => { + jest.restoreAllMocks(); +}); + +test('configureBallotPackageFromUsb', async () => { + jest + .mocked(mockBackendApi) + .configureBallotPackageFromUsb.mockResolvedValueOnce( + ok(electionGeneralDefinition) + ); + + const { result } = renderHook( + () => configureBallotPackageFromUsb.useMutation(), + { wrapper: QueryWrapper } + ); + + expect(mockOnConfigurationChange).not.toHaveBeenCalled(); + + result.current.mutate(); + await waitFor(() => expect(result.current.isSuccess).toEqual(true)); + + expect(mockOnConfigurationChange).toHaveBeenCalled(); +}); + +test('unconfigureMachine', async () => { + jest.mocked(mockBackendApi).unconfigureMachine.mockResolvedValueOnce(); + + const { result } = renderHook(() => unconfigureMachine.useMutation(), { + wrapper: QueryWrapper, + }); + + expect(mockOnConfigurationChange).not.toHaveBeenCalled(); + + result.current.mutate(); + await waitFor(() => expect(result.current.isSuccess).toEqual(true)); + + expect(mockOnConfigurationChange).toHaveBeenCalled(); +}); diff --git a/apps/scan/frontend/src/api.ts b/apps/scan/frontend/src/api.ts index 7c642774e3..ad6dfc8540 100644 --- a/apps/scan/frontend/src/api.ts +++ b/apps/scan/frontend/src/api.ts @@ -14,6 +14,7 @@ import { USB_DRIVE_STATUS_POLLING_INTERVAL_MS, QUERY_CLIENT_DEFAULT_OPTIONS, UsbDriveStatus as LegacyUsbDriveStatus, + createUiStringsApi, } from '@votingworks/ui'; import { typedAs } from '@votingworks/basics'; import type { UsbDriveStatus } from '@votingworks/usb-drive'; @@ -164,6 +165,8 @@ export const ejectUsbDrive = { }, } as const; +export const uiStringsApi = createUiStringsApi(useApiClient); + export const configureFromBallotPackageOnUsbDrive = { useMutation() { const apiClient = useApiClient(); @@ -171,6 +174,7 @@ export const configureFromBallotPackageOnUsbDrive = { return useMutation(apiClient.configureFromBallotPackageOnUsbDrive, { async onSuccess() { await queryClient.invalidateQueries(getConfig.queryKey()); + await uiStringsApi.onMachineConfigurationChange(queryClient); }, }); }, @@ -183,6 +187,7 @@ export const unconfigureElection = { return useMutation(apiClient.unconfigureElection, { async onSuccess() { await queryClient.invalidateQueries(getConfig.queryKey()); + await uiStringsApi.onMachineConfigurationChange(queryClient); }, }); }, diff --git a/apps/scan/frontend/src/api_machine_configuration.test.tsx b/apps/scan/frontend/src/api_machine_configuration.test.tsx new file mode 100644 index 0000000000..78daccdd01 --- /dev/null +++ b/apps/scan/frontend/src/api_machine_configuration.test.tsx @@ -0,0 +1,71 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ok } from '@votingworks/basics'; +import React from 'react'; +import { renderHook, waitFor } from '../test/react_testing_library'; +import { + ApiClient, + ApiClientContext, + configureFromBallotPackageOnUsbDrive, + createApiClient, + unconfigureElection, + uiStringsApi, +} from './api'; + +const queryClient = new QueryClient(); +const mockBackendApi: ApiClient = { + ...createApiClient(), + configureFromBallotPackageOnUsbDrive: jest.fn(), + unconfigureElection: jest.fn(), +}; + +function QueryWrapper(props: { children: React.ReactNode }) { + const { children } = props; + + return ( + + {children} + + ); +} + +const mockOnConfigurationChange = jest.spyOn( + uiStringsApi, + 'onMachineConfigurationChange' +); + +afterAll(() => { + jest.restoreAllMocks(); +}); + +test('configureFromBallotPackageOnUsbDrive', async () => { + jest + .mocked(mockBackendApi) + .configureFromBallotPackageOnUsbDrive.mockResolvedValueOnce(ok()); + + const { result } = renderHook( + () => configureFromBallotPackageOnUsbDrive.useMutation(), + { wrapper: QueryWrapper } + ); + + expect(mockOnConfigurationChange).not.toHaveBeenCalled(); + + result.current.mutate(); + await waitFor(() => expect(result.current.isSuccess).toEqual(true)); + + expect(mockOnConfigurationChange).toHaveBeenCalled(); +}); + +test('unconfigureElection', async () => { + jest.mocked(mockBackendApi).unconfigureElection.mockResolvedValue(); + + const { result } = renderHook(() => unconfigureElection.useMutation(), { + wrapper: QueryWrapper, + }); + + expect(mockOnConfigurationChange).not.toHaveBeenCalled(); + + result.current.mutate({}); + await waitFor(() => expect(result.current.isSuccess).toEqual(true)); + + expect(mockOnConfigurationChange).toHaveBeenCalled(); +}); diff --git a/libs/ui/package.json b/libs/ui/package.json index 80e937e0c6..043c1d679a 100644 --- a/libs/ui/package.json +++ b/libs/ui/package.json @@ -45,6 +45,7 @@ "@testing-library/react": "^14.0.0", "@votingworks/ballot-encoder": "workspace:*", "@votingworks/basics": "workspace:*", + "@votingworks/grout": "workspace:*", "@votingworks/logging": "workspace:*", "@votingworks/types": "workspace:*", "@votingworks/utils": "workspace:*", @@ -119,6 +120,7 @@ "vite": "^4.0.4" }, "peerDependencies": { + "@tanstack/react-query": "^4.32.1", "react": "18.2.0", "react-dom": "18.2.0", "styled-components": "^5.3.11" diff --git a/libs/ui/src/hooks/ui_strings_api.test.tsx b/libs/ui/src/hooks/ui_strings_api.test.tsx new file mode 100644 index 0000000000..8b7be6b19b --- /dev/null +++ b/libs/ui/src/hooks/ui_strings_api.test.tsx @@ -0,0 +1,60 @@ +import { LanguageCode } from '@votingworks/types'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { renderHook, waitFor } from '../../test/react_testing_library'; +import { createUiStringsApi } from './ui_strings_api'; + +const queryClient = new QueryClient(); +function QueryWrapper(props: { children: React.ReactNode }) { + const { children } = props; + + return ( + {children} + ); +} + +const mockApiClient = { + getAudioClipsBase64: jest.fn(), + getAvailableLanguages: jest.fn(), + getUiStringAudioIds: jest.fn(), + getUiStrings: jest.fn(), +} as const; + +const api = createUiStringsApi(() => mockApiClient); + +test('getAvailableLanguages', async () => { + // Simulate initial machine state: + mockApiClient.getAvailableLanguages.mockResolvedValueOnce([]); + + const { result } = renderHook(() => api.getAvailableLanguages.useQuery(), { + wrapper: QueryWrapper, + }); + + await waitFor(() => expect(result.current.isSuccess).toEqual(true)); + expect(result.current.data).toEqual([]); + + // Simulate configuring an election: + await act(async () => { + mockApiClient.getAvailableLanguages.mockResolvedValueOnce([ + LanguageCode.CHINESE, + LanguageCode.SPANISH, + ]); + await api.onMachineConfigurationChange(queryClient); + }); + + await waitFor(() => expect(result.current.isLoading).toEqual(false)); + expect(result.current.data).toEqual([ + LanguageCode.CHINESE, + LanguageCode.SPANISH, + ]); + + // Simulate unconfiguring an election: + await act(async () => { + mockApiClient.getAvailableLanguages.mockResolvedValueOnce([]); + await api.onMachineConfigurationChange(queryClient); + }); + + await waitFor(() => expect(result.current.isLoading).toEqual(false)); + expect(result.current.data).toEqual([]); +}); diff --git a/libs/ui/src/hooks/ui_strings_api.ts b/libs/ui/src/hooks/ui_strings_api.ts new file mode 100644 index 0000000000..ed5999b7f2 --- /dev/null +++ b/libs/ui/src/hooks/ui_strings_api.ts @@ -0,0 +1,46 @@ +import { QueryClient, QueryKey, useQuery } from '@tanstack/react-query'; +import { UiStringsApi } from '@votingworks/types'; +import * as grout from '@votingworks/grout'; + +// Unable to use `grout.Client` directly due to some mismatched +// type inference with `grout.AnyMethods`, so copying the `grout.Client` +// definition here for now: +export type UiStringsApiClient = { + [Method in keyof UiStringsApi]: grout.AsyncRpcMethod; +}; + +function createReactQueryApi(getApiClient: () => UiStringsApiClient) { + return { + getAvailableLanguages: { + getQueryKey(): QueryKey { + return ['getAvailableLanguages']; + }, + + useQuery() { + const apiClient = getApiClient(); + + return useQuery(this.getQueryKey(), () => + apiClient.getAvailableLanguages() + ); + }, + }, + + async onMachineConfigurationChange( + queryClient: QueryClient + ): Promise { + await queryClient.invalidateQueries( + this.getAvailableLanguages.getQueryKey() + ); + }, + + // TODO(kofi): Fill out. + }; +} + +export type UiStringsReactQueryApi = ReturnType; + +export function createUiStringsApi( + getApiClient: () => UiStringsApiClient +): UiStringsReactQueryApi { + return createReactQueryApi(getApiClient); +} diff --git a/libs/ui/src/index.ts b/libs/ui/src/index.ts index 275471a975..e37d6b613c 100644 --- a/libs/ui/src/index.ts +++ b/libs/ui/src/index.ts @@ -15,6 +15,7 @@ export * from './election_info_bar'; export * from './global_styles'; export * from './globals'; export * from './hand_marked_paper_ballot_prose'; +export * from './hooks/ui_strings_api'; export * from './hooks/use_autocomplete'; export * from './hooks/use_cancelable_promise'; export * from './hooks/use_change_listener'; diff --git a/libs/ui/tsconfig.build.json b/libs/ui/tsconfig.build.json index 4f06658df7..aff3919390 100644 --- a/libs/ui/tsconfig.build.json +++ b/libs/ui/tsconfig.build.json @@ -19,6 +19,7 @@ { "path": "../eslint-plugin-vx/tsconfig.build.json" }, { "path": "../fixtures/tsconfig.build.json" }, { "path": "../logging/tsconfig.build.json" }, + { "path": "../grout/tsconfig.build.json" }, { "path": "../monorepo-utils/tsconfig.build.json" }, { "path": "../test-utils/tsconfig.build.json" }, { "path": "../types/tsconfig.build.json" }, diff --git a/libs/ui/tsconfig.json b/libs/ui/tsconfig.json index 61fbfc146a..cbaa7334b2 100644 --- a/libs/ui/tsconfig.json +++ b/libs/ui/tsconfig.json @@ -18,6 +18,7 @@ { "path": "../eslint-plugin-vx/tsconfig.build.json" }, { "path": "../fixtures/tsconfig.build.json" }, { "path": "../logging/tsconfig.build.json" }, + { "path": "../grout/tsconfig.build.json" }, { "path": "../monorepo-utils/tsconfig.build.json" }, { "path": "../test-utils/tsconfig.build.json" }, { "path": "../types/tsconfig.build.json" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f952ffb5f..7a3dd10c75 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5066,6 +5066,9 @@ importers: '@votingworks/basics': specifier: workspace:* version: link:../basics + '@votingworks/grout': + specifier: workspace:* + version: link:../grout '@votingworks/logging': specifier: workspace:* version: link:../logging