-
Notifications
You must be signed in to change notification settings - Fork 7
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[libs/ui] Add shared UiString component and associated context provid…
…er (#4062)
- Loading branch information
Showing
15 changed files
with
712 additions
and
8 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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>; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * from './ui_strings_context'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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]); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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[]; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
} |
Oops, something went wrong.