i18n: consolidate i18n types & runtime services (#102535)

* i18n: consolidate i18n types & runtime services

* Chore: updates after PR feedback

* Chore: updates after feedback

* Chore: updates after feedback

* Chore: updates after PR feedback

* Chore: fix i18n

* Chore: updates after PR feedback
pull/102721/head
Hugo Häggmark 3 months ago committed by GitHub
parent 9e9eb7a4f8
commit 6f2a9abc03
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 9
      .betterer.results
  2. 63
      packages/grafana-runtime/src/types/i18n.ts
  3. 3
      packages/grafana-runtime/src/unstable.ts
  4. 21
      packages/grafana-runtime/src/utils/i18n.ts
  5. 57
      packages/grafana-runtime/src/utils/i18n.tsx
  6. 4
      public/app/app.ts
  7. 92
      public/app/core/internationalization/index.test.tsx
  8. 29
      public/app/core/internationalization/index.tsx
  9. 3
      public/locales/en-US/grafana.json

@ -800,8 +800,7 @@ exports[`better eslint`] = {
"public/app/app.ts:5381": [
[0, 0, 0, "\'@grafana/runtime/src/components/PanelDataErrorView\' import is restricted from being used by a pattern. Import from the public export instead.", "0"],
[0, 0, 0, "\'@grafana/runtime/src/components/PanelRenderer\' import is restricted from being used by a pattern. Import from the public export instead.", "1"],
[0, 0, 0, "\'@grafana/runtime/src/components/PluginPage\' import is restricted from being used by a pattern. Import from the public export instead.", "2"],
[0, 0, 0, "\'@grafana/runtime/src/unstable\' import is restricted from being used by a pattern. Import from the public export instead.", "3"]
[0, 0, 0, "\'@grafana/runtime/src/components/PluginPage\' import is restricted from being used by a pattern. Import from the public export instead.", "2"]
],
"public/app/core/TableModel.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
@ -1016,6 +1015,12 @@ exports[`better eslint`] = {
[0, 0, 0, "Do not re-export imported variable (\`profiler\`)", "6"],
[0, 0, 0, "Do not re-export imported variable (\`updateLegendValues\`)", "7"]
],
"public/app/core/internationalization/index.test.tsx:5381": [
[0, 0, 0, "\'@grafana/runtime/src/unstable\' import is restricted from being used by a pattern. Import from the public export instead.", "0"]
],
"public/app/core/internationalization/index.tsx:5381": [
[0, 0, 0, "\'@grafana/runtime/src/unstable\' import is restricted from being used by a pattern. Import from the public export instead.", "0"]
],
"public/app/core/navigation/GrafanaRouteError.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"]

@ -0,0 +1,63 @@
/**
* Hook type for translation function that takes an ID, default message, and optional values
* @returns A function that returns the translated string
*/
type UseTranslateHook = () => (id: string, defaultMessage: string, values?: Record<string, unknown>) => string;
/**
* Type for children elements in Trans component
* Can be either React nodes or an object of values
*/
type TransChild = React.ReactNode | Record<string, unknown>;
/**
* Props interface for the Trans component used for internationalization
*/
interface TransProps {
/**
* The translation key to look up
*/
i18nKey: string;
/**
* Child elements or values to interpolate
*/
children?: TransChild | readonly TransChild[];
/**
* React elements to use for interpolation
*/
components?: readonly React.ReactElement[] | { readonly [tagName: string]: React.ReactElement };
/**
* Count value for pluralization
*/
count?: number;
/**
* Default text if translation is not found
*/
defaults?: string;
/**
* Namespace for the translation key
*/
ns?: string;
/**
* Whether to unescape HTML entities
*/
shouldUnescape?: boolean;
/**
* Values to interpolate into the translation
*/
values?: Record<string, unknown>;
}
/**
* Function declaration for the Trans component
* @param props - The TransProps object containing translation configuration
* @returns A React element with translated content
*/
declare function Trans(props: TransProps): React.ReactElement;
/**
* Type alias for the Trans component
*/
type TransType = typeof Trans;
export type { UseTranslateHook, TransProps, TransType };

@ -9,4 +9,5 @@
* and be subject to the standard policies
*/
export { useTranslate, setUseTranslateHook } from './utils/i18n';
export { useTranslate, setUseTranslateHook, setTransComponent, Trans } from './utils/i18n';
export type { TransProps } from './types/i18n';

@ -1,21 +0,0 @@
type UseTranslateHook = () => (id: string, defaultMessage: string, values?: Record<string, unknown>) => string;
/**
* Provides a i18next-compatible translation function.
*/
export let useTranslate: UseTranslateHook = () => {
// Fallback implementation that should be overridden by setUseT
const errorMessage = 'useTranslate is not set. useTranslate must not be called before Grafana is initialized.';
if (process.env.NODE_ENV === 'development') {
throw new Error(errorMessage);
}
console.error(errorMessage);
return (id: string, defaultMessage: string) => {
return defaultMessage;
};
};
export function setUseTranslateHook(hook: UseTranslateHook) {
useTranslate = hook;
}

@ -0,0 +1,57 @@
import { type TransProps, type TransType, type UseTranslateHook } from '../types/i18n';
/**
* Provides a i18next-compatible translation function.
*/
export let useTranslate: UseTranslateHook = useTranslateDefault;
function useTranslateDefault() {
// Fallback implementation that should be overridden by setUseT
const errorMessage = 'useTranslate is not set. useTranslate must not be called before Grafana is initialized.';
if (process.env.NODE_ENV === 'development') {
throw new Error(errorMessage);
}
console.error(errorMessage);
return (id: string, defaultMessage: string) => {
return defaultMessage;
};
}
export function setUseTranslateHook(hook: UseTranslateHook) {
useTranslate = hook;
}
let TransComponent: TransType | undefined;
/**
* Sets the Trans component that will be used for translations throughout the application.
* This function should only be called once during application initialization.
*
* @param transComponent - The Trans component function to use for translations
* @throws {Error} If called multiple times outside of test environment
*/
export function setTransComponent(transComponent: TransType) {
// We allow overriding the trans component in tests
if (TransComponent && process.env.NODE_ENV !== 'test') {
throw new Error('setTransComponent() function should only be called once, when Grafana is starting.');
}
TransComponent = transComponent;
}
/**
* A React component for handling translations with support for interpolation and pluralization.
* This component must be initialized using setTransComponent before use.
*
* @param props - The translation props including the i18nKey and any interpolation values
* @returns A React element containing the translated content
* @throws {Error} If the Trans component hasn't been initialized
*/
export function Trans(props: TransProps): React.ReactElement {
if (!TransComponent) {
throw new Error('Trans component not set. Use setTransComponent to set the Trans component.');
}
return <TransComponent {...props} />;
}

@ -45,7 +45,6 @@ import {
import { setPanelDataErrorView } from '@grafana/runtime/src/components/PanelDataErrorView';
import { setPanelRenderer } from '@grafana/runtime/src/components/PanelRenderer';
import { setPluginPage } from '@grafana/runtime/src/components/PluginPage';
import { setUseTranslateHook } from '@grafana/runtime/src/unstable';
import config, { updateConfig } from 'app/core/config';
import { getStandardTransformers } from 'app/features/transformers/standardTransformers';
@ -59,7 +58,7 @@ import { getAllOptionEditors, getAllStandardFieldConfigs } from './core/componen
import { PluginPage } from './core/components/Page/PluginPage';
import { GrafanaContextType, useChromeHeaderHeight, useReturnToPreviousInternal } from './core/context/GrafanaContext';
import { initializeCrashDetection } from './core/crash';
import { initializeI18n, useTranslateInternal } from './core/internationalization';
import { initializeI18n } from './core/internationalization';
import { setMonacoEnv } from './core/monacoEnv';
import { interceptLinkClicks } from './core/navigation/patch/interceptLinkClicks';
import { CorrelationsService } from './core/services/CorrelationsService';
@ -254,7 +253,6 @@ export class GrafanaApp {
setReturnToPreviousHook(useReturnToPreviousInternal);
setChromeHeaderHeightHook(useChromeHeaderHeight);
setUseTranslateHook(useTranslateInternal);
if (config.featureToggles.crashDetection) {
initializeCrashDetection();

@ -1,6 +1,41 @@
import { render } from '@testing-library/react';
import { I18nextProvider } from 'react-i18next';
import { Trans } from './index';
import { PluginContextProvider, PluginMeta, PluginType } from '@grafana/data';
import {
Trans as PluginTrans,
setTransComponent,
setUseTranslateHook,
useTranslate,
} from '@grafana/runtime/src/unstable';
import { getI18next, Trans, useTranslateInternal } from './index';
const id = 'frontend-test-locales-plugin';
const mockedMeta: PluginMeta = {
id,
name: 'Frontend Test Locales Plugin',
type: PluginType.panel,
info: {
author: { name: 'Test Author' },
description: 'Test Description',
links: [],
logos: {
large: 'test-plugin-large-logo',
small: 'test-plugin-small-logo',
},
screenshots: [],
version: '1.0.0',
updated: '2021-01-01',
},
module: 'test-plugin',
baseUrl: 'test-plugin',
};
const DummyUseTranslateComponent = () => {
const t = useTranslate();
return <div>{t('frontendtests.test-key', 'test-key not found')}</div>;
};
describe('internationalization', () => {
describe('Trans component', () => {
@ -22,4 +57,59 @@ describe('internationalization', () => {
expect(getByText('Table - &lt;script&gt;&lt;&#x2F;script&gt;')).toBeInTheDocument();
});
});
describe('for plugins', () => {
beforeEach(() => {
getI18next().addResourceBundle('en', id, { 'frontendtests.test-key': 'test-value' }, undefined, true);
setTransComponent(Trans);
setUseTranslateHook(useTranslateInternal);
});
it('should return the correct value when using Trans component within a plugin context', async () => {
const { getByText, queryByText } = render(
<I18nextProvider i18n={getI18next()}>
<PluginContextProvider meta={mockedMeta}>
<PluginTrans i18nKey="frontendtests.test-key" defaults="test-key not found" />
</PluginContextProvider>
</I18nextProvider>
);
expect(getByText('test-value')).toBeInTheDocument();
expect(queryByText('test-key not found')).not.toBeInTheDocument();
});
it('should return the correct value when using Trans component without a plugin context', async () => {
const { getByText, queryByText } = render(
<I18nextProvider i18n={getI18next()}>
<PluginTrans i18nKey="frontendtests.test-key" defaults="test-key not found" />
</I18nextProvider>
);
expect(getByText('test-key not found')).toBeInTheDocument();
expect(queryByText('test-value')).not.toBeInTheDocument();
});
it('should return the correct value when using useTranslate hook within a plugin context', async () => {
const { getByText, queryByText } = render(
<I18nextProvider i18n={getI18next()}>
<PluginContextProvider meta={mockedMeta}>
<DummyUseTranslateComponent />
</PluginContextProvider>
</I18nextProvider>
);
expect(getByText('test-value')).toBeInTheDocument();
expect(queryByText('test-key not found')).not.toBeInTheDocument();
});
it('should return the correct value when using useTranslate hook without a plugin context', async () => {
const { getByText, queryByText } = render(
<I18nextProvider i18n={getI18next()}>
<DummyUseTranslateComponent />
</I18nextProvider>
);
expect(getByText('test-key not found')).toBeInTheDocument();
expect(queryByText('test-value')).not.toBeInTheDocument();
});
});
});

@ -1,8 +1,11 @@
import i18n, { InitOptions, TFunction } from 'i18next';
import LanguageDetector, { DetectorOptions } from 'i18next-browser-languagedetector';
import { ReactElement } from 'react';
import { ReactElement, useMemo } from 'react';
import { Trans as I18NextTrans, initReactI18next } from 'react-i18next'; // eslint-disable-line no-restricted-imports
import { usePluginContext } from '@grafana/data';
import { setTransComponent, setUseTranslateHook, TransProps } from '@grafana/runtime/src/unstable';
import { DEFAULT_LANGUAGE, NAMESPACES, VALID_LANGUAGES } from './constants';
import { loadTranslations } from './loadTranslations';
@ -59,6 +62,9 @@ export async function initializeI18n(language: string): Promise<{ language: stri
tFunc = i18n.getFixedT(null, NAMESPACES);
setUseTranslateHook(useTranslateInternal);
setTransComponent(Trans);
return {
language: i18nInstance.resolvedLanguage,
};
@ -69,14 +75,14 @@ export function changeLanguage(locale: string) {
return i18n.changeLanguage(validLocale);
}
type I18NextTransType = typeof I18NextTrans;
type I18NextTransProps = Parameters<I18NextTransType>[0];
export const Trans = (props: TransProps): ReactElement => {
const context = usePluginContext();
interface TransProps extends I18NextTransProps {
i18nKey: string;
}
// If we are in a plugin context, use the plugin's id as the namespace
if (context?.meta?.id) {
return <I18NextTrans shouldUnescape ns={context.meta.id} {...props} />;
}
export const Trans = (props: TransProps): ReactElement => {
return <I18NextTrans shouldUnescape ns={NAMESPACES} {...props} />;
};
@ -131,5 +137,12 @@ export function getI18next() {
// Perhaps in the future this will use useTranslation from react-i18next or something else
// from context
export function useTranslateInternal() {
return t;
const context = usePluginContext();
if (!context) {
return t;
}
const { meta } = context;
const pluginT = useMemo(() => getI18next().getFixedT(null, meta.id), [meta.id]);
return pluginT;
}

@ -2392,6 +2392,9 @@
"description": "Changes that you made may not be saved.",
"discard-button": "Discard unsaved changes"
},
"frontendtests": {
"test-key": "test-key not found"
},
"gen-ai": {
"apply-suggestion": "Apply",
"incomplete-request-error": "Sorry, I was unable to complete your request. Please try again.",

Loading…
Cancel
Save