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": [
|
"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/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/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/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"]
|
|
||||||
],
|
],
|
||||||
"public/app/core/TableModel.ts:5381": [
|
"public/app/core/TableModel.ts:5381": [
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
[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 (\`profiler\`)", "6"],
|
||||||
[0, 0, 0, "Do not re-export imported variable (\`updateLegendValues\`)", "7"]
|
[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": [
|
"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()", "0"],
|
||||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"]
|
[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
|
* 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 { setPanelDataErrorView } from '@grafana/runtime/src/components/PanelDataErrorView';
|
||||||
import { setPanelRenderer } from '@grafana/runtime/src/components/PanelRenderer';
|
import { setPanelRenderer } from '@grafana/runtime/src/components/PanelRenderer';
|
||||||
import { setPluginPage } from '@grafana/runtime/src/components/PluginPage';
|
import { setPluginPage } from '@grafana/runtime/src/components/PluginPage';
|
||||||
import { setUseTranslateHook } from '@grafana/runtime/src/unstable';
|
|
||||||
import config, { updateConfig } from 'app/core/config';
|
import config, { updateConfig } from 'app/core/config';
|
||||||
import { getStandardTransformers } from 'app/features/transformers/standardTransformers';
|
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 { PluginPage } from './core/components/Page/PluginPage';
|
||||||
import { GrafanaContextType, useChromeHeaderHeight, useReturnToPreviousInternal } from './core/context/GrafanaContext';
|
import { GrafanaContextType, useChromeHeaderHeight, useReturnToPreviousInternal } from './core/context/GrafanaContext';
|
||||||
import { initializeCrashDetection } from './core/crash';
|
import { initializeCrashDetection } from './core/crash';
|
||||||
import { initializeI18n, useTranslateInternal } from './core/internationalization';
|
import { initializeI18n } from './core/internationalization';
|
||||||
import { setMonacoEnv } from './core/monacoEnv';
|
import { setMonacoEnv } from './core/monacoEnv';
|
||||||
import { interceptLinkClicks } from './core/navigation/patch/interceptLinkClicks';
|
import { interceptLinkClicks } from './core/navigation/patch/interceptLinkClicks';
|
||||||
import { CorrelationsService } from './core/services/CorrelationsService';
|
import { CorrelationsService } from './core/services/CorrelationsService';
|
||||||
@@ -254,7 +253,6 @@ export class GrafanaApp {
|
|||||||
|
|
||||||
setReturnToPreviousHook(useReturnToPreviousInternal);
|
setReturnToPreviousHook(useReturnToPreviousInternal);
|
||||||
setChromeHeaderHeightHook(useChromeHeaderHeight);
|
setChromeHeaderHeightHook(useChromeHeaderHeight);
|
||||||
setUseTranslateHook(useTranslateInternal);
|
|
||||||
|
|
||||||
if (config.featureToggles.crashDetection) {
|
if (config.featureToggles.crashDetection) {
|
||||||
initializeCrashDetection();
|
initializeCrashDetection();
|
||||||
|
|||||||
@@ -1,6 +1,41 @@
|
|||||||
import { render } from '@testing-library/react';
|
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('internationalization', () => {
|
||||||
describe('Trans component', () => {
|
describe('Trans component', () => {
|
||||||
@@ -22,4 +57,59 @@ describe('internationalization', () => {
|
|||||||
expect(getByText('Table - <script></script>')).toBeInTheDocument();
|
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 i18n, { InitOptions, TFunction } from 'i18next';
|
||||||
import LanguageDetector, { DetectorOptions } from 'i18next-browser-languagedetector';
|
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 { 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 { DEFAULT_LANGUAGE, NAMESPACES, VALID_LANGUAGES } from './constants';
|
||||||
import { loadTranslations } from './loadTranslations';
|
import { loadTranslations } from './loadTranslations';
|
||||||
|
|
||||||
@@ -59,6 +62,9 @@ export async function initializeI18n(language: string): Promise<{ language: stri
|
|||||||
|
|
||||||
tFunc = i18n.getFixedT(null, NAMESPACES);
|
tFunc = i18n.getFixedT(null, NAMESPACES);
|
||||||
|
|
||||||
|
setUseTranslateHook(useTranslateInternal);
|
||||||
|
setTransComponent(Trans);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
language: i18nInstance.resolvedLanguage,
|
language: i18nInstance.resolvedLanguage,
|
||||||
};
|
};
|
||||||
@@ -69,14 +75,14 @@ export function changeLanguage(locale: string) {
|
|||||||
return i18n.changeLanguage(validLocale);
|
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 => {
|
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} />;
|
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
|
// Perhaps in the future this will use useTranslation from react-i18next or something else
|
||||||
// from context
|
// from context
|
||||||
export function useTranslateInternal() {
|
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.",
|
"description": "Changes that you made may not be saved.",
|
||||||
"discard-button": "Discard unsaved changes"
|
"discard-button": "Discard unsaved changes"
|
||||||
},
|
},
|
||||||
|
"frontendtests": {
|
||||||
|
"test-key": "test-key not found"
|
||||||
|
},
|
||||||
"gen-ai": {
|
"gen-ai": {
|
||||||
"apply-suggestion": "Apply",
|
"apply-suggestion": "Apply",
|
||||||
"incomplete-request-error": "Sorry, I was unable to complete your request. Please try again.",
|
"incomplete-request-error": "Sorry, I was unable to complete your request. Please try again.",
|
||||||
|
|||||||
Reference in New Issue
Block a user