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 <josh.hunt@grafana.com>
Co-authored-by: Ashley Harrison <ashley.harrison@grafana.com>
This commit is contained in:
Hugo Häggmark
2025-05-28 15:51:59 +01:00
committed by GitHub
co-authored by joshhunt Ashley Harrison
parent 98d1821948
commit b5b02e2c08
4 changed files with 232 additions and 22 deletions
+168
View File
@@ -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);
});
});
});
+50 -20
View File
@@ -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<string[], undefined> | 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) => <I18NextTrans shouldUnescape ns={id} {...props} />;
return { language: getI18nInstance().resolvedLanguage };
await loadPluginResources(id, language, loaders);
return { language };
}
export function getI18nInstance() {
@@ -103,7 +137,7 @@ async function initTranslations({
transComponent = (props: TransProps) => <I18NextTrans shouldUnescape ns={ns} {...props} />;
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<string, ResourceKey>;
type ResourceType = Record<string, ResourceLanguage>;
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<string, unknown>) => {
+1 -1
View File
@@ -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';
+13 -1
View File
@@ -65,4 +65,16 @@ type TransType = typeof Trans;
*/
type TFunction = (id: string, defaultMessage: string, values?: Record<string, unknown>) => string;
export type { UseTranslateHook, TransProps, TransType, TFunction };
/**
* Type for the resources object
*/
interface Resources extends Record<string, string | Resources | unknown> {}
/**
* 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<Resources>;
export type { ResourceLoader, Resources, TransProps, TransType, TFunction, UseTranslateHook };