From b5b02e2c084b8a8dcef3c71d37c559dd2d81205c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Wed, 28 May 2025 16:51:59 +0200 Subject: [PATCH] i18n: adds loading of translations from packages (#106141) * I18n: Translate npm packages (cherry picked from commit 5e1e640b940dd7b7cc9b3f74b9d166ff75c74836) * chore: updates * i18n: adds loading of translations from packages * chore: adds tests --------- Co-authored-by: joshhunt Co-authored-by: Ashley Harrison --- packages/grafana-i18n/src/i18n.test.tsx | 168 ++++++++++++++++++++++++ packages/grafana-i18n/src/i18n.tsx | 70 +++++++--- packages/grafana-i18n/src/index.ts | 2 +- packages/grafana-i18n/src/types.ts | 14 +- 4 files changed, 232 insertions(+), 22 deletions(-) create mode 100644 packages/grafana-i18n/src/i18n.test.tsx diff --git a/packages/grafana-i18n/src/i18n.test.tsx b/packages/grafana-i18n/src/i18n.test.tsx new file mode 100644 index 00000000000..d8212cc5f8e --- /dev/null +++ b/packages/grafana-i18n/src/i18n.test.tsx @@ -0,0 +1,168 @@ +/* eslint-disable no-restricted-imports */ +import i18n from 'i18next'; +import { initReactI18next, setDefaults, setI18n } from 'react-i18next'; + +import { DEFAULT_LANGUAGE } from './constants'; +import { + loadPluginResources, + initDefaultI18nInstance, + initDefaultReactI18nInstance, + initPluginTranslations, +} from './i18n'; +import { ResourceLoader } from './types'; + +jest.mock('react-i18next', () => ({ + getI18n: () => i18n, + setDefaults: jest.fn(), + setI18n: jest.fn(), +})); + +describe('i18n', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('loadPluginResources', () => { + it('should load all resources for a plugin', async () => { + const loaders: ResourceLoader[] = [ + () => Promise.resolve({ hello: 'Hi' }), + () => Promise.resolve({ i18n: 'i18n' }), + ]; + const addResourceBundleSpy = jest.spyOn(i18n, 'addResourceBundle'); + + await loadPluginResources('test', 'en-US', loaders); + + expect(addResourceBundleSpy).toHaveBeenCalledTimes(2); + expect(addResourceBundleSpy).toHaveBeenNthCalledWith(1, 'en-US', 'test', { hello: 'Hi' }, true, false); + expect(addResourceBundleSpy).toHaveBeenNthCalledWith(2, 'en-US', 'test', { i18n: 'i18n' }, true, false); + }); + + it('should load all resources for a plugin even if a loader throws', async () => { + const loaders: ResourceLoader[] = [ + () => Promise.reject({ hello: 'Hi' }), + () => Promise.resolve({ i18n: 'i18n' }), + ]; + jest.spyOn(console, 'error').mockImplementation(); + const addResourceBundleSpy = jest.spyOn(i18n, 'addResourceBundle'); + + await loadPluginResources('test', 'en-US', loaders); + + expect(addResourceBundleSpy).toHaveBeenCalledTimes(1); + expect(addResourceBundleSpy).toHaveBeenCalledWith('en-US', 'test', { i18n: 'i18n' }, true, false); + }); + + it('should not load resources if no loaders are provided', async () => { + const loaders: ResourceLoader[] = []; + const addResourceBundleSpy = jest.spyOn(i18n, 'addResourceBundle'); + + await loadPluginResources('test', 'en-US', loaders); + + expect(addResourceBundleSpy).toHaveBeenCalledTimes(0); + }); + }); + + describe('initDefaultI18nInstance', () => { + it('should not initialize the i18n instance if the resources are already initialized', async () => { + const useSpy = jest.spyOn(i18n, 'use').mockImplementation(); + const initSpy = jest.spyOn(i18n, 'init').mockImplementation(); + + await initDefaultI18nInstance(); + + expect(useSpy).not.toHaveBeenCalled(); // not called because the resources are already initialized in public/test/setupTests.ts + expect(initSpy).not.toHaveBeenCalled(); // not called because the resources are already initialized in public/test/setupTests.ts + }); + + it('should initialize the i18n instance if the resources are not initialized', async () => { + jest.replaceProperty(i18n, 'options', { resources: undefined }); + const useSpy = jest.spyOn(i18n, 'use').mockImplementation(() => i18n); + const initSpy = jest.spyOn(i18n, 'init').mockImplementation(); + + await initDefaultI18nInstance(); + + expect(useSpy).toHaveBeenCalledTimes(1); + expect(useSpy).toHaveBeenCalledWith(initReactI18next); + expect(initSpy).toHaveBeenCalledTimes(1); + expect(initSpy).toHaveBeenCalledWith({ + resources: {}, + returnEmptyString: false, + lng: DEFAULT_LANGUAGE, + }); + }); + }); + + describe('initDefaultReactI18nInstance', () => { + it('should not initialize the react i18n instance if the react options are already initialized', async () => { + jest.replaceProperty(i18n, 'options', { react: {} }); + + initDefaultReactI18nInstance(); + + expect(setDefaults).not.toHaveBeenCalled(); + expect(setI18n).not.toHaveBeenCalled(); + }); + + it('should initialize the react i18n instance if the react options are not initialized', async () => { + jest.replaceProperty(i18n, 'options', { react: undefined }); + + initDefaultReactI18nInstance(); + + expect(setDefaults).toHaveBeenCalledTimes(1); + expect(setDefaults).toHaveBeenCalledWith({}); + expect(setI18n).toHaveBeenCalledTimes(1); + expect(setI18n).toHaveBeenCalledWith(i18n); + }); + }); + + describe('initPluginTranslations', () => { + it('should not initialize the i18n instance and the react i18n instance if they are already initialized', async () => { + const loaders: ResourceLoader[] = [ + () => Promise.resolve({ hello: 'Hi' }), + () => Promise.resolve({ i18n: 'i18n' }), + ]; + const addResourceBundleSpy = jest.spyOn(i18n, 'addResourceBundle'); + const useSpy = jest.spyOn(i18n, 'use').mockImplementation(); + const initSpy = jest.spyOn(i18n, 'init').mockImplementation(); + jest.replaceProperty(i18n, 'options', { react: {}, resources: {} }); + + const { language } = await initPluginTranslations('test', loaders); + + expect(language).toBe('en-US'); + expect(useSpy).not.toHaveBeenCalled(); + expect(initSpy).not.toHaveBeenCalled(); + expect(setDefaults).not.toHaveBeenCalled(); + expect(setI18n).not.toHaveBeenCalled(); + expect(addResourceBundleSpy).toHaveBeenCalledTimes(2); + expect(addResourceBundleSpy).toHaveBeenNthCalledWith(1, 'en-US', 'test', { hello: 'Hi' }, true, false); + expect(addResourceBundleSpy).toHaveBeenNthCalledWith(2, 'en-US', 'test', { i18n: 'i18n' }, true, false); + }); + + it('should initialize the i18n instance and the react i18n instance if they are not initialized', async () => { + const loaders: ResourceLoader[] = [ + () => Promise.resolve({ hello: 'Hi' }), + () => Promise.resolve({ i18n: 'i18n' }), + ]; + const addResourceBundleSpy = jest.spyOn(i18n, 'addResourceBundle'); + const useSpy = jest.spyOn(i18n, 'use').mockImplementation(() => i18n); + const initSpy = jest.spyOn(i18n, 'init').mockImplementation(); + jest.replaceProperty(i18n, 'options', { react: undefined, resources: undefined }); + + const { language } = await initPluginTranslations('test', loaders); + + expect(language).toBe('en-US'); + expect(useSpy).toHaveBeenCalledTimes(1); + expect(useSpy).toHaveBeenCalledWith(initReactI18next); + expect(initSpy).toHaveBeenCalledTimes(1); + expect(initSpy).toHaveBeenCalledWith({ + resources: {}, + returnEmptyString: false, + lng: DEFAULT_LANGUAGE, + }); + expect(setDefaults).toHaveBeenCalledTimes(1); + expect(setDefaults).toHaveBeenCalledWith({}); + expect(setI18n).toHaveBeenCalledTimes(1); + expect(setI18n).toHaveBeenCalledWith(i18n); + expect(addResourceBundleSpy).toHaveBeenCalledTimes(2); + expect(addResourceBundleSpy).toHaveBeenNthCalledWith(1, 'en-US', 'test', { hello: 'Hi' }, true, false); + expect(addResourceBundleSpy).toHaveBeenNthCalledWith(2, 'en-US', 'test', { i18n: 'i18n' }, true, false); + }); + }); +}); diff --git a/packages/grafana-i18n/src/i18n.tsx b/packages/grafana-i18n/src/i18n.tsx index 5435dc3168a..8aeb19681ec 100644 --- a/packages/grafana-i18n/src/i18n.tsx +++ b/packages/grafana-i18n/src/i18n.tsx @@ -6,32 +6,66 @@ import { initReactI18next, setDefaults, setI18n, Trans as I18NextTrans, getI18n import { DEFAULT_LANGUAGE, PSEUDO_LOCALE } from './constants'; import { initRegionalFormat } from './dates'; import { LANGUAGES } from './languages'; -import { TFunction, TransProps, TransType } from './types'; +import { ResourceLoader, Resources, TFunction, TransProps, TransType } from './types'; let tFunc: I18NextTFunction | undefined; let transComponent: TransType; -export async function initPluginTranslations(id: string) { +// exported for testing +export async function loadPluginResources(id: string, language: string, loaders?: ResourceLoader[]) { + if (!loaders?.length) { + return; + } + + return Promise.all( + loaders.map(async (loader) => { + try { + const resources = await loader(language); + addResourceBundle(language, id, resources); + } catch (error) { + console.error(`Error loading resources for plugin ${id} and language: ${language}`, error); + } + }) + ); +} + +// exported for testing +export async function initDefaultI18nInstance() { // If the resources are not an object, we need to initialize the plugin translations - if (!getI18nInstance().options?.resources || typeof getI18nInstance().options.resources !== 'object') { - await getI18nInstance().use(initReactI18next).init({ - resources: {}, - returnEmptyString: false, - lng: DEFAULT_LANGUAGE, // this should be the locale of the phrases in our source JSX - }); + if (getI18nInstance().options?.resources && typeof getI18nInstance().options.resources === 'object') { + return; } + await getI18nInstance().use(initReactI18next).init({ + resources: {}, + returnEmptyString: false, + lng: DEFAULT_LANGUAGE, // this should be the locale of the phrases in our source JSX + }); +} + +// exported for testing +export function initDefaultReactI18nInstance() { // If the initReactI18next is not set, we need to set them - if (!getI18n()?.options?.react) { - const options: ReactOptions = {}; - setDefaults(options); - setI18n(getI18nInstance()); + if (getI18n()?.options?.react) { + return; } + const options: ReactOptions = {}; + setDefaults(options); + setI18n(getI18nInstance()); +} + +export async function initPluginTranslations(id: string, loaders?: ResourceLoader[]) { + await initDefaultI18nInstance(); + initDefaultReactI18nInstance(); + + const language = getResolvedLanguage(); tFunc = getI18nInstance().getFixedT(null, id); transComponent = (props: TransProps) => ; - return { language: getI18nInstance().resolvedLanguage }; + await loadPluginResources(id, language, loaders); + + return { language }; } export function getI18nInstance() { @@ -103,7 +137,7 @@ async function initTranslations({ transComponent = (props: TransProps) => ; return { - language: getI18nInstance().resolvedLanguage, + language: getResolvedLanguage(), }; } @@ -132,12 +166,8 @@ export async function initializeI18n( return initTranslations({ language, ns, module }); } -type ResourceKey = string; -type ResourceLanguage = Record; -type ResourceType = Record; - -export function addResourceBundle(language: string, namespace: string, resource: ResourceType) { - getI18nInstance().addResourceBundle(language, namespace, resource, undefined, true); +export function addResourceBundle(language: string, namespace: string, resources: Resources) { + getI18nInstance().addResourceBundle(language, namespace, resources, true, false); } export const t: TFunction = (id: string, defaultMessage: string, values?: Record) => { diff --git a/packages/grafana-i18n/src/index.ts b/packages/grafana-i18n/src/index.ts index 9a58ce0918f..1aa6432ed39 100644 --- a/packages/grafana-i18n/src/index.ts +++ b/packages/grafana-i18n/src/index.ts @@ -23,5 +23,5 @@ export { DEFAULT_LANGUAGE, } from './constants'; export { initPluginTranslations, Trans, useTranslate } from './i18n'; -export type { TFunction, TransProps } from './types'; +export type { ResourceLoader, Resources, TFunction, TransProps } from './types'; export { formatDate, formatDuration, formatDateRange } from './dates'; diff --git a/packages/grafana-i18n/src/types.ts b/packages/grafana-i18n/src/types.ts index 07be917522a..52dc6d856bb 100644 --- a/packages/grafana-i18n/src/types.ts +++ b/packages/grafana-i18n/src/types.ts @@ -65,4 +65,16 @@ type TransType = typeof Trans; */ type TFunction = (id: string, defaultMessage: string, values?: Record) => string; -export type { UseTranslateHook, TransProps, TransType, TFunction }; +/** + * Type for the resources object + */ +interface Resources extends Record {} + +/** + * Type for the resource loader function + * @param resolvedLanguage - The resolved language to load resources for + * @returns A promise that resolves to the resources + */ +type ResourceLoader = (resolvedLanguage: string) => Promise; + +export type { ResourceLoader, Resources, TransProps, TransType, TFunction, UseTranslateHook };