) => 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;
-}
diff --git a/packages/grafana-runtime/src/utils/i18n.tsx b/packages/grafana-runtime/src/utils/i18n.tsx
new file mode 100644
index 00000000000..30c06263dfc
--- /dev/null
+++ b/packages/grafana-runtime/src/utils/i18n.tsx
@@ -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 ;
+}
diff --git a/public/app/app.ts b/public/app/app.ts
index f9e65818a3d..f576ba415a0 100644
--- a/public/app/app.ts
+++ b/public/app/app.ts
@@ -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();
diff --git a/public/app/core/internationalization/index.test.tsx b/public/app/core/internationalization/index.test.tsx
index 8eabddb6f15..8dab4d6e250 100644
--- a/public/app/core/internationalization/index.test.tsx
+++ b/public/app/core/internationalization/index.test.tsx
@@ -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 {t('frontendtests.test-key', 'test-key not found')}
;
+};
describe('internationalization', () => {
describe('Trans component', () => {
@@ -22,4 +57,59 @@ describe('internationalization', () => {
expect(getByText('Table - <script></script>')).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(
+
+
+
+
+
+ );
+
+ 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(
+
+
+
+ );
+
+ 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(
+
+
+
+
+
+ );
+
+ 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(
+
+
+
+ );
+
+ expect(getByText('test-key not found')).toBeInTheDocument();
+ expect(queryByText('test-value')).not.toBeInTheDocument();
+ });
+ });
});
diff --git a/public/app/core/internationalization/index.tsx b/public/app/core/internationalization/index.tsx
index 821887eb53a..73242d04728 100644
--- a/public/app/core/internationalization/index.tsx
+++ b/public/app/core/internationalization/index.tsx
@@ -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[0];
-
-interface TransProps extends I18NextTransProps {
- i18nKey: string;
-}
-
export const Trans = (props: TransProps): ReactElement => {
+ const context = usePluginContext();
+
+ // If we are in a plugin context, use the plugin's id as the namespace
+ if (context?.meta?.id) {
+ return ;
+ }
+
return ;
};
@@ -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;
}
diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json
index 774dba60d03..301e0a5ddc2 100644
--- a/public/locales/en-US/grafana.json
+++ b/public/locales/en-US/grafana.json
@@ -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.",