diff --git a/changelogs/fragments/7686.yml b/changelogs/fragments/7686.yml new file mode 100644 index 00000000000..f446ed68764 --- /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/docs/_sidebar.md b/docs/_sidebar.md index e5136a6df4c..792c35e3949 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -30,6 +30,7 @@ - public - application - [Hooks](../src/plugins/console/public/application/hooks/README.md) + - [Content_management](../src/plugins/content_management/README.md) - [Csp_handler](../src/plugins/csp_handler/README.md) - [Dashboard](../src/plugins/dashboard/README.md) - [Data](../src/plugins/data/README.md) @@ -164,6 +165,7 @@ - [Opensearch dashboards.release notes 2.13.0](../release-notes/opensearch-dashboards.release-notes-2.13.0.md) - [Opensearch dashboards.release notes 2.14.0](../release-notes/opensearch-dashboards.release-notes-2.14.0.md) - [Opensearch dashboards.release notes 2.15.0](../release-notes/opensearch-dashboards.release-notes-2.15.0.md) + - [Opensearch dashboards.release notes 2.16.0](../release-notes/opensearch-dashboards.release-notes-2.16.0.md) - [Opensearch dashboards.release notes 2.2.0](../release-notes/opensearch-dashboards.release-notes-2.2.0.md) - [Opensearch dashboards.release notes 2.2.1](../release-notes/opensearch-dashboards.release-notes-2.2.1.md) - [Opensearch dashboards.release notes 2.3.0](../release-notes/opensearch-dashboards.release-notes-2.3.0.md) diff --git a/packages/osd-i18n/src/core/i18n.test.ts b/packages/osd-i18n/src/core/i18n.test.ts index 0ee114c78c9..ebfd546f856 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 3268fae5079..65da4931ef1 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 067c33256bd..6575a6aa1ab 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 487093be191..74e4bb068d3 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 00000000000..238dbced389 --- /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 00000000000..38a734a523b --- /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 77b47e8b895..87a6ab49973 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 e4209b460f8..7630b4441ab 100644 --- a/src/core/public/osd_bootstrap.test.ts +++ b/src/core/public/osd_bootstrap.test.ts @@ -28,23 +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 = { branding: { darkMode: 'true' }, - i18n: { translationsUrl: 'http://localhost' }, + 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 () => { @@ -64,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 ed64ed0bc2b..5190fdc37e2 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,33 @@ 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__ || ''; @@ -67,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 c76cb7bfd34..11224945704 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 e30f9bf7d72..8a571144cdf 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 053f5dbdfca..fbbfbff91f4 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 v7light from '@elastic/eui/dist/eui_theme_light.json'; import * as v7dark from '@elastic/eui/dist/eui_theme_dark.json'; import * as v8light from '@elastic/eui/dist/eui_theme_next_light.json'; @@ -53,32 +53,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; - // 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'); + 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.`, + }; } + 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 00000000000..81f8a9f9a69 --- /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'); + }); + }); +});