Skip to content

Commit

Permalink
[libs/ui] Add shared UiString component and associated context provid…
Browse files Browse the repository at this point in the history
…er (#4062)
  • Loading branch information
kofi-q authored Oct 10, 2023
1 parent 021f5ea commit 45d53da
Show file tree
Hide file tree
Showing 15 changed files with 712 additions and 8 deletions.
8 changes: 6 additions & 2 deletions libs/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,14 @@
"debug": "^4.3.2",
"deep-eql": "^4.0.0",
"dompurify": "^2.0.12",
"i18next": "^23.5.1",
"lodash": "^4.17.20",
"luxon": "^3.0.0",
"normalize.css": "^8.0.1",
"pluralize": "^8.0.0",
"polished": "^4.2.2",
"qrcode.react": "^3.1.0",
"react-i18next": "^13.2.2",
"react-idle-timer": "^5.7.2",
"react-modal": "^3.16.1",
"react-router-dom": "^5.3.4",
Expand All @@ -76,14 +79,16 @@
"@types/deep-eql": "workspace:*",
"@types/dompurify": "^2.0.3",
"@types/history": "4",
"@types/i18next": "^13.0.0",
"@types/jest": "^29.5.3",
"@types/kiosk-browser": "workspace:*",
"@types/lodash.clonedeep": "^4.5.7",
"@types/lodash": "^4.14.168",
"@types/luxon": "^3.0.0",
"@types/node": "16.18.23",
"@types/pluralize": "^0.0.29",
"@types/react": "18.2.18",
"@types/react-dom": "^18.2.7",
"@types/react-i18next": "^8.1.0",
"@types/react-modal": "^3.13.1",
"@types/react-router-dom": "^5.3.3",
"@types/styled-components": "^5.1.26",
Expand All @@ -106,7 +111,6 @@
"jest-styled-components": "^7.1.1",
"jest-watch-typeahead": "^2.2.2",
"lint-staged": "^11.0.0",
"lodash.clonedeep": "^4.5.0",
"lorem-ipsum": "^2.0.8",
"parse-css-color": "^0.2.1",
"path": "^0.12.7",
Expand Down
1 change: 1 addition & 0 deletions libs/ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export * from './typography';
export * from './usbcontroller_button';
export * from './remove_card_screen';
export { InvalidCardScreen } from './invalid_card_screen';
export * from './ui_strings';
export * from './unlock_machine_screen';
export * from './system_administrator_screen_contents';
export * from './unconfigure_machine_button';
Expand Down
30 changes: 30 additions & 0 deletions libs/ui/src/ui_strings/audio_context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import React from 'react';

import { Optional } from '@votingworks/basics';

import { UiStringsReactQueryApi } from '../hooks/ui_strings_api';

export interface AudioContextInterface {
// TODO(kofi): Flesh out.
}

const AudioContext =
React.createContext<Optional<AudioContextInterface>>(undefined);

export function useAudioContext(): Optional<AudioContextInterface> {
return React.useContext(AudioContext);
}

export interface AudioContextProviderProps {
// eslint-disable-next-line react/no-unused-prop-types
api: UiStringsReactQueryApi;
children: React.ReactNode;
}

export function AudioContextProvider(
props: AudioContextProviderProps
): JSX.Element {
const { children } = props;

return <AudioContext.Provider value={{}}>{children}</AudioContext.Provider>;
}
1 change: 1 addition & 0 deletions libs/ui/src/ui_strings/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './ui_strings_context';
64 changes: 64 additions & 0 deletions libs/ui/src/ui_strings/language_context.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { LanguageCode } from '@votingworks/types';
import { act } from 'react-dom/test-utils';
import { waitFor } from '../../test/react_testing_library';
import { newTestContext } from '../../test/ui_strings/test_utils';
import { TEST_UI_STRING_TRANSLATIONS } from '../../test/ui_strings/test_strings';
import {
DEFAULT_I18NEXT_NAMESPACE,
DEFAULT_LANGUAGE_CODE,
} from './language_context';

const { getLanguageContext, mockBackendApi, render } = newTestContext();

beforeEach(() => {
jest.resetAllMocks();

mockBackendApi.getAvailableLanguages.mockResolvedValueOnce([
LanguageCode.ENGLISH,
LanguageCode.CHINESE,
]);

mockBackendApi.getUiStrings.mockImplementation(({ languageCode }) =>
Promise.resolve(TEST_UI_STRING_TRANSLATIONS[languageCode] || null)
);
});

test('availableLanguages', async () => {
render(<div>foo</div>);

await waitFor(() => expect(getLanguageContext()).toBeDefined());
expect(getLanguageContext()?.availableLanguages).toEqual([
LanguageCode.ENGLISH,
LanguageCode.CHINESE,
]);
});

test('setLanguage', async () => {
render(<div>foo</div>);

await waitFor(() => expect(getLanguageContext()).toBeDefined());

expect(getLanguageContext()?.currentLanguageCode).toEqual(
DEFAULT_LANGUAGE_CODE
);
expect(
getLanguageContext()?.i18next.getResourceBundle(
DEFAULT_LANGUAGE_CODE,
DEFAULT_I18NEXT_NAMESPACE
)
).toEqual(TEST_UI_STRING_TRANSLATIONS[DEFAULT_LANGUAGE_CODE]);

act(() => getLanguageContext()?.setLanguage(LanguageCode.CHINESE));

await waitFor(() =>
expect(getLanguageContext()?.currentLanguageCode).toEqual(
LanguageCode.CHINESE
)
);
expect(
getLanguageContext()?.i18next.getResourceBundle(
LanguageCode.CHINESE,
DEFAULT_I18NEXT_NAMESPACE
)
).toEqual(TEST_UI_STRING_TRANSLATIONS[LanguageCode.CHINESE]);
});
108 changes: 108 additions & 0 deletions libs/ui/src/ui_strings/language_context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import React from 'react';
import i18next, { i18n } from 'i18next';
import { initReactI18next, useTranslation } from 'react-i18next';

import { LanguageCode } from '@votingworks/types';
import { Optional } from '@votingworks/basics';
import { Screen } from '../screen';
import { UiStringsReactQueryApi } from '../hooks/ui_strings_api';

export interface LanguageContextInterface {
availableLanguages: LanguageCode[];
currentLanguageCode: LanguageCode;
i18next: i18n;
setLanguage: (code: LanguageCode) => void;
translationFunction: ReturnType<typeof useTranslation>['t'];
}

export const DEFAULT_LANGUAGE_CODE = LanguageCode.ENGLISH;
export const DEFAULT_I18NEXT_NAMESPACE = 'translation';

const LanguageContext =
React.createContext<Optional<LanguageContextInterface>>(undefined);

export function useLanguageContext(): Optional<LanguageContextInterface> {
return React.useContext(LanguageContext);
}

const i18nextInitPromise = i18next.use(initReactI18next).init({
lng: DEFAULT_LANGUAGE_CODE,
fallbackLng: DEFAULT_LANGUAGE_CODE,
supportedLngs: Object.values(LanguageCode),
interpolation: {
escapeValue: false, // Sanitization already handled by React.
},
react: {
// Configure events that trigger re-renders:
bindI18n: 'languageChanged loaded',
bindI18nStore: 'added removed',
},
});

export interface LanguageContextProviderProps {
api: UiStringsReactQueryApi;
children: React.ReactNode;
}

export function LanguageContextProvider(
props: LanguageContextProviderProps
): JSX.Element {
const { api, children } = props;

const [currentLanguageCode, setLanguage] = React.useState(
DEFAULT_LANGUAGE_CODE
);
const [isI18nextReady, setIs18nReady] = React.useState(false);

const { t: translationFunction } = useTranslation();

const uiStringsQuery = api.getUiStrings.useQuery(currentLanguageCode);
const availableLanguagesQuery = api.getAvailableLanguages.useQuery();

React.useEffect(() => {
async function waitForI18nextReady() {
await i18nextInitPromise;
setIs18nReady(true);
}

void waitForI18nextReady();
}, []);

const uiStringsData = uiStringsQuery.data;
const isUiStringsLoading = uiStringsQuery.isLoading;

React.useEffect(() => {
if (isUiStringsLoading || !uiStringsData) {
return;
}

i18next.addResourceBundle(currentLanguageCode, DEFAULT_I18NEXT_NAMESPACE, {
...uiStringsData,
});

void i18next.changeLanguage(currentLanguageCode);
}, [currentLanguageCode, isUiStringsLoading, uiStringsData]);

if (!isI18nextReady || !availableLanguagesQuery.isSuccess) {
// This state is too brief to warrant a loading screen which would only
// flash for an instant - going with an empty screen, since this only
// happens once on initial app render, before any content is rendered.
return <Screen />;
}

// TODO(kofi): Add logging for missing translation keys and data fetch errors.

return (
<LanguageContext.Provider
value={{
availableLanguages: availableLanguagesQuery.data,
currentLanguageCode,
i18next,
setLanguage,
translationFunction,
}}
>
{children}
</LanguageContext.Provider>
);
}
19 changes: 19 additions & 0 deletions libs/ui/src/ui_strings/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import React from 'react';

type ReactUiStringFragment = React.ReactNode | Record<string, unknown>;

/**
* Modified React component children type that supports interpolated parameter
* objects used in string translations.
*
* e.g.
* ```
* const interpolatedValue = 24;
* return (
* <UiString>This is an {{ interpolatedValue }}.</UiString>
* );
* ```
*/
export type ReactUiString =
| ReactUiStringFragment
| readonly ReactUiStringFragment[];
111 changes: 111 additions & 0 deletions libs/ui/src/ui_strings/ui_string.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { Meta } from '@storybook/react';

import { LanguageCode } from '@votingworks/types';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import { assertDefined } from '@votingworks/basics';

import { UiString as Component, UiStringProps as Props } from './ui_string';
import {
TEST_UI_STRING_TRANSLATIONS,
testUiStrings,
NumPlanets,
} from '../../test/ui_strings/test_strings';
import {
Caption,
H4,
P,
QUERY_CLIENT_DEFAULT_OPTIONS,
SegmentedButton,
UiStringsContextProvider,
UiStringsReactQueryApi,
createUiStringsApi,
} from '..';
import { useLanguageContext } from './language_context';

const initialProps: Partial<Props> = {
children: null,
pluralCount: 9,
};

const meta: Meta<typeof Component> = {
title: 'libs-ui/UiString',
component: Component,
args: initialProps,
};

export default meta;

const queryClient = new QueryClient({
defaultOptions: QUERY_CLIENT_DEFAULT_OPTIONS,
});

function LanguagePicker(): React.ReactNode {
const languageContext = useLanguageContext();
if (!languageContext) {
return null;
}

const { availableLanguages, currentLanguageCode, setLanguage } =
languageContext;

return (
<SegmentedButton
label="Language:"
hideLabel
onChange={setLanguage}
options={availableLanguages.map((code) => ({ id: code, label: code }))}
selectedOptionId={currentLanguageCode}
/>
);
}

const uiStringsApi: UiStringsReactQueryApi = createUiStringsApi(() => ({
getAudioClipsBase64: () => Promise.reject(new Error('not yet implemented')),
getAvailableLanguages: () =>
Promise.resolve([
LanguageCode.ENGLISH,
LanguageCode.CHINESE,
LanguageCode.SPANISH,
]),
getUiStringAudioIds: () => Promise.reject(new Error('not yet implemented')),
getUiStrings: ({ languageCode }) =>
Promise.resolve(TEST_UI_STRING_TRANSLATIONS[languageCode] || null),
}));

export function UiString(props: Props): JSX.Element {
const { pluralCount } = props;

return (
<QueryClientProvider client={queryClient}>
<UiStringsContextProvider api={uiStringsApi}>
<H4 as="h1">Pluralized String:</H4>
<P>
<code>numPlanets:</code>{' '}
<NumPlanets pluralCount={assertDefined(pluralCount)} />
</P>
<H4 as="h1">ID-Disambiguated String:</H4>
<P>
<code>planetName.planet1:</code> {testUiStrings.planetName('planet1')}
</P>
<P>
<code>planetName.planet3:</code> {testUiStrings.planetName('planet3')}
</P>
<P>
<code>planetName.planet9:</code> {testUiStrings.planetName('planet9')}
</P>
<H4 as="h1">Config:</H4>
<P>
<LanguagePicker />
</P>
<Caption>
<code>
<pre>
{JSON.stringify(TEST_UI_STRING_TRANSLATIONS, undefined, 2)}
</pre>
</code>
</Caption>
</UiStringsContextProvider>
</QueryClientProvider>
);
}
Loading

0 comments on commit 45d53da

Please sign in to comment.