From 67c9d0d1a416ed8bb6ae4afe609280d5b5225efd Mon Sep 17 00:00:00 2001 From: Anan Zhuang Date: Thu, 15 Aug 2024 13:05:54 -0700 Subject: [PATCH] [2.x manual backport]Change the locale dynamically by adding &i18n-locale to URL (#7686) Backport PR: https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7686 * Change the locale dynamically by adding &i18n-locale to URL The main issue was the inability to dynamically change the locale in OpenSearch Dashboards. Currently we need to update config file and i18nrc.json. This PR allows users to switch to a different locale (e.g., from English to Chinese) by appending or modifying the 'i18n-locale' parameter in the URL. * getAndUpdateLocaleInUrl: If a non-default locale is found, this function reconstructs the URL with the locale parameter in the correct position. * updated the ScopedHistory class, allowing it to detect locale changes and trigger reloads as necessary. * modify the i18nMixin, which sets up the i18n system during server startup, to register all available translation files during server startup, not just the current locale. * update the uiRenderMixin to accept requests for any registered locale and dynamically load and cache translations for requested locales. --------- Signed-off-by: Anan Zhuang Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/7686.yml | 2 + packages/osd-i18n/src/core/i18n.test.ts | 34 +++- packages/osd-i18n/src/core/i18n.ts | 9 +- .../public/application/scoped_history.test.ts | 60 ++++++ src/core/public/application/scoped_history.ts | 11 ++ src/core/public/locale_helper.test.ts | 50 +++++ src/core/public/locale_helper.ts | 68 +++++++ src/core/public/osd_bootstrap.test.mocks.ts | 28 +-- src/core/public/osd_bootstrap.test.ts | 67 ++++++- src/core/public/osd_bootstrap.ts | 49 +++++ src/dev/jest/config.js | 1 - src/legacy/server/i18n/index.ts | 11 +- src/legacy/ui/ui_render/ui_render_mixin.js | 67 +++++-- .../ui/ui_render/ui_render_mixin.test.js | 175 ++++++++++++++++++ 14 files changed, 592 insertions(+), 40 deletions(-) create mode 100644 changelogs/fragments/7686.yml create mode 100644 src/core/public/locale_helper.test.ts create mode 100644 src/core/public/locale_helper.ts create mode 100644 src/legacy/ui/ui_render/ui_render_mixin.test.js diff --git a/changelogs/fragments/7686.yml b/changelogs/fragments/7686.yml new file mode 100644 index 000000000000..f446ed68764c --- /dev/null +++ b/changelogs/fragments/7686.yml @@ -0,0 +1,2 @@ +feat: +- Change the locale dynamically by adding &i18n-locale to URL ([#7686](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7686)) \ No newline at end of file diff --git a/packages/osd-i18n/src/core/i18n.test.ts b/packages/osd-i18n/src/core/i18n.test.ts index 0ee114c78c95..ebfd546f8561 100644 --- a/packages/osd-i18n/src/core/i18n.test.ts +++ b/packages/osd-i18n/src/core/i18n.test.ts @@ -899,8 +899,17 @@ describe('I18n engine', () => { describe('load', () => { let mockFetch: jest.SpyInstance; + let originalWindow: any; + beforeEach(() => { mockFetch = jest.spyOn(global as any, 'fetch').mockImplementation(); + originalWindow = global.window; + global.window = { ...originalWindow }; + }); + + afterEach(() => { + global.window = originalWindow; + delete (window as any).__i18nWarning; // Clear the warning after each test }); test('fails if server returns >= 300 status code', async () => { @@ -928,7 +937,7 @@ describe('I18n engine', () => { mockFetch.mockResolvedValue({ status: 200, - json: jest.fn().mockResolvedValue(translations), + json: jest.fn().mockResolvedValue({ translations }), }); await expect(i18n.load('some-url')).resolves.toBeUndefined(); @@ -938,5 +947,28 @@ describe('I18n engine', () => { expect(i18n.getTranslation()).toEqual(translations); }); + + test('sets warning on window when present in response', async () => { + const warning = { title: 'Warning', text: 'This is a warning' }; + mockFetch.mockResolvedValue({ + status: 200, + json: jest.fn().mockResolvedValue({ translations: { locale: 'en' }, warning }), + }); + + await i18n.load('some-url'); + + expect((window as any).__i18nWarning).toEqual(warning); + }); + + test('does not set warning on window when not present in response', async () => { + mockFetch.mockResolvedValue({ + status: 200, + json: jest.fn().mockResolvedValue({ translations: { locale: 'en' } }), + }); + + await i18n.load('some-url'); + + expect((window as any).__i18nWarning).toBeUndefined(); + }); }); }); diff --git a/packages/osd-i18n/src/core/i18n.ts b/packages/osd-i18n/src/core/i18n.ts index 3268fae5079f..65da4931ef13 100644 --- a/packages/osd-i18n/src/core/i18n.ts +++ b/packages/osd-i18n/src/core/i18n.ts @@ -261,5 +261,12 @@ export async function load(translationsUrl: string) { throw new Error(`Translations request failed with status code: ${response.status}`); } - init(await response.json()); + const data = await response.json(); + + if (data.warning) { + // Store the warning to be displayed after core system setup + (window as any).__i18nWarning = data.warning; + } + + init(data.translations); } diff --git a/src/core/public/application/scoped_history.test.ts b/src/core/public/application/scoped_history.test.ts index 067c33256bd1..6575a6aa1ab1 100644 --- a/src/core/public/application/scoped_history.test.ts +++ b/src/core/public/application/scoped_history.test.ts @@ -30,8 +30,23 @@ import { ScopedHistory } from './scoped_history'; import { createMemoryHistory } from 'history'; +import { getLocaleInUrl } from '../locale_helper'; +import { i18n } from '@osd/i18n'; + +jest.mock('../locale_helper', () => ({ + getLocaleInUrl: jest.fn(), +})); + +jest.mock('@osd/i18n', () => ({ + i18n: { + getLocale: jest.fn(), + }, +})); describe('ScopedHistory', () => { + beforeEach(() => { + (getLocaleInUrl as jest.Mock).mockReturnValue('en'); + }); describe('construction', () => { it('succeeds if current location matches basePath', () => { const gh = createMemoryHistory(); @@ -358,4 +373,49 @@ describe('ScopedHistory', () => { expect(gh.length).toBe(4); }); }); + + describe('locale handling', () => { + let originalLocation: Location; + + beforeEach(() => { + originalLocation = window.location; + delete (window as any).location; + window.location = { href: 'http://localhost/app/wow', reload: jest.fn() } as any; + (i18n.getLocale as jest.Mock).mockReturnValue('en'); + }); + + afterEach(() => { + window.location = originalLocation; + jest.resetAllMocks(); + }); + + it('reloads the page when locale changes', () => { + const gh = createMemoryHistory(); + gh.push('/app/wow'); + const h = new ScopedHistory(gh, '/app/wow'); + // Use the 'h' variable to trigger the listener + h.push('/new-page'); + + // Mock getLocaleInUrl to return a different locale + (getLocaleInUrl as jest.Mock).mockReturnValue('fr'); + + // Simulate navigation + gh.push('/app/wow/new-page'); + + expect(window.location.reload).toHaveBeenCalled(); + }); + + it('does not reload the page when locale changes', () => { + const gh = createMemoryHistory(); + gh.push('/app/wow'); + + // Mock getLocaleInUrl to return a different locale + (getLocaleInUrl as jest.Mock).mockReturnValue('en'); + + // Simulate navigation + gh.push('/app/wow/new-page'); + + expect(window.location.reload).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/core/public/application/scoped_history.ts b/src/core/public/application/scoped_history.ts index 487093be191f..74e4bb068d3e 100644 --- a/src/core/public/application/scoped_history.ts +++ b/src/core/public/application/scoped_history.ts @@ -39,6 +39,8 @@ import { Href, Action, } from 'history'; +import { i18n } from '@osd/i18n'; +import { getLocaleInUrl } from '../locale_helper'; /** * A wrapper around a `History` instance that is scoped to a particular base path of the history stack. Behaves @@ -307,6 +309,7 @@ export class ScopedHistory * state. Also forwards events to child listeners with the base path stripped from the location. */ private setupHistoryListener() { + const currentLocale = i18n.getLocale() || 'en'; const unlisten = this.parentHistory.listen((location, action) => { // If the user navigates outside the scope of this basePath, tear it down. if (!location.pathname.startsWith(this.basePath)) { @@ -315,6 +318,14 @@ export class ScopedHistory return; } + const localeValue = getLocaleInUrl(window.location.href); + + if (localeValue !== currentLocale) { + // Force a full page reload + window.location.reload(); + return; + } + /** * Track location keys using the same algorithm the browser uses internally. * - On PUSH, remove all items that came after the current location and append the new location. diff --git a/src/core/public/locale_helper.test.ts b/src/core/public/locale_helper.test.ts new file mode 100644 index 000000000000..238dbced3892 --- /dev/null +++ b/src/core/public/locale_helper.test.ts @@ -0,0 +1,50 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { getLocaleInUrl } from './locale_helper'; + +describe('getLocaleInUrl', () => { + beforeEach(() => { + // Clear any warnings before each test + delete (window as any).__localeWarning; + }); + + it('should return the locale from a valid query string', () => { + const url = 'http://localhost:5603/app/home?locale=en-US'; + expect(getLocaleInUrl(url)).toBe('en-US'); + }); + + it('should return the locale from a valid hash query string', () => { + const url = 'http://localhost:5603/app/home#/?locale=fr-FR'; + expect(getLocaleInUrl(url)).toBe('fr-FR'); + }); + + it('should return en for a URL without locale', () => { + const url = 'http://localhost:5603/app/home'; + expect(getLocaleInUrl(url)).toBe('en'); + }); + + it('should return en and set a warning for an invalid locale format in hash', () => { + const url = 'http://localhost:5603/app/home#/&locale=de-DE'; + expect(getLocaleInUrl(url)).toBe('en'); + expect((window as any).__localeWarning).toBeDefined(); + expect((window as any).__localeWarning.title).toBe('Invalid URL Format'); + }); + + it('should return en for an empty locale value', () => { + const url = 'http://localhost:5603/app/home?locale='; + expect(getLocaleInUrl(url)).toBe('en'); + }); + + it('should handle URLs with other query parameters', () => { + const url = 'http://localhost:5603/app/home?param1=value1&locale=ja-JP¶m2=value2'; + expect(getLocaleInUrl(url)).toBe('ja-JP'); + }); + + it('should handle URLs with other hash parameters', () => { + const url = 'http://localhost:5603/app/home#/route?param1=value1&locale=zh-CN¶m2=value2'; + expect(getLocaleInUrl(url)).toBe('zh-CN'); + }); +}); diff --git a/src/core/public/locale_helper.ts b/src/core/public/locale_helper.ts new file mode 100644 index 000000000000..38a734a523b1 --- /dev/null +++ b/src/core/public/locale_helper.ts @@ -0,0 +1,68 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Extracts the locale value from a given URL. + * + * This function looks for the 'locale' parameter in either the main query string + * or in the hash part of the URL. It supports two valid formats: + * 1. As a regular query parameter: "?locale=xx-XX" + * 2. In the hash with a proper query string: "#/?locale=xx-XX" + * + * If an invalid format is detected, it sets a warning message on the window object. + * + * @param url - The URL to extract the locale from + * @returns The locale value if found and valid, or null otherwise + */ +export function getLocaleInUrl(url: string): string | null { + let urlObject: URL; + // Attempt to parse the URL, return null if invalid + try { + urlObject = new URL(url, window.location.origin); + } catch (error) { + setInvalidUrlWarning(); + return null; + } + + let localeValue: string | null = null; + + // Check for locale in the main query string + if (urlObject.searchParams.has('locale')) { + localeValue = urlObject.searchParams.get('locale'); + } + // Check for locale in the hash, but only if it's in proper query string format + else if (urlObject.hash.includes('?')) { + const hashParams = new URLSearchParams(urlObject.hash.split('?')[1]); + if (hashParams.has('locale')) { + localeValue = hashParams.get('locale'); + } + } + + // Check for non standard query format: + if (localeValue === null && url.includes('&locale=')) { + setInvalidUrlWithLocaleWarning(); + return 'en'; + } + + // Return the locale value if found, or 'en' if not found + return localeValue && localeValue.trim() !== '' ? localeValue : 'en'; +} + +function setInvalidUrlWarning(): void { + (window as any).__localeWarning = { + title: 'Invalid URL Format', + text: 'The provided URL is not in a valid format.', + }; +} + +function setInvalidUrlWithLocaleWarning(): void { + (window as any).__localeWarning = { + title: 'Invalid URL Format', + text: + 'The locale parameter is not in a valid URL format. ' + + 'Use either "?locale=xx-XX" in the main URL or "#/?locale=xx-XX" in the hash. ' + + 'For example: "yourapp.com/page?locale=en-US" or "yourapp.com/page#/?locale=en-US".', + }; +} diff --git a/src/core/public/osd_bootstrap.test.mocks.ts b/src/core/public/osd_bootstrap.test.mocks.ts index 77b47e8b895b..87a6ab499731 100644 --- a/src/core/public/osd_bootstrap.test.mocks.ts +++ b/src/core/public/osd_bootstrap.test.mocks.ts @@ -31,18 +31,6 @@ import { applicationServiceMock } from './application/application_service.mock'; import { fatalErrorsServiceMock } from './fatal_errors/fatal_errors_service.mock'; export const fatalErrorMock = fatalErrorsServiceMock.createSetupContract(); -export const coreSystemMock = { - setup: jest.fn().mockResolvedValue({ - fatalErrors: fatalErrorMock, - }), - start: jest.fn().mockResolvedValue({ - application: applicationServiceMock.createInternalStartContract(), - }), -}; -jest.doMock('./core_system', () => ({ - CoreSystem: jest.fn().mockImplementation(() => coreSystemMock), -})); - export const apmSystem = { setup: jest.fn().mockResolvedValue(undefined), start: jest.fn().mockResolvedValue(undefined), @@ -53,9 +41,25 @@ jest.doMock('./apm_system', () => ({ })); export const i18nLoad = jest.fn().mockResolvedValue(undefined); +export const i18nSetLocale = jest.fn(); jest.doMock('@osd/i18n', () => ({ i18n: { ...jest.requireActual('@osd/i18n').i18n, load: i18nLoad, + setLocale: i18nSetLocale, }, })); + +export const coreSystemMock = { + setup: jest.fn().mockResolvedValue({ + fatalErrors: fatalErrorMock, + }), + start: jest.fn().mockResolvedValue({ + application: applicationServiceMock.createInternalStartContract(), + }), +}; +jest.doMock('./core_system', () => ({ + CoreSystem: jest.fn().mockImplementation(() => coreSystemMock), +})); + +export const consoleWarnMock = jest.spyOn(console, 'warn').mockImplementation(() => {}); diff --git a/src/core/public/osd_bootstrap.test.ts b/src/core/public/osd_bootstrap.test.ts index 806841287bee..7630b4441ab6 100644 --- a/src/core/public/osd_bootstrap.test.ts +++ b/src/core/public/osd_bootstrap.test.ts @@ -28,22 +28,46 @@ * under the License. */ -import { apmSystem, fatalErrorMock, i18nLoad } from './osd_bootstrap.test.mocks'; +import { + apmSystem, + fatalErrorMock, + i18nLoad, + i18nSetLocale, + consoleWarnMock, +} from './osd_bootstrap.test.mocks'; import { __osdBootstrap__ } from './'; +import { getLocaleInUrl } from './locale_helper'; + +jest.mock('./locale_helper', () => ({ + getLocaleInUrl: jest.fn(), +})); describe('osd_bootstrap', () => { + let originalWindowLocation: Location; + beforeAll(() => { const metadata = { - i18n: { translationsUrl: 'http://localhost' }, + branding: { darkMode: 'true' }, + i18n: { translationsUrl: 'http://localhost/translations/en.json' }, vars: { apmConfig: null }, }; // eslint-disable-next-line no-unsanitized/property - document.body.innerHTML = ` -`; + document.body.innerHTML = ` `; + + originalWindowLocation = window.location; + delete (window as any).location; + window.location = { ...originalWindowLocation, href: 'http://localhost' }; }); beforeEach(() => { jest.clearAllMocks(); + (getLocaleInUrl as jest.Mock).mockReturnValue(null); + }); + + afterAll(() => { + window.location = originalWindowLocation; }); it('does not report a fatal error if apm load fails', async () => { @@ -63,4 +87,39 @@ describe('osd_bootstrap', () => { expect(fatalErrorMock.add).toHaveBeenCalledTimes(1); }); + + it('sets locale from URL if present', async () => { + (getLocaleInUrl as jest.Mock).mockReturnValue('fr'); + window.location.href = 'http://localhost/?locale=fr'; + + await __osdBootstrap__(); + + expect(i18nSetLocale).toHaveBeenCalledWith('fr'); + expect(i18nLoad).toHaveBeenCalledWith('http://localhost/translations/fr.json'); + }); + + it('sets default locale if not present in URL', async () => { + await __osdBootstrap__(); + + expect(i18nSetLocale).toHaveBeenCalledWith('en'); + expect(i18nLoad).toHaveBeenCalledWith('http://localhost/translations/en.json'); + }); + + it('displays locale warning if set', async () => { + (window as any).__localeWarning = { title: 'Locale Warning', text: 'Invalid locale' }; + + await __osdBootstrap__(); + + expect(consoleWarnMock).toHaveBeenCalledWith('Locale Warning: Invalid locale'); + expect((window as any).__localeWarning).toBeUndefined(); + }); + + it('displays i18n warning if set', async () => { + (window as any).__i18nWarning = { title: 'i18n Warning', text: 'Translation issue' }; + + await __osdBootstrap__(); + + expect(consoleWarnMock).toHaveBeenCalledWith('i18n Warning: Translation issue'); + expect((window as any).__i18nWarning).toBeUndefined(); + }); }); diff --git a/src/core/public/osd_bootstrap.ts b/src/core/public/osd_bootstrap.ts index f5571292b83a..5190fdc37e21 100644 --- a/src/core/public/osd_bootstrap.ts +++ b/src/core/public/osd_bootstrap.ts @@ -31,6 +31,7 @@ import { i18n } from '@osd/i18n'; import { CoreSystem } from './core_system'; import { ApmSystem } from './apm_system'; +import { getLocaleInUrl } from './locale_helper'; /** @internal */ export async function __osdBootstrap__() { @@ -38,6 +39,38 @@ export async function __osdBootstrap__() { document.querySelector('osd-injected-metadata')!.getAttribute('data')! ); + // Extract the locale from the URL if present + const currentLocale = i18n.getLocale(); + const urlLocale = getLocaleInUrl(window.location.href); + + if (urlLocale && urlLocale !== currentLocale) { + // If a locale is specified in the URL, update the i18n settings + // This enables dynamic language switching + // Note: This works in conjunction with server-side changes: + // 1. The server registers all available translation files at startup + // 2. A server route handles requests for specific locale translations + + // Set the locale in the i18n core + // This will affect all subsequent i18n.translate() calls + i18n.setLocale(urlLocale); + + // Modify the translationsUrl to include the new locale + // This ensures that the correct translation file is requested from the server + // The replace function changes the locale in the URL, e.g., + // from '/translations/en.json' to '/translations/zh-CN.json' + injectedMetadata.i18n.translationsUrl = injectedMetadata.i18n.translationsUrl.replace( + /\/([^/]+)\.json$/, + `/${urlLocale}.json` + ); + } else if (!urlLocale) { + i18n.setLocale('en'); + } + + const globals: any = typeof window === 'undefined' ? {} : window; + const themeTag: string = globals.__osdThemeTag__ || ''; + + injectedMetadata.branding.darkMode = themeTag.endsWith('dark'); + let i18nError: Error | undefined; const apmSystem = new ApmSystem(injectedMetadata.vars.apmConfig, injectedMetadata.basePath); @@ -62,4 +95,20 @@ export async function __osdBootstrap__() { const start = await coreSystem.start(); await apmSystem.start(start); + + // Display the i18n warning if it exists + if ((window as any).__i18nWarning) { + const warning = (window as any).__i18nWarning; + // eslint-disable-next-line no-console + console.warn(`${warning.title}: ${warning.text}`); + delete (window as any).__i18nWarning; + } + + // Display the locale warning if it exists + if ((window as any).__localeWarning) { + const warning = (window as any).__localeWarning; + // eslint-disable-next-line no-console + console.warn(`${warning.title}: ${warning.text}`); + delete (window as any).__localeWarning; + } } diff --git a/src/dev/jest/config.js b/src/dev/jest/config.js index c9239710b398..fb630556c91b 100644 --- a/src/dev/jest/config.js +++ b/src/dev/jest/config.js @@ -122,7 +122,6 @@ const ciGroups = process.argv.reduce((acc, arg) => { return acc; }, []); -console.log('ciGroups', ciGroups); if (ciGroups.length > 0) { console.log(`Requested group${ciGroups.length === 1 ? '' : 's'}: ${ciGroups.join(', ')}`); ciGroups.forEach((id) => { diff --git a/src/legacy/server/i18n/index.ts b/src/legacy/server/i18n/index.ts index e30f9bf7d72b..8a571144cdf5 100644 --- a/src/legacy/server/i18n/index.ts +++ b/src/legacy/server/i18n/index.ts @@ -62,10 +62,11 @@ export async function i18nMixin( }), ]); - const currentTranslationPaths = ([] as string[]) - .concat(...translationPaths) - .filter((translationPath) => basename(translationPath, '.json') === locale); - i18nLoader.registerTranslationFiles(currentTranslationPaths); + // Flatten the array of arrays + const allTranslationPaths = ([] as string[]).concat(...translationPaths); + + // Register all translation files, not just the ones for the current locale + i18nLoader.registerTranslationFiles(allTranslationPaths); const translations = await i18nLoader.getTranslationsByLocale(locale); i18n.init( @@ -75,7 +76,7 @@ export async function i18nMixin( }) ); - const getTranslationsFilePaths = () => currentTranslationPaths; + const getTranslationsFilePaths = () => allTranslationPaths; server.decorate('server', 'getTranslationsFilePaths', getTranslationsFilePaths); diff --git a/src/legacy/ui/ui_render/ui_render_mixin.js b/src/legacy/ui/ui_render/ui_render_mixin.js index 24837ba4a085..f950f708e2c3 100644 --- a/src/legacy/ui/ui_render/ui_render_mixin.js +++ b/src/legacy/ui/ui_render/ui_render_mixin.js @@ -30,7 +30,7 @@ import { createHash } from 'crypto'; import Boom from '@hapi/boom'; -import { i18n } from '@osd/i18n'; +import { i18n, i18nLoader } from '@osd/i18n'; import * as UiSharedDeps from '@osd/ui-shared-deps'; import { OpenSearchDashboardsRequest } from '../../../core/server'; import { AppBootstrap } from './bootstrap'; @@ -49,32 +49,67 @@ import { getApmConfig } from '../apm'; */ export function uiRenderMixin(osdServer, server, config) { const translationsCache = { translations: null, hash: null }; + const defaultLocale = i18n.getLocale() || 'en'; // Fallback to 'en' if no default locale is set + + // Route handler for serving translation files. + // This handler supports two scenarios: + // 1. Serving translations for the default locale + // 2. Serving translations for other registered locales server.route({ path: '/translations/{locale}.json', method: 'GET', config: { auth: false }, - handler(request, h) { - // OpenSearch Dashboards server loads translations only for a single locale - // that is specified in `i18n.locale` config value. + handler: async (request, h) => { const { locale } = request.params; - if (i18n.getLocale() !== locale.toLowerCase()) { - throw Boom.notFound(`Unknown locale: ${locale}`); + const normalizedLocale = locale.toLowerCase(); + const registeredLocales = i18nLoader.getRegisteredLocales().map((l) => l.toLowerCase()); + let warning = null; + + // Function to get or create cached translations + const getCachedTranslations = async (localeKey, getTranslationsFn) => { + if (!translationsCache[localeKey]) { + const translations = await getTranslationsFn(); + translationsCache[localeKey] = { + translations: translations, + hash: createHash('sha1').update(JSON.stringify(translations)).digest('hex'), + }; + } + return translationsCache[localeKey]; + }; + + let cachedTranslations; + + if (normalizedLocale === defaultLocale.toLowerCase()) { + // Default locale + cachedTranslations = await getCachedTranslations(defaultLocale, () => + i18n.getTranslation() + ); + } else if (registeredLocales.includes(normalizedLocale)) { + // Other registered locales + cachedTranslations = await getCachedTranslations(normalizedLocale, () => + i18nLoader.getTranslationsByLocale(locale) + ); + } else { + // Locale not found, fall back to en locale + cachedTranslations = await getCachedTranslations('en', () => + i18nLoader.getTranslationsByLocale('en') + ); + warning = { + title: 'Unsupported Locale', + text: `The requested locale "${locale}" is not supported. Falling back to English.`, + }; } - // Stringifying thousands of labels and calculating hash on the resulting - // string can be expensive so it makes sense to do it once and cache. - if (translationsCache.translations == null) { - translationsCache.translations = JSON.stringify(i18n.getTranslation()); - translationsCache.hash = createHash('sha1') - .update(translationsCache.translations) - .digest('hex'); - } + const response = { + translations: cachedTranslations.translations, + warning, + }; return h - .response(translationsCache.translations) + .response(response) .header('cache-control', 'must-revalidate') .header('content-type', 'application/json') - .etag(translationsCache.hash); + .etag(cachedTranslations.hash); }, }); diff --git a/src/legacy/ui/ui_render/ui_render_mixin.test.js b/src/legacy/ui/ui_render/ui_render_mixin.test.js new file mode 100644 index 000000000000..81f8a9f9a696 --- /dev/null +++ b/src/legacy/ui/ui_render/ui_render_mixin.test.js @@ -0,0 +1,175 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { uiRenderMixin } from './ui_render_mixin'; + +// Mock dependencies +jest.mock('@osd/i18n', () => ({ + i18n: { + getLocale: jest.fn(), + getTranslation: jest.fn(), + translate: jest.fn((key, { defaultMessage }) => defaultMessage), + }, + i18nLoader: { + getRegisteredLocales: jest.fn(), + getTranslationsByLocale: jest.fn(), + }, +})); + +// Import mocked modules +const { i18n, i18nLoader } = require('@osd/i18n'); + +describe('uiRenderMixin', () => { + let server; + let osdServer; + let config; + let routes; + let decorations; + + beforeEach(() => { + routes = []; + decorations = {}; + server = { + route: jest.fn((route) => routes.push(route)), + decorate: jest.fn((type, name, value) => { + decorations[`${type}.${name}`] = value; + }), + auth: { settings: { default: false } }, + }; + osdServer = { + newPlatform: { + setup: { + core: { + http: { csp: { header: 'test-csp-header' } }, + }, + }, + start: { + core: { + savedObjects: { + getScopedClient: jest.fn(), + }, + uiSettings: { + asScopedToClient: jest.fn(), + }, + }, + }, + __internals: { + rendering: { + render: jest.fn(), + }, + }, + }, + }; + config = { + get: jest.fn(), + }; + + // Reset mocks + jest.clearAllMocks(); + }); + + describe('translations route', () => { + let handler; + let h; + + beforeEach(() => { + uiRenderMixin(osdServer, server, config); + handler = routes.find((route) => route.path === '/translations/{locale}.json').handler; + h = { + response: jest.fn().mockReturnThis(), + header: jest.fn().mockReturnThis(), + etag: jest.fn().mockReturnThis(), + }; + }); + + it('should handle default locale', async () => { + const defaultLocale = 'en'; + const defaultTranslations = { hello: 'Hello' }; + i18n.getLocale.mockReturnValue(defaultLocale); + i18n.getTranslation.mockReturnValue(defaultTranslations); + i18nLoader.getRegisteredLocales.mockReturnValue([defaultLocale]); + + const request = { params: { locale: defaultLocale } }; + await handler(request, h); + + expect(i18n.getTranslation).toHaveBeenCalled(); + expect(h.response).toHaveBeenCalledWith({ + translations: defaultTranslations, + warning: null, + }); + expect(h.header).toHaveBeenCalledWith('cache-control', 'must-revalidate'); + expect(h.header).toHaveBeenCalledWith('content-type', 'application/json'); + expect(h.etag).toHaveBeenCalled(); + }); + + it('should handle non-default registered locale', async () => { + const defaultLocale = 'en'; + const requestedLocale = 'fr'; + const frTranslations = { hello: 'Bonjour' }; + i18n.getLocale.mockReturnValue(defaultLocale); + i18nLoader.getRegisteredLocales.mockReturnValue([defaultLocale, requestedLocale]); + i18nLoader.getTranslationsByLocale.mockResolvedValue(frTranslations); + + const request = { params: { locale: requestedLocale } }; + await handler(request, h); + + expect(i18nLoader.getTranslationsByLocale).toHaveBeenCalledWith(requestedLocale); + expect(h.response).toHaveBeenCalledWith({ + translations: frTranslations, + warning: null, + }); + }); + + it('should fallback to English translations for unknown locale', async () => { + const defaultLocale = 'en'; + const unknownLocale = 'xx'; + const englishTranslations = { hello: 'Hello' }; + i18n.getLocale.mockReturnValue(defaultLocale); + i18nLoader.getRegisteredLocales.mockReturnValue([defaultLocale]); + i18nLoader.getTranslationsByLocale.mockResolvedValue(englishTranslations); + + const request = { params: { locale: unknownLocale } }; + await handler(request, h); + + expect(i18nLoader.getTranslationsByLocale).toHaveBeenCalledWith('en'); + expect(h.response).toHaveBeenCalledWith({ + translations: englishTranslations, + warning: { + title: 'Unsupported Locale', + text: `The requested locale "${unknownLocale}" is not supported. Falling back to English.`, + }, + }); + expect(h.header).toHaveBeenCalledWith('cache-control', 'must-revalidate'); + expect(h.header).toHaveBeenCalledWith('content-type', 'application/json'); + expect(h.etag).toHaveBeenCalled(); + }); + + it('should cache translations', async () => { + const defaultLocale = 'en'; + const defaultTranslations = { hello: 'Hello' }; + i18n.getLocale.mockReturnValue(defaultLocale); + i18n.getTranslation.mockReturnValue(defaultTranslations); + i18nLoader.getRegisteredLocales.mockReturnValue([defaultLocale]); + + const request = { params: { locale: defaultLocale } }; + await handler(request, h); + await handler(request, h); + + expect(i18n.getTranslation).toHaveBeenCalledTimes(1); + }); + + it('should handle errors gracefully', async () => { + const defaultLocale = 'en'; + i18n.getLocale.mockReturnValue(defaultLocale); + i18n.getTranslation.mockImplementation(() => { + throw new Error('Translation error'); + }); + i18nLoader.getRegisteredLocales.mockReturnValue([defaultLocale]); + + const request = { params: { locale: defaultLocale } }; + await expect(handler(request, h)).rejects.toThrow('Translation error'); + }); + }); +});