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