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:
co-authored by
joshhunt
Ashley Harrison
parent
98d1821948
commit
b5b02e2c08
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>) => {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user