From 6f2a9abc0353829869ea9dcbb90a6576c8a41524 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Mon, 24 Mar 2025 13:20:00 +0100 Subject: [PATCH] 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 --- .betterer.results | 9 +- packages/grafana-runtime/src/types/i18n.ts | 63 +++++++++++++ packages/grafana-runtime/src/unstable.ts | 3 +- packages/grafana-runtime/src/utils/i18n.ts | 21 ----- packages/grafana-runtime/src/utils/i18n.tsx | 57 ++++++++++++ public/app/app.ts | 4 +- .../core/internationalization/index.test.tsx | 92 ++++++++++++++++++- .../app/core/internationalization/index.tsx | 31 +++++-- public/locales/en-US/grafana.json | 3 + 9 files changed, 246 insertions(+), 37 deletions(-) create mode 100644 packages/grafana-runtime/src/types/i18n.ts delete mode 100644 packages/grafana-runtime/src/utils/i18n.ts create mode 100644 packages/grafana-runtime/src/utils/i18n.tsx diff --git a/.betterer.results b/.betterer.results index b93947f59dd..6c13f9b1ddb 100644 --- a/.betterer.results +++ b/.betterer.results @@ -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 or use t()", "0"], [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "1"] diff --git a/packages/grafana-runtime/src/types/i18n.ts b/packages/grafana-runtime/src/types/i18n.ts new file mode 100644 index 00000000000..31879879a5f --- /dev/null +++ b/packages/grafana-runtime/src/types/i18n.ts @@ -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; + +/** + * Type for children elements in Trans component + * Can be either React nodes or an object of values + */ +type TransChild = React.ReactNode | Record; + +/** + * 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; +} + +/** + * 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 }; diff --git a/packages/grafana-runtime/src/unstable.ts b/packages/grafana-runtime/src/unstable.ts index 84075dbb08a..07bb817ac5b 100644 --- a/packages/grafana-runtime/src/unstable.ts +++ b/packages/grafana-runtime/src/unstable.ts @@ -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'; diff --git a/packages/grafana-runtime/src/utils/i18n.ts b/packages/grafana-runtime/src/utils/i18n.ts deleted file mode 100644 index f67f63316e5..00000000000 --- a/packages/grafana-runtime/src/utils/i18n.ts +++ /dev/null @@ -1,21 +0,0 @@ -type UseTranslateHook = () => (id: string, defaultMessage: string, values?: Record) => 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.",