From 3519d64c08df837e1dc256a1ffa359e25135a217 Mon Sep 17 00:00:00 2001 From: Alexandre Philibeaux Date: Mon, 15 Jan 2024 19:14:04 +0100 Subject: [PATCH 1/2] fix(i18n): add onTranslateError and fallback on defaultLocal with context value --- packages/use-i18n/src/usei18n.tsx | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/packages/use-i18n/src/usei18n.tsx b/packages/use-i18n/src/usei18n.tsx index d7e5b000a..8a4ed9701 100644 --- a/packages/use-i18n/src/usei18n.tsx +++ b/packages/use-i18n/src/usei18n.tsx @@ -200,6 +200,7 @@ const I18nContextProvider = ({ loadDateLocaleAsync, localeItemStorage = LOCALE_ITEM_STORAGE, onLoadDateLocaleError, + onTranslateError, supportedLocales, }: { children: ReactNode @@ -213,6 +214,7 @@ const I18nContextProvider = ({ enableDebugKey: boolean localeItemStorage: string supportedLocales: string[] + onTranslateError?: (error: Error) => void }): ReactElement => { const [currentLocale, setCurrentLocale] = useState( getCurrentLocale({ defaultLocale, localeItemStorage, supportedLocales }), @@ -382,6 +384,7 @@ const I18nContextProvider = ({ const translate = useCallback( (key: string, context?: ReactParamsObject) => { const value = translations[currentLocale]?.[key] as string + if (enableDebugKey) { return key } @@ -390,14 +393,31 @@ const I18nContextProvider = ({ return '' } if (context) { - return formatters - .getTranslationFormat(value, currentLocale) - .format(context) as string + try { + return formatters + .getTranslationFormat(value, currentLocale) + .format(context) as string + } catch (err) { + onTranslateError?.(err as Error) + + // with default locale nothing should break or it's normal to not ignore it. + const defaultValue = translations[defaultLocale]?.[key] as string + + return formatters + .getTranslationFormat(defaultValue, defaultLocale) + .format(context) as string + } } return value }, - [currentLocale, translations, enableDebugKey], + [ + currentLocale, + translations, + enableDebugKey, + defaultLocale, + onTranslateError, + ], ) const namespaceTranslation = useCallback( From fbdc1bc45172a9c53a7e0fc69fa1459533f34543 Mon Sep 17 00:00:00 2001 From: Alexandre Philibeaux Date: Tue, 16 Jan 2024 11:16:29 +0100 Subject: [PATCH 2/2] test(translate): add more translate test --- .changeset/curly-trains-draw.md | 5 ++ .../use-i18n/src/__tests__/locales/en.json | 7 -- packages/use-i18n/src/__tests__/locales/en.ts | 10 +++ .../use-i18n/src/__tests__/locales/es.json | 6 -- packages/use-i18n/src/__tests__/locales/es.ts | 7 ++ .../use-i18n/src/__tests__/locales/fr.json | 6 -- packages/use-i18n/src/__tests__/locales/fr.ts | 9 ++ packages/use-i18n/src/__tests__/usei18n.tsx | 86 +++++++++++++++---- packages/use-i18n/src/usei18n.tsx | 19 +++- 9 files changed, 116 insertions(+), 39 deletions(-) create mode 100644 .changeset/curly-trains-draw.md delete mode 100644 packages/use-i18n/src/__tests__/locales/en.json create mode 100644 packages/use-i18n/src/__tests__/locales/en.ts delete mode 100644 packages/use-i18n/src/__tests__/locales/es.json create mode 100644 packages/use-i18n/src/__tests__/locales/es.ts delete mode 100644 packages/use-i18n/src/__tests__/locales/fr.json create mode 100644 packages/use-i18n/src/__tests__/locales/fr.ts diff --git a/.changeset/curly-trains-draw.md b/.changeset/curly-trains-draw.md new file mode 100644 index 000000000..c9c010219 --- /dev/null +++ b/.changeset/curly-trains-draw.md @@ -0,0 +1,5 @@ +--- +"@scaleway/use-i18n": patch +--- + +add a onTranslateError function in case of desync traduction between language. This will help to focus only on default language for developers diff --git a/packages/use-i18n/src/__tests__/locales/en.json b/packages/use-i18n/src/__tests__/locales/en.json deleted file mode 100644 index 675e26090..000000000 --- a/packages/use-i18n/src/__tests__/locales/en.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "with.identifier": "Are you sure you want to delete {identifier}?", - "plurals": "You have {numPhotos, plural, =0 {no photos.} =1 {one photo.} other {# photos.}}", - "subtitle": "Here is a subtitle", - "tests.test.namespaces": "test", - "title": "Welcome on @scaelway/ui i18n hook" -} diff --git a/packages/use-i18n/src/__tests__/locales/en.ts b/packages/use-i18n/src/__tests__/locales/en.ts new file mode 100644 index 000000000..29bf7d49b --- /dev/null +++ b/packages/use-i18n/src/__tests__/locales/en.ts @@ -0,0 +1,10 @@ +export default { + 'with.identifier': 'Are you sure you want to delete {identifier}?', + plurals: + '{count, plural, =0 {No file} =1 {{count} file} other {{count} files}}', + subtitle: 'Here is a subtitle', + 'tests.test.namespaces': 'test', + title: 'Welcome on @scaelway/ui i18n hook', + 'translate.error': + 'On translate sync issue with variable between locales {newVariable}', +} as const diff --git a/packages/use-i18n/src/__tests__/locales/es.json b/packages/use-i18n/src/__tests__/locales/es.json deleted file mode 100644 index 88d1da05d..000000000 --- a/packages/use-i18n/src/__tests__/locales/es.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "plurals": "You have {numPhotos, plural, =0 {no photos.} =1 {one photo.} other {# photos.}}", - "subtitle": "Aquí hay un subtítulo", - "tests.test.namespaces": "test", - "title": "Bienvenido @scaelway/ui i18n hook" -} diff --git a/packages/use-i18n/src/__tests__/locales/es.ts b/packages/use-i18n/src/__tests__/locales/es.ts new file mode 100644 index 000000000..d4e38fb8a --- /dev/null +++ b/packages/use-i18n/src/__tests__/locales/es.ts @@ -0,0 +1,7 @@ +export default { + plurals: + 'You have {numPhotos, plural, =0 {no photos.} =1 {one photo.} other {# photos.}}', + subtitle: 'Aquí hay un subtítulo', + 'tests.test.namespaces': 'test', + title: 'Bienvenido @scaelway/ui i18n hook', +} as const diff --git a/packages/use-i18n/src/__tests__/locales/fr.json b/packages/use-i18n/src/__tests__/locales/fr.json deleted file mode 100644 index d3bd10a59..000000000 --- a/packages/use-i18n/src/__tests__/locales/fr.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "plurals": "You have {numPhotos, plural, =0 {no photos.} =1 {one photo.} other {# photos.}}", - "subtitle": "Voici un sous-titre", - "tests.test.namespaces": "test", - "title": "Bienvenue sur @scaelway/ui i18n hook" -} diff --git a/packages/use-i18n/src/__tests__/locales/fr.ts b/packages/use-i18n/src/__tests__/locales/fr.ts new file mode 100644 index 000000000..fef4af5b1 --- /dev/null +++ b/packages/use-i18n/src/__tests__/locales/fr.ts @@ -0,0 +1,9 @@ +export default { + plurals: + 'You have {numPhotos, plural, =0 {no photos.} =1 {one photo.} other {# photos.}}', + subtitle: 'Voici un sous-titre', + 'tests.test.namespaces': 'test', + title: 'Bienvenue sur @scaelway/ui i18n hook', + 'translate.error': + 'onTranslateError fonction sera appelé car il manque une variable en français {oldFrenchVariable}', +} as const diff --git a/packages/use-i18n/src/__tests__/usei18n.tsx b/packages/use-i18n/src/__tests__/usei18n.tsx index bf5e852a4..692b82584 100644 --- a/packages/use-i18n/src/__tests__/usei18n.tsx +++ b/packages/use-i18n/src/__tests__/usei18n.tsx @@ -9,31 +9,28 @@ import { import { act, renderHook, waitFor } from '@testing-library/react' import { enGB, fr as frDateFns } from 'date-fns/locale' import mockdate from 'mockdate' -import type { ReactNode } from 'react' +import type { ComponentProps, ReactNode } from 'react' import I18n, { useI18n, useTranslation } from '..' -import en from './locales/en.json' -import es from './locales/es.json' -import fr from './locales/fr.json' +import en from './locales/en' +import es from './locales/es' +import fr from './locales/fr' const LOCALE_ITEM_STORAGE = 'locales' -type Locale = { - test: 'Test' - 'with.identifier': 'Are you sure you want to delete {identifier}?' - plurals: '{numPhotos, plural, =0 {You have one photo.} other {You have # photos.}}' - subtitle: 'Here is a subtitle' - 'tests.test.namespaces': 'test' - title: 'Welcome on @scaelway/ui i18n hook' -} - +type LocaleEN = typeof en +type Locale = LocaleEN type NamespaceLocale = { name: 'Name' lastName: 'Last Name' languages: 'Languages' } +type OnTranslateError = ComponentProps['onTranslateError'] + const defaultSupportedLocales = ['en', 'fr', 'es'] +const defaultOnTranslateError: OnTranslateError = () => {} + const wrapper = ({ loadDateLocaleAsync = async (locale: string) => { @@ -61,13 +58,14 @@ const wrapper = return enGB }, defaultLoad = async ({ locale }: { locale: string }) => - import(`./locales/${locale}.json`), + import(`./locales/${locale}.ts`), defaultLocale = 'en', defaultTranslations = {}, enableDebugKey = false, enableDefaultLocale = false, localeItemStorage = LOCALE_ITEM_STORAGE, supportedLocales = defaultSupportedLocales, + onTranslateError = defaultOnTranslateError, } = {}) => ({ children }: { children: ReactNode }) => ( {children} @@ -470,7 +469,7 @@ describe('i18n hook', () => { expect(localStorage.getItem(LOCALE_ITEM_STORAGE)).toBe('es') }) - it('should translate correctly with enableDebugKey', async () => { + it('should translate correctly with enableDebugKey and return key', async () => { const { result } = renderHook(() => useI18n(), { wrapper: wrapper({ defaultLocale: 'en', @@ -479,15 +478,66 @@ describe('i18n hook', () => { supportedLocales: ['en', 'fr'], }), }) + + // @ts-expect-error this key doesn't exist but enable debug key will return the key expect(result.current.t('test')).toEqual('test') await waitFor(() => { expect(result.current.t('title')).toEqual('title') expect(result.current.t('subtitle')).toEqual('subtitle') - expect(result.current.t('plurals', { numPhotos: 0 })).toEqual('plurals') - expect(result.current.t('plurals', { numPhotos: 1 })).toEqual('plurals') - expect(result.current.t('plurals', { numPhotos: 2 })).toEqual('plurals') + expect(result.current.t('plurals', { count: 0 })).toEqual('plurals') + expect(result.current.t('plurals', { count: 1 })).toEqual('plurals') + expect(result.current.t('plurals', { count: 2 })).toEqual('plurals') + }) + }) + + it('should call onTranslateError when there is a sync issue to remove/add variable in one traduction of a language', async () => { + const mockOnTranslateError = jest.fn() + + const { result } = renderHook(() => useI18n(), { + wrapper: wrapper({ + defaultLocale: 'en', + defaultTranslations: { en, fr }, + supportedLocales: ['en', 'fr'], + onTranslateError: mockOnTranslateError, + }), + }) + + await act(async () => { + await result.current.switchLocale('fr') + }) + + waitFor(() => { + expect(result.current.currentLocale).toEqual('fr') }) + + const newVariable = 'newVariable' + expect( + result.current.t('translate.error', { newVariable: 'newVariable' }), + ).toBe( + `On translate sync issue with variable between locales ${newVariable}`, + ) + + expect(mockOnTranslateError).toHaveBeenCalledTimes(1) + expect(mockOnTranslateError).toHaveBeenCalledWith({ + error: new Error( + 'The intl string context variable "oldFrenchVariable" was not provided to the string "onTranslateError fonction sera appelé car il manque une variable en français {oldFrenchVariable}"', + ), + currentLocale: 'fr', + key: 'translate.error', + value: + 'onTranslateError fonction sera appelé car il manque une variable en français {oldFrenchVariable}', + }) + + const oldFrenchVariable = 'cette variable fonctionne' + expect( + result.current.t('translate.error', { + // @ts-expect-error this variable doesn't exist in english anymore but still in french locales + oldFrenchVariable, + }), + ).toBe( + `onTranslateError fonction sera appelé car il manque une variable en français ${oldFrenchVariable}`, + ) }) it('should use namespaceTranslation', async () => { diff --git a/packages/use-i18n/src/usei18n.tsx b/packages/use-i18n/src/usei18n.tsx index 8a4ed9701..6e13a382a 100644 --- a/packages/use-i18n/src/usei18n.tsx +++ b/packages/use-i18n/src/usei18n.tsx @@ -214,7 +214,17 @@ const I18nContextProvider = ({ enableDebugKey: boolean localeItemStorage: string supportedLocales: string[] - onTranslateError?: (error: Error) => void + onTranslateError?: ({ + error, + currentLocale, + value, + key, + }: { + error: Error + currentLocale: string + value: string + key: string + }) => void }): ReactElement => { const [currentLocale, setCurrentLocale] = useState( getCurrentLocale({ defaultLocale, localeItemStorage, supportedLocales }), @@ -398,7 +408,12 @@ const I18nContextProvider = ({ .getTranslationFormat(value, currentLocale) .format(context) as string } catch (err) { - onTranslateError?.(err as Error) + onTranslateError?.({ + error: err as Error, + currentLocale, + value, + key, + }) // with default locale nothing should break or it's normal to not ignore it. const defaultValue = translations[defaultLocale]?.[key] as string