Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Change the locale dynamically by adding &i18n-locale to URL #7686

Merged
merged 5 commits into from
Aug 15, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/_sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
9 changes: 8 additions & 1 deletion packages/osd-i18n/src/core/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it common to store this in window?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it is not uncommon, especially in situations where we need to pass information from sever side to browser that doesn't have a direct communication channel.

}

init(data.translations);
}
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 { getLocaleInUrl } 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 = 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.
Expand Down
31 changes: 31 additions & 0 deletions src/core/public/core_system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,4 +318,35 @@
this.workspaces.stop();
this.rootDomElement.textContent = '';
}

public async displayWarning(title: string, text: string) {
const i18n = await this.i18n.start();
const injectedMetadata = this.injectedMetadata.setup();
this.fatalErrorsSetup = this.fatalErrors.setup({
injectedMetadata,
i18n: this.i18n.getContext(),
});
await this.integrations.setup();

Check failure on line 329 in src/core/public/core_system.ts

View workflow job for this annotation

GitHub Actions / Build and Verify on Linux (ciGroup1)

Delete `··`
this.docLinks.setup();

Check failure on line 330 in src/core/public/core_system.ts

View workflow job for this annotation

GitHub Actions / Build and Verify on Linux (ciGroup1)

Delete `··`
const http = this.http.setup({ injectedMetadata, fatalErrors: this.fatalErrorsSetup });

Check failure on line 331 in src/core/public/core_system.ts

View workflow job for this annotation

GitHub Actions / Build and Verify on Linux (ciGroup1)

Delete `··`
const uiSettings = this.uiSettings.setup({ http, injectedMetadata });
const notificationsTargetDomElement = document.createElement('div');
const overlayTargetDomElement = document.createElement('div');
const overlays = this.overlay.start({
i18n,
targetDomElement: overlayTargetDomElement,
uiSettings,
});
if (this.notifications) {
const toasts = await this.notifications.start({
i18n,
overlays,
targetDomElement: notificationsTargetDomElement,
})?.toasts;

if (toasts) {
toasts.addWarning({ title, text });
}
}
}
}
50 changes: 50 additions & 0 deletions src/core/public/locale_helper.test.ts
Original file line number Diff line number Diff line change
@@ -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 null for a URL without locale', () => {
const url = 'http://localhost:5603/app/home';
expect(getLocaleInUrl(url)).toBeNull();
});

it('should return null and set a warning for an invalid locale format in hash', () => {
const url = 'http://localhost:5603/app/home#/&locale=de-DE';
expect(getLocaleInUrl(url)).toBeNull();
expect((window as any).__localeWarning).toBeDefined();
expect((window as any).__localeWarning.title).toBe('Invalid URL Format');
});

it('should return null for an empty locale value', () => {
const url = 'http://localhost:5603/app/home?locale=';
expect(getLocaleInUrl(url)).toBeNull();
});

it('should handle URLs with other query parameters', () => {
const url = 'http://localhost:5603/app/home?param1=value1&locale=ja-JP&param2=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&param2=value2';
expect(getLocaleInUrl(url)).toBe('zh-CN');
});
});
68 changes: 68 additions & 0 deletions src/core/public/locale_helper.ts
Original file line number Diff line number Diff line change
@@ -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 null;
}

// Return the locale value if found, or null if not found
return localeValue && localeValue.trim() !== '' ? localeValue : null;
}

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".',
};
}
28 changes: 16 additions & 12 deletions src/core/public/osd_bootstrap.test.mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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 displayWarningMock = jest.fn();
export const coreSystemMock = {
setup: jest.fn().mockResolvedValue({
fatalErrors: fatalErrorMock,
}),
start: jest.fn().mockResolvedValue({
application: applicationServiceMock.createInternalStartContract(),
}),
displayWarning: displayWarningMock,
};
jest.doMock('./core_system', () => ({
CoreSystem: jest.fn().mockImplementation(() => coreSystemMock),
}));
66 changes: 62 additions & 4 deletions src/core/public/osd_bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,23 +28,46 @@
* under the License.
*/

import { apmSystem, fatalErrorMock, i18nLoad } from './osd_bootstrap.test.mocks';
import {
apmSystem,
fatalErrorMock,
i18nLoad,
i18nSetLocale,
displayWarningMock,
} 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 = `<osd-injected-metadata data=${JSON.stringify(metadata)}>
</osd-injected-metadata>`;
document.body.innerHTML = `<osd-injected-metadata data=${JSON.stringify(
metadata
)}> </osd-injected-metadata>`;

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 () => {
Expand All @@ -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(displayWarningMock).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(displayWarningMock).toHaveBeenCalledWith('i18n Warning', 'Translation issue');
expect((window as any).__i18nWarning).toBeUndefined();
});
});
Loading
Loading