From e49c73533b5a456481acf08847aa6d7ccfcb9790 Mon Sep 17 00:00:00 2001 From: Josh Hunt Date: Tue, 17 Jun 2025 10:55:15 +0100 Subject: [PATCH] I18n: Allow pseudo-locale to be enabled in production builds (#106738) * I18n: Allow pseudo-locale to be enabled in production builds * fix tests now that pseudo is around * remove psuedo locale from i18n package * load en-us from plugin resources for pseudo * fix tests + remove 'hidden' option --------- Co-authored-by: Ashley Harrison --- packages/grafana-i18n/src/i18n.tsx | 24 +++++++++----- packages/grafana-i18n/src/languages.test.ts | 31 ++++++++++++------- packages/grafana-i18n/src/languages.ts | 8 ++--- .../SharedPreferences.test.tsx | 18 +++++++++++ .../SharedPreferences/SharedPreferences.tsx | 7 +++++ .../internationalization/constants.test.ts | 20 ------------ .../core/internationalization/constants.ts | 8 ++--- 7 files changed, 67 insertions(+), 49 deletions(-) diff --git a/packages/grafana-i18n/src/i18n.tsx b/packages/grafana-i18n/src/i18n.tsx index 0a737a95fcc..6f9ceb0e830 100644 --- a/packages/grafana-i18n/src/i18n.tsx +++ b/packages/grafana-i18n/src/i18n.tsx @@ -11,6 +11,14 @@ import { ResourceLoader, Resources, TFunction, TransProps, TransType } from './t let tFunc: I18NextTFunction | undefined; let transComponent: TransType; +const VALID_LANGUAGES = [ + ...LANGUAGES, + { + name: 'Pseudo', + code: PSEUDO_LOCALE, + }, +]; + function initTFuncAndTransComponent({ id, ns }: { id?: string; ns?: string[] } = {}) { if (id) { tFunc = getI18nInstance().getFixedT(null, id); @@ -28,13 +36,15 @@ export async function loadPluginResources(id: string, language: string, loaders? return; } + const resolvedLanguage = language === PSEUDO_LOCALE ? DEFAULT_LANGUAGE : language; + return Promise.all( loaders.map(async (loader) => { try { - const resources = await loader(language); - addResourceBundle(language, id, resources); + const resources = await loader(resolvedLanguage); + addResourceBundle(resolvedLanguage, id, resources); } catch (error) { - console.error(`Error loading resources for plugin ${id} and language: ${language}`, error); + console.error(`Error loading resources for plugin ${id} and language: ${resolvedLanguage}`, error); } }) ); @@ -108,7 +118,7 @@ async function initTranslations({ returnEmptyString: false, // Required to ensure that `resolvedLanguage` is set property when an invalid language is passed (such as through 'detect') - supportedLngs: LANGUAGES.map((language) => language.code), + supportedLngs: VALID_LANGUAGES.map((lang) => lang.code), fallbackLng: DEFAULT_LANGUAGE, ns, @@ -123,7 +133,7 @@ async function initTranslations({ const detection: DetectorOptions = { order: ['navigator'], caches: [] }; options.detection = detection; } else { - options.lng = LANGUAGES.find((lang) => lang.code === language)?.code ?? undefined; + options.lng = VALID_LANGUAGES.find((lang) => lang.code === language)?.code ?? undefined; } if (module) { @@ -132,7 +142,7 @@ async function initTranslations({ getI18nInstance().use(initReactI18next); // passes i18n down to react-i18next } - if (process.env.NODE_ENV === 'development') { + if (language === PSEUDO_LOCALE) { const { default: Pseudo } = await import('i18next-pseudo'); getI18nInstance().use( new Pseudo({ @@ -165,7 +175,7 @@ export function getNamespaces() { } export async function changeLanguage(language?: string) { - const validLanguage = LANGUAGES.find((lang) => lang.code === language)?.code ?? DEFAULT_LANGUAGE; + const validLanguage = VALID_LANGUAGES.find((lang) => lang.code === language)?.code ?? DEFAULT_LANGUAGE; await getI18nInstance().changeLanguage(validLanguage); } diff --git a/packages/grafana-i18n/src/languages.test.ts b/packages/grafana-i18n/src/languages.test.ts index 4c2804b5bb2..421486d3043 100644 --- a/packages/grafana-i18n/src/languages.test.ts +++ b/packages/grafana-i18n/src/languages.test.ts @@ -27,19 +27,28 @@ describe('LANGUAGES', () => { expect(LANGUAGES).toEqual(expectedLanguages); }); - it('should include the pseudo-locale in development mode', async () => { - process.env.NODE_ENV = 'development'; - jest.resetModules(); - const { LANGUAGES: languages } = await import('./languages'); - - expect(languages).toEqual([...expectedLanguages, { code: 'pseudo', name: 'Pseudo-locale' }]); + it('should match a canonical locale definition', () => { + for (const lang of LANGUAGES) { + const resolved = Intl.getCanonicalLocales(lang.code); + expect(lang.code).toEqual(resolved[0]); + } }); - it('should not include the pseudo-locale in production mode', async () => { - process.env.NODE_ENV = 'production'; - jest.resetModules(); - const { LANGUAGES: languages } = await import('./languages'); + it('should have locale codes including the country code', () => { + for (const lang of LANGUAGES) { + if (lang.code === 'pseudo') { + // special case pseudo because its not a real language + continue; + } + expect(lang.code).toMatch(/^[a-z]{2}-[a-zA-Z]+$/); + } + }); - expect(languages).toEqual(expectedLanguages); + it('should not have duplicate languages codes', () => { + for (let i = 0; i < LANGUAGES.length; i++) { + const lang = LANGUAGES[i]; + const index = LANGUAGES.findIndex((v) => v.code === lang.code); + expect(index).toBe(i); + } }); }); diff --git a/packages/grafana-i18n/src/languages.ts b/packages/grafana-i18n/src/languages.ts index 697dbf99481..9d7db4d59ed 100644 --- a/packages/grafana-i18n/src/languages.ts +++ b/packages/grafana-i18n/src/languages.ts @@ -18,11 +18,13 @@ import { POLISH_POLAND, SWEDISH_SWEDEN, TURKISH_TURKEY, - PSEUDO_LOCALE, } from './constants'; interface TranslationDefinition { + /** IETF language tag */ code: string; + + /** The language name in its own language (e.g. "Français" for French) */ name: string; } @@ -50,7 +52,3 @@ export const LANGUAGES: TranslationDefinition[] = [ { code: SWEDISH_SWEDEN, name: 'Svenska' }, { code: TURKISH_TURKEY, name: 'Türkçe' }, ]; - -if (process.env.NODE_ENV === 'development') { - LANGUAGES.push({ code: PSEUDO_LOCALE, name: 'Pseudo-locale' }); -} diff --git a/public/app/core/components/SharedPreferences/SharedPreferences.test.tsx b/public/app/core/components/SharedPreferences/SharedPreferences.test.tsx index aa6a1217338..7039dcd3505 100644 --- a/public/app/core/components/SharedPreferences/SharedPreferences.test.tsx +++ b/public/app/core/components/SharedPreferences/SharedPreferences.test.tsx @@ -161,6 +161,24 @@ describe('SharedPreferences', () => { expect(langSelect).toHaveValue('Default'); }); + it('does not render the pseudo-locale', async () => { + const langSelect = await screen.findByRole('combobox', { name: /language/i }); + + // Open the combobox and wait for the options to be rendered + await userEvent.click(langSelect); + + // TODO: The input value should be cleared when clicked, but for some reason it's not? + // checking langSelect.value beforehand indicates that it is cleared, but after using + // userEvent.type the default value comes back? + await userEvent.type( + langSelect, + '{Backspace}{Backspace}{Backspace}{Backspace}{Backspace}{Backspace}{Backspace}Pseudo' + ); + + const option = screen.queryByRole('option', { name: 'Pseudo-locale' }); + expect(option).not.toBeInTheDocument(); + }); + it('saves the users new preferences', async () => { await selectComboboxOptionInTest(await screen.findByRole('combobox', { name: 'Interface theme' }), 'Dark'); await selectOptionInTest(screen.getByLabelText('Timezone'), 'Australia/Sydney'); diff --git a/public/app/core/components/SharedPreferences/SharedPreferences.tsx b/public/app/core/components/SharedPreferences/SharedPreferences.tsx index 81feb53b398..2991942372b 100644 --- a/public/app/core/components/SharedPreferences/SharedPreferences.tsx +++ b/public/app/core/components/SharedPreferences/SharedPreferences.tsx @@ -57,6 +57,13 @@ function getLanguageOptions(): ComboboxOption[] { return a.label.localeCompare(b.label); }); + if (process.env.NODE_ENV === 'development') { + languageOptions.push({ + value: PSEUDO_LOCALE, + label: 'Pseudo-locale', + }); + } + const options = [ { value: '', diff --git a/public/app/core/internationalization/constants.test.ts b/public/app/core/internationalization/constants.test.ts index e48cae33433..0a7bab493cf 100644 --- a/public/app/core/internationalization/constants.test.ts +++ b/public/app/core/internationalization/constants.test.ts @@ -1,26 +1,6 @@ -import { uniqBy } from 'lodash'; - import { LANGUAGES, VALID_LANGUAGES } from './constants'; describe('internationalization constants', () => { - it('should match a canonical locale definition', () => { - for (const lang of LANGUAGES) { - const resolved = Intl.getCanonicalLocales(lang.code); - expect(lang.code).toEqual(resolved[0]); - } - }); - - it('should have locale codes including the country code', () => { - for (const lang of LANGUAGES) { - expect(lang.code).toMatch(/^[a-z]{2}-[a-zA-Z]+$/); - } - }); - - it('should not have duplicate languages codes', () => { - const uniqLocales = uniqBy(LANGUAGES, (v) => v.code); - expect(LANGUAGES).toHaveLength(uniqLocales.length); - }); - it('should have a correct list of valid locale codes', () => { expect(VALID_LANGUAGES).toEqual(LANGUAGES.map((v) => v.code)); }); diff --git a/public/app/core/internationalization/constants.ts b/public/app/core/internationalization/constants.ts index 05cb9a6d40e..769f79f4a32 100644 --- a/public/app/core/internationalization/constants.ts +++ b/public/app/core/internationalization/constants.ts @@ -4,13 +4,9 @@ import { uniq } from 'lodash'; import { DEFAULT_LANGUAGE, PSEUDO_LOCALE, LANGUAGES as SUPPORTED_LANGUAGES } from '@grafana/i18n'; export type LocaleFileLoader = () => Promise; -export interface LanguageDefinition { - /** IETF language tag for the language e.g. en-US */ - code: string; - - /** Language name to show in the UI. Should be formatted local to that language e.g. Français for French */ - name: string; +type BaseLanguageDefinition = (typeof SUPPORTED_LANGUAGES)[number]; +export interface LanguageDefinition extends BaseLanguageDefinition { /** Function to load translations */ loader: Record; }