Skip to content

Commit

Permalink
Change the locale dynamically by adding &i18n-locale to URL
Browse files Browse the repository at this point in the history
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 <[email protected]>
  • Loading branch information
ananzh committed Aug 12, 2024
1 parent 1bf63e3 commit 8a0ff79
Show file tree
Hide file tree
Showing 7 changed files with 483 additions and 21 deletions.
10 changes: 10 additions & 0 deletions src/core/public/application/scoped_history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ import {
Href,
Action,
} from 'history';
import { i18n } from '@osd/i18n';
import { extractLocaleInfo } from '../locale_helper';

/**
* A wrapper around a `History` instance that is scoped to a particular base path of the history stack. Behaves
Expand Down Expand Up @@ -307,13 +309,21 @@ export class ScopedHistory<HistoryLocationState = unknown>
* 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)) {
unlisten();
this.isActive = false;
return;
}
// const fullUrl = `${location.pathname}${location.search}${location.hash}`;
const { localeValue } = extractLocaleInfo(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.
Expand Down
128 changes: 128 additions & 0 deletions src/core/public/locale_helper.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import { extractLocaleInfo, getAndUpdateLocaleInUrl } from './locale_helper';

describe('extractLocaleInfo', () => {
const testCases = [
{
description: 'After hash and slash',
input: 'http://localhost:5603/app/home#/&i18n-locale=fr-FR',
expected: {
localeValue: 'fr-FR',
localeParam: 'i18n-locale=fr-FR',
updatedUrl: 'http://localhost:5603/app/home#/',
},
},
{
description: 'After path and slash',
input: 'http://localhost:5603/app/home/&i18n-locale=de-DE',
expected: {
localeValue: 'de-DE',
localeParam: 'i18n-locale=de-DE',
updatedUrl: 'http://localhost:5603/app/home/',
},
},
{
description: 'No locale parameter',
input: 'http://localhost:5603/app/home',
expected: {
localeValue: 'en',
localeParam: null,
updatedUrl: 'http://localhost:5603/app/home',
},
},
{
description: 'Complex URL with locale',
input: 'http://localhost:5603/app/dashboards#/view/id?_g=(...)&_a=(...)&i18n-locale=es-ES',
expected: {
localeValue: 'es-ES',
localeParam: 'i18n-locale=es-ES',
updatedUrl: 'http://localhost:5603/app/dashboards#/view/id?_g=(...)&_a=(...)',
},
},
];

testCases.forEach(({ description, input, expected }) => {
it(description, () => {
const result = extractLocaleInfo(input);
expect(result).toEqual(expected);
});
});
});

describe('getAndUpdateLocaleInUrl', () => {
let originalHistoryReplaceState: typeof window.history.replaceState;

beforeEach(() => {
// Mock window.history.replaceState
originalHistoryReplaceState = window.history.replaceState;
window.history.replaceState = jest.fn();
});

afterEach(() => {
// Restore original window.history.replaceState
window.history.replaceState = originalHistoryReplaceState;
});

const testCases = [
{
description: 'Category 1: basePath + #/',
input: 'http://localhost:5603/app/home#/&i18n-locale=zh-CN',
expected: 'http://localhost:5603/app/home#/?i18n-locale=zh-CN',
locale: 'zh-CN',
},
{
description: 'Category 1: basePath + # (empty hashPath)',
input: 'http://localhost:5603/app/home#&i18n-locale=zh-CN',
expected: 'http://localhost:5603/app/home#?i18n-locale=zh-CN',
locale: 'zh-CN',
},
{
description: 'Category 2: basePath + # + hashPath + ? + hashQuery',
input: 'http://localhost:5603/app/dashboards#/view/id?_g=(...)&_a=(...)&i18n-locale=zh-CN',
expected: 'http://localhost:5603/app/dashboards#/view/id?_g=(...)&_a=(...)&i18n-locale=zh-CN',
locale: 'zh-CN',
},
{
description: 'Category 3: basePath only',
input: 'http://localhost:5603/app/management&i18n-locale=zh-CN',
expected: 'http://localhost:5603/app/management?i18n-locale=zh-CN',
locale: 'zh-CN',
},
{
description: 'Category 1: basePath + # + hashPath',
input: 'http://localhost:5603/app/dev_tools#/console&i18n-locale=zh-CN',
expected: 'http://localhost:5603/app/dev_tools#/console?i18n-locale=zh-CN',
locale: 'zh-CN',
},
{
description: 'URL without locale parameter',
input: 'http://localhost:5603/app/home#/',
expected: 'http://localhost:5603/app/home#/',
locale: 'en',
},
{
description: 'Complex URL with multiple parameters',
input:
"http://localhost:5603/app/dashboards#/view/7adfa750-4c81-11e8-b3d7-01146121b73d?_g=(filters:!(),refreshInterval:(pause:!f,value:900000),time:(from:now-24h,to:now))&_a=(description:'Analyze%20mock%20flight%20data',filters:!())&i18n-locale=zh-CN",
expected:
"http://localhost:5603/app/dashboards#/view/7adfa750-4c81-11e8-b3d7-01146121b73d?_g=(filters:!(),refreshInterval:(pause:!f,value:900000),time:(from:now-24h,to:now))&_a=(description:'Analyze%20mock%20flight%20data',filters:!())&i18n-locale=zh-CN",
locale: 'zh-CN',
},
];

testCases.forEach(({ description, input, expected, locale }) => {
it(description, () => {
const result = getAndUpdateLocaleInUrl(input);
expect(result).toBe(locale);
if (locale !== 'en') {
expect(window.history.replaceState).toHaveBeenCalledWith(null, '', expected);
} else {
expect(window.history.replaceState).not.toHaveBeenCalled();
}
});
});
});
114 changes: 114 additions & 0 deletions src/core/public/locale_helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

/**
* Extracts the locale value and parameter from a given URL string.
*
* @param url - The full URL string to parse
* @returns An object with localeValue, localeParam or null if not found, and updatedUrl or url if no updates.
*/
export function extractLocaleInfo(
url: string
): { localeValue: string | null; localeParam: string | null; updatedUrl: string } {
const patterns = [
/[#&?](i18n-locale)=([^&/]+)/i, // Standard query parameter
/#\/&(i18n-locale)=([^&/]+)/i, // After hash and slash
/\/&(i18n-locale)=([^&/]+)/i, // After path and slash
];

for (const pattern of patterns) {
const match = url.match(pattern);
if (match) {
const localeValue = match[2];
const localeParam = `${match[1]}=${match[2]}`;
const updatedUrl = url.replace(match[0], '');
return { localeValue, localeParam, updatedUrl };
}
}

return { localeValue: 'en', localeParam: null, updatedUrl: url };
}

/**
* Extracts a dynamically added locale parameter from a URL and restructures the URL
* to include this locale parameter in a consistent, functional manner.
*
* This function is specifically designed to handle cases where '&i18n-locale=<locale>'
* has been appended to the URL, potentially in a position that could cause issues
* with OpenSearch Dashboards' URL parsing or functionality.
*
* The restructuring is necessary because simply appending the locale parameter
* to certain URL structures can lead to parsing errors or the parameter being ignored.
*
* The function handles various URL structures to ensure the locale parameter
* is placed in a position where it will be correctly parsed and utilized by
* OpenSearch Dashboards, while maintaining the integrity of existing URL components.
*
* URL Components:
* - basePath: The part of the URL before the hash (#). It typically includes the domain and application path.
* - hashPath: The part of the URL after the hash (#) but before any query parameters (?).
* - hashQuery: The query parameters after the hashPath. In OpenSearch Dashboards, this often contains
* RISON-encoded data and never ends with a slash (/) to avoid RISON parsing errors.
*
* This function handles three main categories of URLs:
* 1. basePath + # + hashPath (including when hashPath is '/' or empty)
* Before: basePath#hashPath&i18n-locale=zh-CN
* After: basePath#hashPath?i18n-locale=zh-CN
* Restructuring rationale: The '&' is changed to '?' because there were no existing
* query parameters after the hashPath. This ensures the locale is treated as a proper
* query parameter and not mistakenly considered part of the hashPath.
*
* 2. basePath + # + hashPath + ? + hashQuery
* Before: basePath#hashPath?hashQuery&i18n-locale=zh-CN
* After: basePath#hashPath?hashQuery&i18n-locale=zh-CN
* Restructuring rationale: The locale parameter is appended to existing query parameters.
* No change in structure is needed as it's already in the correct position.
*
* 3. basePath only
* Before: basePath&i18n-locale=zh-CN
* After: basePath?i18n-locale=zh-CN
* Restructuring rationale: The '&' is changed to '?' because there were no existing
* query parameters in the basePath. This ensures the locale is recognized as the
* start of the query string rather than being misinterpreted as part of the path.
*
* The function performs the following steps:
* 1. Extracts the locale parameter from its current position in the URL.
* 2. Removes the locale parameter from its original position.
* 3. Reconstructs the URL, placing the locale parameter in the correct position
* based on the URL structure to ensure proper parsing by OpenSearch Dashboards.
* 4. Updates the browser's URL without causing a page reload.
*
* @param {string} url - The full URL to process
* @returns {string|null} The extracted locale value, or null if no locale was found
*/

export function getAndUpdateLocaleInUrl(url: string): string | null {
let fullUrl = '';
const { localeValue, localeParam, updatedUrl } = extractLocaleInfo(url);

if (localeValue && localeParam) {
const [basePath, hashPart] = updatedUrl.split('#');

if (hashPart !== undefined) {
const [hashPath, hashQuery] = hashPart.split('?');
if (hashQuery) {
// Category 2: basePath + # + hashPath + ? + hashQuery
fullUrl = `${basePath}#${hashPath}?${hashQuery}&${localeParam}`;
} else {
// Category 1: basePath + # + hashPath (including when hashPath is '/' or empty)
fullUrl = `${basePath}#${hashPath}?${localeParam}`;
}
} else {
// Category 3: basePath only
fullUrl = `${basePath}?${localeParam}`;
}

// Update the URL without causing a page reload
window.history.replaceState(null, '', fullUrl);
return localeValue;
}

return 'en';
}
28 changes: 28 additions & 0 deletions src/core/public/osd_bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,41 @@
import { i18n } from '@osd/i18n';
import { CoreSystem } from './core_system';
import { ApmSystem } from './apm_system';
import { getAndUpdateLocaleInUrl } from './locale_helper';

/** @internal */
export async function __osdBootstrap__() {
const injectedMetadata = JSON.parse(
document.querySelector('osd-injected-metadata')!.getAttribute('data')!
);

// Extract the locale from the URL if present
// This allows for dynamic locale setting via URL parameters
const urlLocale = getAndUpdateLocaleInUrl(window.location.href);

if (urlLocale) {
// 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 {
i18n.setLocale('en');
}

const globals: any = typeof window === 'undefined' ? {} : window;
const themeTag: string = globals.__osdThemeTag__ || '';

Expand Down
11 changes: 6 additions & 5 deletions src/legacy/server/i18n/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -75,7 +76,7 @@ export async function i18nMixin(
})
);

const getTranslationsFilePaths = () => currentTranslationPaths;
const getTranslationsFilePaths = () => allTranslationPaths;

server.decorate('server', 'getTranslationsFilePaths', getTranslationsFilePaths);

Expand Down
Loading

0 comments on commit 8a0ff79

Please sign in to comment.