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
This commit is contained in:
+7
-2
@@ -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} />;
|
||||
}
|
||||
+1
-3
@@ -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 - <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(
|
||||
<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];
|
||||
|
||||
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 <I18NextTrans shouldUnescape ns={context.meta.id} {...props} />;
|
||||
}
|
||||
|
||||
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.",
|
||||
|
||||
Reference in New Issue
Block a user