Merge pull request #19962 from backstage/rugvip/i18n-app-refactor
core-app-api: refactor translation API into standalone implementation + split out language API
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/test-utils': patch
|
||||
---
|
||||
|
||||
Add a new `MockTranslationApi` as an `/alpha` export.
|
||||
@@ -12,50 +12,57 @@ The Backstage core function provides internationalization for plugins
|
||||
|
||||
When you are creating your plugin, you have the possibility to use `createTranslationRef` to define all messages for your plugin. For example
|
||||
|
||||
```typescript jsx
|
||||
```ts
|
||||
import { createTranslationRef } from '@backstage/core-plugin-api/alpha';
|
||||
|
||||
/** @alpha */
|
||||
export const userSettingsTranslationRef = createTranslationRef({
|
||||
id: 'user-settings',
|
||||
export const myPluginTranslationRef = createTranslationRef({
|
||||
id: 'plugin.my-plugin',
|
||||
messages: {
|
||||
language: 'Language',
|
||||
change_the_language: 'Change the language',
|
||||
index_page_title: 'All your components',
|
||||
create_component_button_label: 'Create new component',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
And the using this messages in your components like:
|
||||
|
||||
```typescript jsx
|
||||
const t = useTranslationRef(userSettingsTranslationRef);
|
||||
```tsx
|
||||
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
|
||||
|
||||
const { t } = useTranslationRef(userSettingsTranslationRef);
|
||||
|
||||
return (
|
||||
<ListItemText
|
||||
className={classes.listItemText}
|
||||
primary={t('language')}
|
||||
secondary={t('change_the_language')}
|
||||
/>
|
||||
<PageHeader title={t('index_page_title')}>
|
||||
<Button onClick={handleCreateComponent}>
|
||||
{t('create_component_button_label')}
|
||||
</Button>
|
||||
</PageHeader>
|
||||
);
|
||||
```
|
||||
|
||||
## For an application developer overwrite plugin messages
|
||||
|
||||
```diff typescript jsx
|
||||
const app = createApp({
|
||||
+ __experimentalI18n: {
|
||||
+ supportedLanguages: ['zh', 'en'],
|
||||
+ messages: [
|
||||
+ createTranslationResource({
|
||||
+ ref: userSettingsTranslationRef,
|
||||
In an app you can both override the default messages, as well as register translations for additional languages:
|
||||
|
||||
```diff
|
||||
const app = createApp({
|
||||
+ __experimentalTranslations: {
|
||||
+ availableLanguages: ['en', 'zh'],
|
||||
+ resources: [
|
||||
+ createTranslationMessages({
|
||||
+ ref: myPluginTranslationRef,
|
||||
+ messages: {
|
||||
+ zh: {
|
||||
+ select_lng: '选择中文-app',
|
||||
+ },
|
||||
+ create_component_button_label: 'Create new entity',
|
||||
+ },
|
||||
+ }),
|
||||
+ createTranslationResource({
|
||||
+ ref: myPluginTranslationRef,
|
||||
+ messages: {
|
||||
+ zh: () => import('./translations/zh'),
|
||||
+ },
|
||||
+ }),
|
||||
+ ],
|
||||
+ },
|
||||
...
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
@@ -223,8 +223,8 @@ export type AppOptions = {
|
||||
configLoader?: AppConfigLoader;
|
||||
bindRoutes?(context: { bind: AppRouteBinder }): void;
|
||||
__experimentalTranslations?: {
|
||||
fallbackLanguage?: string | string[];
|
||||
supportedLanguages?: string[];
|
||||
defaultLanguage?: string;
|
||||
availableLanguages?: string[];
|
||||
resources?: Array<TranslationMessages | TranslationResource>;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -50,9 +50,7 @@
|
||||
"@types/react": "^16.13.1 || ^17.0.0",
|
||||
"history": "^5.0.0",
|
||||
"i18next": "^22.4.15",
|
||||
"i18next-browser-languagedetector": "^7.0.2",
|
||||
"prop-types": "^15.7.2",
|
||||
"react-i18next": "^12.3.1",
|
||||
"react-use": "^17.2.4",
|
||||
"zen-observable": "^0.10.0",
|
||||
"zod": "^3.21.4"
|
||||
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { AppLanguageSelector } from './AppLanguageSelector';
|
||||
|
||||
const baseOptions = {
|
||||
availableLanguages: ['en', 'de'],
|
||||
};
|
||||
|
||||
describe('AppLanguageSelector', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.removeItem('language');
|
||||
});
|
||||
|
||||
it('should select language', async () => {
|
||||
const selector = AppLanguageSelector.create(baseOptions);
|
||||
|
||||
expect(selector.getAvailableLanguages()).toEqual({
|
||||
languages: ['en', 'de'],
|
||||
});
|
||||
|
||||
const subFn = jest.fn();
|
||||
selector.language$().subscribe(subFn);
|
||||
expect(selector.getLanguage()).toEqual({ language: 'en' });
|
||||
await 'wait a tick';
|
||||
expect(subFn).toHaveBeenLastCalledWith({ language: 'en' });
|
||||
|
||||
selector.setLanguage('de');
|
||||
expect(subFn).toHaveBeenLastCalledWith({ language: 'de' });
|
||||
expect(selector.getLanguage()).toEqual({ language: 'de' });
|
||||
|
||||
selector.setLanguage('en');
|
||||
expect(subFn).toHaveBeenLastCalledWith({ language: 'en' });
|
||||
expect(selector.getLanguage()).toEqual({ language: 'en' });
|
||||
});
|
||||
|
||||
it('should return a new array of languages', () => {
|
||||
const languages = ['en', 'de'];
|
||||
const selector = AppLanguageSelector.create({
|
||||
availableLanguages: languages,
|
||||
});
|
||||
|
||||
expect(selector.getAvailableLanguages().languages).toEqual(languages);
|
||||
expect(selector.getAvailableLanguages().languages).not.toBe(languages);
|
||||
expect(selector.getAvailableLanguages().languages).toEqual(
|
||||
selector.getAvailableLanguages().languages,
|
||||
);
|
||||
expect(selector.getAvailableLanguages().languages).not.toBe(
|
||||
selector.getAvailableLanguages().languages,
|
||||
);
|
||||
});
|
||||
|
||||
it('should skip duplicates', async () => {
|
||||
const languages = ['en', 'de'];
|
||||
const selector = AppLanguageSelector.create({
|
||||
availableLanguages: languages,
|
||||
});
|
||||
|
||||
const emitted = new Array<string>();
|
||||
selector.language$().subscribe(({ language }) => {
|
||||
emitted.push(language);
|
||||
});
|
||||
selector.setLanguage('en');
|
||||
selector.setLanguage('en');
|
||||
selector.setLanguage('de');
|
||||
selector.setLanguage('de');
|
||||
selector.setLanguage('de');
|
||||
selector.setLanguage('en');
|
||||
selector.setLanguage('en');
|
||||
selector.setLanguage('en');
|
||||
await 'wait a tick';
|
||||
|
||||
expect(emitted).toEqual(['en', 'de', 'en']);
|
||||
});
|
||||
|
||||
it('should be initialized from storage', () => {
|
||||
expect(
|
||||
AppLanguageSelector.createWithStorage(baseOptions).getLanguage(),
|
||||
).toEqual({ language: 'en' });
|
||||
|
||||
localStorage.setItem('language', 'de');
|
||||
|
||||
expect(
|
||||
AppLanguageSelector.createWithStorage(baseOptions).getLanguage(),
|
||||
).toEqual({ language: 'de' });
|
||||
|
||||
localStorage.removeItem('language');
|
||||
|
||||
expect(
|
||||
AppLanguageSelector.createWithStorage(baseOptions).getLanguage(),
|
||||
).toEqual({ language: 'en' });
|
||||
});
|
||||
|
||||
it('should sync with storage', async () => {
|
||||
const addListenerSpy = jest.spyOn(window, 'addEventListener');
|
||||
const selector = AppLanguageSelector.createWithStorage(baseOptions);
|
||||
|
||||
expect(addListenerSpy).toHaveBeenCalledTimes(1);
|
||||
expect(addListenerSpy).toHaveBeenCalledWith(
|
||||
'storage',
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
selector.setLanguage('de');
|
||||
await 'wait a tick';
|
||||
expect(localStorage.getItem('language')).toBe('de');
|
||||
|
||||
selector.setLanguage('en');
|
||||
await 'wait a tick';
|
||||
expect(localStorage.getItem('language')).toBe('en');
|
||||
|
||||
localStorage.setItem('language', 'de');
|
||||
expect(selector.getLanguage()).toEqual({ language: 'en' });
|
||||
|
||||
const listener = addListenerSpy.mock.calls[0][1] as EventListener;
|
||||
listener({ key: 'language' } as StorageEvent);
|
||||
|
||||
expect(selector.getLanguage()).toEqual({ language: 'de' });
|
||||
});
|
||||
|
||||
it('should reject invalid input', async () => {
|
||||
expect(() =>
|
||||
AppLanguageSelector.createWithStorage({
|
||||
availableLanguages: ['en', 'de', 'en'],
|
||||
}),
|
||||
).toThrow(
|
||||
"Supported languages may not contain duplicates, got 'en', 'de', 'en'",
|
||||
);
|
||||
|
||||
expect(() =>
|
||||
AppLanguageSelector.createWithStorage({
|
||||
availableLanguages: ['de'],
|
||||
}),
|
||||
).toThrow("Supported languages must include 'en'");
|
||||
|
||||
const selector = AppLanguageSelector.createWithStorage(baseOptions);
|
||||
expect(() => selector.setLanguage('sv')).toThrow(
|
||||
"Failed to change language to 'sv', available languages are 'en', 'de'",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// Internal import to avoid code duplication, this will lead to duplication in build output
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { AppLanguageApi } from '@backstage/core-plugin-api/alpha';
|
||||
import { Observable } from '@backstage/types';
|
||||
import { BehaviorSubject } from '../../../lib';
|
||||
|
||||
const STORAGE_KEY = 'language';
|
||||
export const DEFAULT_LANGUAGE = 'en';
|
||||
|
||||
/** @alpha */
|
||||
export interface AppLanguageSelectorOptions {
|
||||
defaultLanguage?: string;
|
||||
availableLanguages?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Exposes the available languages in the app and allows for switching of the active language.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export class AppLanguageSelector implements AppLanguageApi {
|
||||
static create(options?: AppLanguageSelectorOptions) {
|
||||
const languages = options?.availableLanguages ?? [DEFAULT_LANGUAGE];
|
||||
if (languages.length !== new Set(languages).size) {
|
||||
throw new Error(
|
||||
`Supported languages may not contain duplicates, got '${languages.join(
|
||||
"', '",
|
||||
)}'`,
|
||||
);
|
||||
}
|
||||
if (!languages.includes(DEFAULT_LANGUAGE)) {
|
||||
throw new Error(`Supported languages must include '${DEFAULT_LANGUAGE}'`);
|
||||
}
|
||||
|
||||
const initialLanguage = options?.defaultLanguage ?? DEFAULT_LANGUAGE;
|
||||
if (!languages.includes(initialLanguage)) {
|
||||
throw new Error(
|
||||
`Initial language must be one of the supported languages, got '${initialLanguage}'`,
|
||||
);
|
||||
}
|
||||
|
||||
return new AppLanguageSelector(languages, initialLanguage);
|
||||
}
|
||||
|
||||
static createWithStorage(options?: AppLanguageSelectorOptions) {
|
||||
const selector = AppLanguageSelector.create(options);
|
||||
|
||||
if (!window.localStorage) {
|
||||
return selector;
|
||||
}
|
||||
|
||||
const storedLanguage = window.localStorage.getItem(STORAGE_KEY);
|
||||
const { languages } = selector.getAvailableLanguages();
|
||||
if (storedLanguage && languages.includes(storedLanguage)) {
|
||||
selector.setLanguage(storedLanguage);
|
||||
}
|
||||
|
||||
selector.language$().subscribe(({ language }) => {
|
||||
if (language !== window.localStorage.getItem(STORAGE_KEY)) {
|
||||
window.localStorage.setItem(STORAGE_KEY, language);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('storage', event => {
|
||||
if (event.key === STORAGE_KEY) {
|
||||
const language = localStorage.getItem(STORAGE_KEY) ?? undefined;
|
||||
if (language) {
|
||||
selector.setLanguage(language);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return selector;
|
||||
}
|
||||
|
||||
#languages: string[];
|
||||
#language: string;
|
||||
#subject: BehaviorSubject<{ language: string }>;
|
||||
|
||||
private constructor(languages: string[], initialLanguage: string) {
|
||||
this.#languages = languages;
|
||||
this.#language = initialLanguage;
|
||||
this.#subject = new BehaviorSubject<{ language: string }>({
|
||||
language: this.#language,
|
||||
});
|
||||
}
|
||||
|
||||
getAvailableLanguages(): { languages: string[] } {
|
||||
return { languages: this.#languages.slice() };
|
||||
}
|
||||
|
||||
setLanguage(language?: string | undefined): void {
|
||||
const lng = language ?? DEFAULT_LANGUAGE;
|
||||
if (lng === this.#language) {
|
||||
return;
|
||||
}
|
||||
if (lng && !this.#languages.includes(lng)) {
|
||||
throw new Error(
|
||||
`Failed to change language to '${lng}', available languages are '${this.#languages.join(
|
||||
"', '",
|
||||
)}'`,
|
||||
);
|
||||
}
|
||||
this.#language = lng;
|
||||
this.#subject.next({ language: lng });
|
||||
}
|
||||
|
||||
getLanguage(): { language: string } {
|
||||
return { language: this.#language };
|
||||
}
|
||||
|
||||
language$(): Observable<{ language: string }> {
|
||||
return this.#subject;
|
||||
}
|
||||
}
|
||||
+4
-11
@@ -14,14 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { PropsWithChildren } from 'react';
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
import { appTranslationApiRef } from '@backstage/core-plugin-api/alpha';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
|
||||
/** @alpha */
|
||||
export function AppTranslationProvider({ children }: PropsWithChildren<{}>) {
|
||||
const appTranslationAPi = useApi(appTranslationApiRef);
|
||||
const i18n = appTranslationAPi.getI18n();
|
||||
return <I18nextProvider i18n={i18n}>{children}</I18nextProvider>;
|
||||
}
|
||||
export {
|
||||
AppLanguageSelector,
|
||||
type AppLanguageSelectorOptions,
|
||||
} from './AppLanguageSelector';
|
||||
-141
@@ -1,141 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
createTranslationMessages,
|
||||
createTranslationRef,
|
||||
createTranslationResource,
|
||||
} from '@backstage/core-plugin-api/alpha';
|
||||
import { AppTranslationApiImpl } from './AppTranslationImpl';
|
||||
import i18next from 'i18next';
|
||||
|
||||
jest.mock('i18next', () => ({
|
||||
createInstance: jest.fn(() => ({
|
||||
use: jest.fn(() => ({
|
||||
init: jest.fn(),
|
||||
use: jest.fn(),
|
||||
addResourceBundle: jest.fn(),
|
||||
reloadResources: jest.fn(),
|
||||
emit: jest.fn(),
|
||||
services: {
|
||||
languageUtils: {
|
||||
getFallbackCodes: jest.fn().mockReturnValue(['en']),
|
||||
},
|
||||
},
|
||||
options: {
|
||||
fallbackLng: 'en',
|
||||
supportedLngs: ['zh', 'en'],
|
||||
},
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('AppTranslationApiImpl', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should create i18n instance and init with options', () => {
|
||||
const i18nMock = i18next.createInstance() as any;
|
||||
const useReturnMock = i18nMock.use();
|
||||
jest.spyOn(i18nMock, 'use').mockReturnValue(useReturnMock);
|
||||
jest.spyOn(i18next, 'createInstance').mockReturnValue(i18nMock);
|
||||
|
||||
const instance = AppTranslationApiImpl.create({
|
||||
supportedLanguages: ['en', 'zh'],
|
||||
});
|
||||
|
||||
expect(i18next.createInstance).toHaveBeenCalled();
|
||||
expect(i18nMock.use).toHaveBeenCalled();
|
||||
expect(useReturnMock.init).toHaveBeenCalledWith({
|
||||
fallbackLng: 'en',
|
||||
supportedLngs: ['en', 'zh'],
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
react: {
|
||||
bindI18n: 'loaded languageChanged',
|
||||
},
|
||||
});
|
||||
expect(instance).toBeInstanceOf(AppTranslationApiImpl);
|
||||
});
|
||||
|
||||
it('should init messages correctly', () => {
|
||||
const addMessagesMock = jest.spyOn(
|
||||
AppTranslationApiImpl.prototype,
|
||||
'addMessages',
|
||||
);
|
||||
const addLazyResourcesMock = jest.spyOn(
|
||||
AppTranslationApiImpl.prototype,
|
||||
'addLazyResources',
|
||||
);
|
||||
const ref = createTranslationRef({
|
||||
id: 'ref-id',
|
||||
messages: {
|
||||
key: '',
|
||||
},
|
||||
});
|
||||
|
||||
const overrides = createTranslationMessages({
|
||||
ref,
|
||||
messages: { key: 'value1' },
|
||||
});
|
||||
const resource = createTranslationResource({
|
||||
ref,
|
||||
translations: {
|
||||
en: () =>
|
||||
Promise.resolve({
|
||||
default: {
|
||||
key: 'value2',
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
AppTranslationApiImpl.create({
|
||||
supportedLanguages: ['en'],
|
||||
resources: [overrides, resource],
|
||||
});
|
||||
|
||||
expect(addMessagesMock).toHaveBeenCalledWith(overrides);
|
||||
expect(addLazyResourcesMock).toHaveBeenCalledWith(resource);
|
||||
});
|
||||
|
||||
it('should useResources correctly', () => {
|
||||
const addLazyResourcesMock = jest.spyOn(
|
||||
AppTranslationApiImpl.prototype,
|
||||
'addLazyResources',
|
||||
);
|
||||
|
||||
const ref = createTranslationRef({
|
||||
id: 'ref-id',
|
||||
messages: {
|
||||
key1: 'value1',
|
||||
},
|
||||
translations: {
|
||||
en: () => Promise.resolve({ default: { key1: 'value2' } }),
|
||||
},
|
||||
});
|
||||
|
||||
const instance = AppTranslationApiImpl.create({
|
||||
supportedLanguages: ['en'],
|
||||
});
|
||||
instance.addResource(ref);
|
||||
|
||||
expect(addLazyResourcesMock).toHaveBeenCalledTimes(1);
|
||||
expect(addLazyResourcesMock.mock.calls[0][0].id).toBe('ref-id');
|
||||
});
|
||||
});
|
||||
-188
@@ -1,188 +0,0 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
AppTranslationApi,
|
||||
TranslationMessages,
|
||||
TranslationRef,
|
||||
TranslationResource,
|
||||
} from '@backstage/core-plugin-api/alpha';
|
||||
import i18next, { type i18n } from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
|
||||
// Internal import to avoid code duplication, this will lead to duplication in build output
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { toInternalTranslationResource } from '../../../../../core-plugin-api/src/translation/TranslationResource';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { toInternalTranslationRef } from '../../../../../core-plugin-api/src/translation/TranslationRef';
|
||||
|
||||
const DEFAULT_LANGUAGE = 'en';
|
||||
|
||||
/** @alpha */
|
||||
export type ExperimentalI18n = {
|
||||
fallbackLanguage?: string | string[];
|
||||
supportedLanguages?: string[];
|
||||
resources?: Array<TranslationMessages | TranslationResource>;
|
||||
};
|
||||
|
||||
function removeNulls(
|
||||
messages: Record<string, string | null>,
|
||||
): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(messages).filter(
|
||||
(e): e is [string, string] => e[1] !== null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** @alpha */
|
||||
export class AppTranslationApiImpl implements AppTranslationApi {
|
||||
static create(options?: ExperimentalI18n) {
|
||||
const i18n = i18next.createInstance().use(initReactI18next);
|
||||
|
||||
i18n.use(LanguageDetector);
|
||||
|
||||
i18n.init({
|
||||
fallbackLng: options?.fallbackLanguage || DEFAULT_LANGUAGE,
|
||||
supportedLngs: options?.supportedLanguages || [DEFAULT_LANGUAGE],
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
react: {
|
||||
bindI18n: 'loaded languageChanged',
|
||||
},
|
||||
});
|
||||
|
||||
return new AppTranslationApiImpl(i18n, options);
|
||||
}
|
||||
|
||||
private readonly cache = new Set<string>();
|
||||
private readonly lazyCache = new Map<string, Set<string>>();
|
||||
|
||||
getI18n() {
|
||||
return this.i18n;
|
||||
}
|
||||
|
||||
initMessages(options?: ExperimentalI18n) {
|
||||
for (const resource of options?.resources || []) {
|
||||
if (resource.$$type === '@backstage/TranslationResource') {
|
||||
this.addLazyResources(resource);
|
||||
} else if (resource.$$type === '@backstage/TranslationMessages') {
|
||||
// Overrides for default messages, created with createTranslationMessages and installed via app
|
||||
this.addMessages(resource);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addResource(translationRef: TranslationRef): void {
|
||||
const internalRef = toInternalTranslationRef(translationRef);
|
||||
const defaultResource = internalRef.getDefaultResource();
|
||||
if (defaultResource) {
|
||||
this.addLazyResources(defaultResource);
|
||||
}
|
||||
}
|
||||
|
||||
addMessages(messages: TranslationMessages) {
|
||||
if (this.cache.has(messages.id)) {
|
||||
return;
|
||||
}
|
||||
this.cache.add(messages.id);
|
||||
this.i18n.addResourceBundle(
|
||||
DEFAULT_LANGUAGE,
|
||||
messages.id,
|
||||
removeNulls(messages.messages),
|
||||
true,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
addLazyResources(resource: TranslationResource) {
|
||||
let cache = this.lazyCache.get(resource.id);
|
||||
|
||||
if (!cache) {
|
||||
cache = new Set();
|
||||
this.lazyCache.set(resource.id, cache);
|
||||
}
|
||||
|
||||
const {
|
||||
language: currentLanguage,
|
||||
services,
|
||||
options,
|
||||
addResourceBundle,
|
||||
reloadResources,
|
||||
} = this.i18n;
|
||||
|
||||
if (cache.has(currentLanguage)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const internalResource = toInternalTranslationResource(resource);
|
||||
const namespace = internalResource.id;
|
||||
|
||||
Promise.allSettled((options.supportedLngs || []).map(addLanguage)).then(
|
||||
results => {
|
||||
if (results.some(result => result.status === 'fulfilled')) {
|
||||
this.i18n.emit('loaded');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
async function addLanguage(language: string) {
|
||||
if (cache!.has(language)) {
|
||||
return;
|
||||
}
|
||||
|
||||
cache!.add(language);
|
||||
|
||||
let loadBackend: Promise<void> | undefined;
|
||||
|
||||
if (services.backendConnector?.backend) {
|
||||
loadBackend = reloadResources([language], [namespace]);
|
||||
}
|
||||
|
||||
const loadLazyResources = internalResource.resources.find(
|
||||
entry => entry.language === language,
|
||||
)?.loader;
|
||||
|
||||
if (!loadLazyResources) {
|
||||
await loadBackend;
|
||||
return;
|
||||
}
|
||||
|
||||
const [result] = await Promise.allSettled([
|
||||
loadLazyResources(),
|
||||
loadBackend,
|
||||
]);
|
||||
|
||||
if (result.status === 'rejected') {
|
||||
throw result.reason;
|
||||
}
|
||||
|
||||
addResourceBundle(
|
||||
language,
|
||||
namespace,
|
||||
result.value.messages,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private constructor(private readonly i18n: i18n, options?: ExperimentalI18n) {
|
||||
this.initMessages(options);
|
||||
}
|
||||
}
|
||||
+514
@@ -0,0 +1,514 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
createTranslationMessages,
|
||||
createTranslationRef,
|
||||
createTranslationResource,
|
||||
TranslationSnapshot,
|
||||
} from '@backstage/core-plugin-api/alpha';
|
||||
import { Observable } from '@backstage/types';
|
||||
import { AppLanguageSelector } from '../AppLanguageApi';
|
||||
import { I18nextTranslationApi } from './I18nextTranslationApi';
|
||||
|
||||
const plainRef = createTranslationRef({
|
||||
id: 'plain',
|
||||
messages: { foo: 'Foo' },
|
||||
});
|
||||
|
||||
const resourceRef = createTranslationRef({
|
||||
id: 'resource',
|
||||
messages: {
|
||||
foo: 'Foo',
|
||||
bar: 'Bar',
|
||||
},
|
||||
translations: {
|
||||
sv: () => Promise.resolve({ default: { foo: 'Föö', bar: null } }),
|
||||
},
|
||||
});
|
||||
|
||||
function waitForNext<T>(
|
||||
observable: Observable<T>,
|
||||
predicate?: (result: T) => boolean,
|
||||
): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const sub = observable.subscribe({
|
||||
next(next) {
|
||||
if (!predicate || predicate(next)) {
|
||||
resolve(next);
|
||||
sub.unsubscribe();
|
||||
}
|
||||
},
|
||||
error(err) {
|
||||
reject(err);
|
||||
sub.unsubscribe();
|
||||
},
|
||||
complete() {
|
||||
reject(new Error('Observable completed without emitting'));
|
||||
sub.unsubscribe();
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function assertReady<TMessages extends { [key in string]: string }>(
|
||||
snapshot: TranslationSnapshot<TMessages>,
|
||||
) {
|
||||
if (!snapshot.ready) {
|
||||
throw new Error('snapshot not ready');
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
describe('I18nextTranslationApi', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should get a translation snapshot', () => {
|
||||
const translationApi = I18nextTranslationApi.create({
|
||||
languageApi: AppLanguageSelector.create(),
|
||||
});
|
||||
|
||||
const snapshot = assertReady(translationApi.getTranslation(plainRef));
|
||||
expect(snapshot.t('foo')).toBe('Foo');
|
||||
});
|
||||
|
||||
it('should get a translation snapshot for ref with translations', async () => {
|
||||
const languageApi = AppLanguageSelector.create({
|
||||
availableLanguages: ['en', 'sv'],
|
||||
});
|
||||
const translationApi = I18nextTranslationApi.create({ languageApi });
|
||||
|
||||
expect(translationApi.getTranslation(resourceRef).ready).toBe(true);
|
||||
languageApi.setLanguage('sv');
|
||||
await 'a tick';
|
||||
expect(translationApi.getTranslation(resourceRef).ready).toBe(false);
|
||||
});
|
||||
|
||||
it('should wait for translations to be loaded', async () => {
|
||||
const languageApi = AppLanguageSelector.create({
|
||||
availableLanguages: ['en', 'sv'],
|
||||
});
|
||||
const translationApi = I18nextTranslationApi.create({ languageApi });
|
||||
expect(translationApi.getTranslation(resourceRef).ready).toBe(true);
|
||||
languageApi.setLanguage('sv');
|
||||
await 'a tick';
|
||||
expect(translationApi.getTranslation(resourceRef).ready).toBe(false);
|
||||
|
||||
const snapshot = assertReady(
|
||||
await waitForNext(translationApi.translation$(resourceRef), s => s.ready),
|
||||
);
|
||||
expect(snapshot.t('foo')).toBe('Föö');
|
||||
});
|
||||
|
||||
it('should create an instance with message overrides', () => {
|
||||
const languageApi = AppLanguageSelector.create();
|
||||
const translationApi = I18nextTranslationApi.create({
|
||||
languageApi,
|
||||
resources: [
|
||||
createTranslationMessages({
|
||||
ref: resourceRef,
|
||||
messages: { foo: 'Foo Override' },
|
||||
}),
|
||||
],
|
||||
});
|
||||
const snapshot = assertReady(translationApi.getTranslation(resourceRef));
|
||||
expect(snapshot.t('foo')).toBe('Foo Override');
|
||||
expect(snapshot.t('bar')).toBe('Bar');
|
||||
});
|
||||
|
||||
it('should create an instance and ignore null overrides', () => {
|
||||
const languageApi = AppLanguageSelector.create();
|
||||
const translationApi = I18nextTranslationApi.create({
|
||||
languageApi,
|
||||
resources: [
|
||||
createTranslationMessages({
|
||||
ref: plainRef,
|
||||
messages: { foo: null },
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const snapshot = assertReady(translationApi.getTranslation(plainRef));
|
||||
expect(snapshot.t('foo')).toBe('Foo');
|
||||
});
|
||||
|
||||
it('should create an instance with translation resources', async () => {
|
||||
const languageApi = AppLanguageSelector.create({
|
||||
availableLanguages: ['en', 'sv'],
|
||||
});
|
||||
const translationApi = I18nextTranslationApi.create({
|
||||
languageApi,
|
||||
resources: [
|
||||
createTranslationResource({
|
||||
ref: plainRef,
|
||||
translations: {
|
||||
sv: () => Promise.resolve({ default: { foo: 'Föö' } }),
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
languageApi.setLanguage('sv');
|
||||
await 'a tick';
|
||||
|
||||
expect(translationApi.getTranslation(plainRef).ready).toBe(false);
|
||||
|
||||
const snapshot = assertReady(
|
||||
await waitForNext(translationApi.translation$(resourceRef), s => s.ready),
|
||||
);
|
||||
expect(snapshot.t('foo')).toBe('Föö');
|
||||
});
|
||||
|
||||
it('should wait for default language translations to be loaded', async () => {
|
||||
const languageApi = AppLanguageSelector.create();
|
||||
const translationApi = I18nextTranslationApi.create({
|
||||
languageApi,
|
||||
resources: [
|
||||
createTranslationResource({
|
||||
ref: plainRef,
|
||||
translations: {
|
||||
en: () => Promise.resolve({ default: { foo: 'OtherFoo' } }),
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const snapshot = assertReady(
|
||||
await waitForNext(translationApi.translation$(plainRef), s => s.ready),
|
||||
);
|
||||
expect(snapshot.t('foo')).toBe('OtherFoo');
|
||||
});
|
||||
|
||||
it('should allow initial language to not be the default one', async () => {
|
||||
const languageApi = AppLanguageSelector.create({
|
||||
defaultLanguage: 'sv',
|
||||
availableLanguages: ['en', 'sv'],
|
||||
});
|
||||
const translationApi = I18nextTranslationApi.create({
|
||||
languageApi,
|
||||
resources: [
|
||||
createTranslationResource({
|
||||
ref: plainRef,
|
||||
translations: {
|
||||
sv: () => Promise.resolve({ default: { foo: 'Föö' } }),
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const snapshot = assertReady(
|
||||
await waitForNext(translationApi.translation$(plainRef), s => s.ready),
|
||||
);
|
||||
expect(snapshot.t('foo')).toBe('Föö');
|
||||
});
|
||||
|
||||
it('should prefer the last loaded resource', async () => {
|
||||
const languageApi = AppLanguageSelector.create({
|
||||
availableLanguages: ['en', 'sv'],
|
||||
});
|
||||
const translationApi = I18nextTranslationApi.create({
|
||||
languageApi,
|
||||
resources: [
|
||||
createTranslationResource({
|
||||
ref: resourceRef,
|
||||
translations: {
|
||||
// Duplicate translations fully override previous entries, so the foo value here is ignored
|
||||
sv: () => Promise.resolve({ default: { foo: 'Föö', bar: 'Bår' } }),
|
||||
},
|
||||
}),
|
||||
createTranslationResource({
|
||||
ref: resourceRef,
|
||||
translations: {
|
||||
sv: () =>
|
||||
Promise.resolve({
|
||||
default: createTranslationMessages({
|
||||
ref: resourceRef,
|
||||
messages: { foo: null, bar: 'Bär' },
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
languageApi.setLanguage('sv');
|
||||
|
||||
const snapshot = assertReady(
|
||||
await waitForNext(translationApi.translation$(resourceRef), s => s.ready),
|
||||
);
|
||||
expect(snapshot.t('foo')).toBe('Foo');
|
||||
expect(snapshot.t('bar')).toBe('Bär');
|
||||
});
|
||||
|
||||
it('should forward loading errors and then ignore them', async () => {
|
||||
const languageApi = AppLanguageSelector.create();
|
||||
const translationApi = I18nextTranslationApi.create({
|
||||
languageApi,
|
||||
resources: [
|
||||
createTranslationResource({
|
||||
ref: plainRef,
|
||||
translations: { en: () => Promise.reject<never>(new Error('NOPE')) },
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
waitForNext(translationApi.translation$(plainRef), s => s.ready),
|
||||
).rejects.toThrow('NOPE');
|
||||
|
||||
const snapshot = assertReady(translationApi.getTranslation(plainRef));
|
||||
expect(snapshot.t('foo')).toBe('Foo');
|
||||
});
|
||||
|
||||
it('should only call the loader once', async () => {
|
||||
const languageApi = AppLanguageSelector.create();
|
||||
const loader = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ default: { foo: 'OtherFoo' } });
|
||||
const translationApi = I18nextTranslationApi.create({
|
||||
languageApi,
|
||||
resources: [
|
||||
createTranslationResource({
|
||||
ref: plainRef,
|
||||
translations: { en: loader },
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const observable = translationApi.translation$(plainRef);
|
||||
|
||||
const snapshots = await Promise.all([
|
||||
waitForNext(observable, s => s.ready),
|
||||
waitForNext(observable, s => s.ready),
|
||||
waitForNext(translationApi.translation$(plainRef), s => s.ready),
|
||||
]);
|
||||
const [snapshot1, snapshot2, snapshot3] = snapshots.map(assertReady);
|
||||
expect(snapshot1.t('foo')).toBe('OtherFoo');
|
||||
expect(snapshot2.t('foo')).toBe('OtherFoo');
|
||||
expect(snapshot3.t('foo')).toBe('OtherFoo');
|
||||
|
||||
expect(loader).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle interrupted loads gracefully', async () => {
|
||||
const languageApi = AppLanguageSelector.create({
|
||||
availableLanguages: ['en', 'sv', 'no'],
|
||||
});
|
||||
const translationApi = I18nextTranslationApi.create({
|
||||
languageApi,
|
||||
resources: [
|
||||
createTranslationResource({
|
||||
ref: plainRef,
|
||||
translations: {
|
||||
en: () => Promise.resolve({ default: { foo: 'foo' } }),
|
||||
sv: () => Promise.resolve({ default: { foo: 'Föö' } }),
|
||||
no: () => Promise.resolve({ default: { foo: 'Føø' } }),
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// Wait for i18n to be initialized first
|
||||
const enSnapshot = assertReady(
|
||||
await waitForNext(translationApi.translation$(plainRef), s => s.ready),
|
||||
);
|
||||
expect(enSnapshot.t('foo')).toBe('foo');
|
||||
|
||||
languageApi.setLanguage('sv');
|
||||
const nextPromise = waitForNext(translationApi.translation$(plainRef));
|
||||
languageApi.setLanguage('no');
|
||||
|
||||
const snapshot = assertReady(await nextPromise);
|
||||
expect(snapshot.t('foo')).toBe('Føø');
|
||||
});
|
||||
|
||||
it('should only emit changes', async () => {
|
||||
const languageApi = AppLanguageSelector.create({
|
||||
availableLanguages: ['en', 'dk', 'sv', 'no'],
|
||||
});
|
||||
const translationApi = I18nextTranslationApi.create({
|
||||
languageApi,
|
||||
resources: [
|
||||
createTranslationResource({
|
||||
ref: plainRef,
|
||||
translations: {
|
||||
en: () => Promise.resolve({ default: { foo: 'foo' } }),
|
||||
dk: () => Promise.resolve({ default: { foo: 'F🥔🥔' } }),
|
||||
sv: () => Promise.resolve({ default: { foo: 'Föö' } }),
|
||||
no: () => Promise.resolve({ default: { foo: 'Føø' } }),
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const translations = new Array<string | null>();
|
||||
await new Promise<void>(resolve => {
|
||||
const subscription = translationApi.translation$(plainRef).subscribe({
|
||||
next(snapshot) {
|
||||
const translation = snapshot.ready ? snapshot.t('foo') : null;
|
||||
translations.push(translation);
|
||||
|
||||
if (translation === 'foo') {
|
||||
languageApi.setLanguage('dk'); // Not visible
|
||||
languageApi.setLanguage('sv');
|
||||
} else if (translation === 'Föö') {
|
||||
languageApi.setLanguage('no');
|
||||
} else if (translation === 'Føø') {
|
||||
resolve();
|
||||
subscription.unsubscribe();
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
expect(translations).toEqual(['foo', null, 'Föö', null, 'Føø']);
|
||||
});
|
||||
|
||||
describe('formatting', () => {
|
||||
function snapshotWithMessages<
|
||||
TMessages extends { [key in string]: string },
|
||||
>(messages: TMessages) {
|
||||
const translationApi = I18nextTranslationApi.create({
|
||||
languageApi: AppLanguageSelector.create(),
|
||||
});
|
||||
const ref = createTranslationRef({
|
||||
id: 'test',
|
||||
messages,
|
||||
});
|
||||
return assertReady(translationApi.getTranslation(ref));
|
||||
}
|
||||
|
||||
it('should format plain messages', () => {
|
||||
const snapshot = snapshotWithMessages({
|
||||
foo: 'Foo',
|
||||
bar: 'Bar',
|
||||
baz: 'Baz',
|
||||
});
|
||||
|
||||
expect(snapshot.t('foo')).toBe('Foo');
|
||||
expect(snapshot.t('bar')).toBe('Bar');
|
||||
expect(snapshot.t('baz')).toBe('Baz');
|
||||
});
|
||||
|
||||
it('should support interpolation', () => {
|
||||
const snapshot = snapshotWithMessages({
|
||||
shallow: 'Foo {{ bar }}',
|
||||
multiple: 'Foo {{ bar }} {{ baz }}',
|
||||
deep: 'Foo {{ bar.baz }}',
|
||||
});
|
||||
|
||||
expect(snapshot.t('shallow')).toBe('Foo {{ bar }}');
|
||||
expect(snapshot.t('shallow', { bar: 'Bar' })).toBe('Foo Bar');
|
||||
|
||||
expect(snapshot.t('multiple')).toBe('Foo {{ bar }} {{ baz }}');
|
||||
expect(snapshot.t('multiple', { bar: 'Bar' })).toBe('Foo Bar {{ baz }}');
|
||||
expect(snapshot.t('multiple', { bar: 'Bar', baz: 'Baz' })).toBe(
|
||||
'Foo Bar Baz',
|
||||
);
|
||||
|
||||
expect(snapshot.t('deep')).toBe('Foo {{ bar.baz }}');
|
||||
expect(snapshot.t('deep', { bar: { baz: 'Baz' } })).toBe('Foo Baz');
|
||||
});
|
||||
|
||||
// Escaping isn't as useful in React, since we don't need to escape HTML in strings
|
||||
it('should not escape by default', () => {
|
||||
const snapshot = snapshotWithMessages({
|
||||
foo: 'Foo {{ foo }}',
|
||||
});
|
||||
|
||||
expect(snapshot.t('foo', { foo: '<div>' })).toBe('Foo <div>');
|
||||
expect(
|
||||
snapshot.t('foo', {
|
||||
foo: '<div>',
|
||||
interpolation: { escapeValue: true },
|
||||
}),
|
||||
).toBe('Foo <div>');
|
||||
});
|
||||
|
||||
it('should support nesting', () => {
|
||||
const snapshot = snapshotWithMessages({
|
||||
foo: 'Foo $t(bar) $t(baz)',
|
||||
bar: 'Nested',
|
||||
baz: 'Baz {{ qux }}',
|
||||
});
|
||||
|
||||
expect(snapshot.t('foo', { qux: 'Deep' })).toBe('Foo Nested Baz Deep');
|
||||
});
|
||||
|
||||
it('should support formatting', () => {
|
||||
const snapshot = snapshotWithMessages({
|
||||
plain: '= {{ x }}',
|
||||
number: '= {{ x, number }}',
|
||||
numberFixed: '= {{ x, number(minimumFractionDigits: 2) }}',
|
||||
relativeTime: '= {{ x, relativeTime }}',
|
||||
relativeSeconds: '= {{ x, relativeTime(second) }}',
|
||||
relativeSecondsShort:
|
||||
'= {{ x, relativeTime(range: second; style: short) }}',
|
||||
list: '= {{ x, list }}',
|
||||
});
|
||||
|
||||
expect(snapshot.t('plain', { x: 5 })).toBe('= 5');
|
||||
expect(snapshot.t('number', { x: 5 })).toBe('= 5');
|
||||
expect(snapshot.t('number', { x: 5, minimumFractionDigits: 1 })).toBe(
|
||||
'= 5.0',
|
||||
);
|
||||
expect(snapshot.t('numberFixed', { x: 5 })).toBe('= 5.00');
|
||||
expect(
|
||||
snapshot.t('numberFixed', { x: 5, minimumFractionDigits: 3 }),
|
||||
).toBe('= 5.000');
|
||||
expect(snapshot.t('relativeTime', { x: 3 })).toBe('= in 3 days');
|
||||
expect(snapshot.t('relativeTime', { x: -3 })).toBe('= 3 days ago');
|
||||
expect(snapshot.t('relativeTime', { x: 15, range: 'weeks' })).toBe(
|
||||
'= in 15 weeks',
|
||||
);
|
||||
expect(
|
||||
snapshot.t('relativeTime', { x: 15, range: 'weeks', style: 'short' }),
|
||||
).toBe('= in 15 wk.');
|
||||
expect(snapshot.t('relativeSeconds', { x: 1 })).toBe('= in 1 second');
|
||||
expect(snapshot.t('relativeSeconds', { x: 2 })).toBe('= in 2 seconds');
|
||||
expect(snapshot.t('relativeSeconds', { x: -3 })).toBe('= 3 seconds ago');
|
||||
expect(snapshot.t('relativeSeconds', { x: 0 })).toBe('= in 0 seconds');
|
||||
expect(snapshot.t('relativeSecondsShort', { x: 1 })).toBe('= in 1 sec.');
|
||||
expect(snapshot.t('relativeSecondsShort', { x: 2 })).toBe('= in 2 sec.');
|
||||
expect(snapshot.t('relativeSecondsShort', { x: -3 })).toBe(
|
||||
'= 3 sec. ago',
|
||||
);
|
||||
expect(snapshot.t('relativeSecondsShort', { x: 0 })).toBe('= in 0 sec.');
|
||||
expect(snapshot.t('list', { x: ['a'] })).toBe('= a');
|
||||
expect(snapshot.t('list', { x: ['a', 'b'] })).toBe('= a and b');
|
||||
expect(snapshot.t('list', { x: ['a', 'b', 'c'] })).toBe('= a, b, and c');
|
||||
});
|
||||
|
||||
it('should support plurals', () => {
|
||||
const snapshot = snapshotWithMessages({
|
||||
derp_one: 'derp',
|
||||
derp_other: 'derps',
|
||||
derpWithCount_one: '{{ count }} derp',
|
||||
derpWithCount_other: '{{ count }} derps',
|
||||
});
|
||||
|
||||
// TODO(Rugvip): Support plural keys
|
||||
expect(snapshot.t('derp' as any, { count: 1 })).toBe('derp');
|
||||
expect(snapshot.t('derp' as any, { count: 2 })).toBe('derps');
|
||||
expect(snapshot.t('derp' as any, { count: 0 })).toBe('derps');
|
||||
expect(snapshot.t('derpWithCount' as any, { count: 1 })).toBe('1 derp');
|
||||
expect(snapshot.t('derpWithCount' as any, { count: 2 })).toBe('2 derps');
|
||||
expect(snapshot.t('derpWithCount' as any, { count: 0 })).toBe('0 derps');
|
||||
});
|
||||
});
|
||||
});
|
||||
+334
@@ -0,0 +1,334 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
AppLanguageApi,
|
||||
TranslationApi,
|
||||
TranslationMessages,
|
||||
TranslationRef,
|
||||
TranslationResource,
|
||||
TranslationSnapshot,
|
||||
} from '@backstage/core-plugin-api/alpha';
|
||||
import { createInstance as createI18n, type i18n as I18n } from 'i18next';
|
||||
import ObservableImpl from 'zen-observable';
|
||||
|
||||
// Internal import to avoid code duplication, this will lead to duplication in build output
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import {
|
||||
toInternalTranslationResource,
|
||||
InternalTranslationResourceLoader,
|
||||
} from '../../../../../core-plugin-api/src/translation/TranslationResource';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import {
|
||||
toInternalTranslationRef,
|
||||
InternalTranslationRef,
|
||||
} from '../../../../../core-plugin-api/src/translation/TranslationRef';
|
||||
import { Observable } from '@backstage/types';
|
||||
import { DEFAULT_LANGUAGE } from '../AppLanguageApi/AppLanguageSelector';
|
||||
|
||||
/** @alpha */
|
||||
export interface I18nextTranslationApiOptions {
|
||||
languageApi: AppLanguageApi;
|
||||
resources?: Array<TranslationMessages | TranslationResource>;
|
||||
}
|
||||
|
||||
function removeNulls(
|
||||
messages: Record<string, string | null>,
|
||||
): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(messages).filter(
|
||||
(e): e is [string, string] => e[1] !== null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The built-in i18next backend loading logic doesn't handle on the fly switches
|
||||
* of language very well. It gets a bit confused about whether resources are actually
|
||||
* loaded or not, so instead we implement our own resource loader.
|
||||
*/
|
||||
class ResourceLoader {
|
||||
/** Loaded resources by loader key */
|
||||
#loaded = new Set<string>();
|
||||
/** Resource loading promises by loader key */
|
||||
#loading = new Map<string, Promise<void>>();
|
||||
/** Loaders for each resource language */
|
||||
#loaders = new Map<string, InternalTranslationResourceLoader>();
|
||||
|
||||
constructor(
|
||||
private readonly onLoad: (loaded: {
|
||||
language: string;
|
||||
namespace: string;
|
||||
messages: Record<string, string | null>;
|
||||
}) => void,
|
||||
) {}
|
||||
|
||||
addTranslationResource(resource: TranslationResource) {
|
||||
const internalResource = toInternalTranslationResource(resource);
|
||||
for (const entry of internalResource.resources) {
|
||||
const key = this.#getLoaderKey(entry.language, internalResource.id);
|
||||
|
||||
// First loader to register wins, this means that resources registered in the app
|
||||
// have priority over default resource from translation refs
|
||||
if (!this.#loaders.has(key)) {
|
||||
this.#loaders.set(key, entry.loader);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#getLoaderKey(language: string, namespace: string) {
|
||||
return `${language}/${namespace}`;
|
||||
}
|
||||
|
||||
needsLoading(language: string, namespace: string) {
|
||||
const key = this.#getLoaderKey(language, namespace);
|
||||
const loader = this.#loaders.get(key);
|
||||
if (!loader) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !this.#loaded.has(key);
|
||||
}
|
||||
|
||||
async load(language: string, namespace: string): Promise<void> {
|
||||
const key = this.#getLoaderKey(language, namespace);
|
||||
|
||||
const loader = this.#loaders.get(key);
|
||||
if (!loader) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.#loaded.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loading = this.#loading.get(key);
|
||||
if (loading) {
|
||||
await loading;
|
||||
return;
|
||||
}
|
||||
|
||||
const load = loader().then(
|
||||
result => {
|
||||
this.onLoad({ language, namespace, messages: result.messages });
|
||||
this.#loaded.add(key);
|
||||
},
|
||||
error => {
|
||||
this.#loaded.add(key); // Do not try to load failed resources again
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
this.#loading.set(key, load);
|
||||
await load;
|
||||
}
|
||||
}
|
||||
|
||||
/** @alpha */
|
||||
export class I18nextTranslationApi implements TranslationApi {
|
||||
static create(options: I18nextTranslationApiOptions) {
|
||||
const { languages } = options.languageApi.getAvailableLanguages();
|
||||
|
||||
const i18n = createI18n({
|
||||
fallbackLng: DEFAULT_LANGUAGE,
|
||||
supportedLngs: languages,
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
ns: [],
|
||||
defaultNS: false,
|
||||
fallbackNS: false,
|
||||
|
||||
// Disable resource loading on init, meaning i18n will be ready to use immediately
|
||||
initImmediate: false,
|
||||
});
|
||||
|
||||
i18n.init();
|
||||
if (!i18n.isInitialized) {
|
||||
throw new Error('i18next was unexpectedly not initialized');
|
||||
}
|
||||
|
||||
const { language: initialLanguage } = options.languageApi.getLanguage();
|
||||
if (initialLanguage !== DEFAULT_LANGUAGE) {
|
||||
i18n.changeLanguage(initialLanguage);
|
||||
}
|
||||
|
||||
const loader = new ResourceLoader(loaded => {
|
||||
i18n.addResourceBundle(
|
||||
loaded.language,
|
||||
loaded.namespace,
|
||||
removeNulls(loaded.messages),
|
||||
false, // do not merge with existing translations
|
||||
true, // overwrite translations
|
||||
);
|
||||
});
|
||||
|
||||
const resources = options?.resources || [];
|
||||
// Iterate in reverse, giving higher priority to resources registered later
|
||||
for (let i = resources.length - 1; i >= 0; i--) {
|
||||
const resource = resources[i];
|
||||
if (resource.$$type === '@backstage/TranslationResource') {
|
||||
loader.addTranslationResource(resource);
|
||||
} else if (resource.$$type === '@backstage/TranslationMessages') {
|
||||
// Overrides for default messages, created with createTranslationMessages and installed via app
|
||||
i18n.addResourceBundle(
|
||||
DEFAULT_LANGUAGE,
|
||||
resource.id,
|
||||
removeNulls(resource.messages),
|
||||
true, // merge with existing translations
|
||||
false, // do not overwrite translations
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new I18nextTranslationApi(
|
||||
i18n,
|
||||
loader,
|
||||
options.languageApi.getLanguage().language,
|
||||
);
|
||||
|
||||
options.languageApi.language$().subscribe(({ language }) => {
|
||||
instance.#changeLanguage(language);
|
||||
});
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
#i18n: I18n;
|
||||
#loader: ResourceLoader;
|
||||
#language: string;
|
||||
|
||||
/** Keep track of which refs we have registered default resources for */
|
||||
#registeredRefs = new Set<string>();
|
||||
/** Notify observers when language changes */
|
||||
#languageChangeListeners = new Set<() => void>();
|
||||
|
||||
private constructor(i18n: I18n, loader: ResourceLoader, language: string) {
|
||||
this.#i18n = i18n;
|
||||
this.#loader = loader;
|
||||
this.#language = language;
|
||||
}
|
||||
|
||||
getTranslation<TMessages extends { [key in string]: string }>(
|
||||
translationRef: TranslationRef<string, TMessages>,
|
||||
): TranslationSnapshot<TMessages> {
|
||||
const internalRef = toInternalTranslationRef(translationRef);
|
||||
|
||||
this.#registerDefaults(internalRef);
|
||||
|
||||
return this.#createSnapshot(internalRef);
|
||||
}
|
||||
|
||||
translation$<TMessages extends { [key in string]: string }>(
|
||||
translationRef: TranslationRef<string, TMessages>,
|
||||
): Observable<TranslationSnapshot<TMessages>> {
|
||||
const internalRef = toInternalTranslationRef(translationRef);
|
||||
|
||||
this.#registerDefaults(internalRef);
|
||||
|
||||
return new ObservableImpl<TranslationSnapshot<TMessages>>(subscriber => {
|
||||
let loadTicket = {}; // To check for stale loads
|
||||
let lastSnapshotWasReady = false;
|
||||
|
||||
const loadResource = () => {
|
||||
loadTicket = {};
|
||||
const ticket = loadTicket;
|
||||
this.#loader.load(this.#language, internalRef.id).then(
|
||||
() => {
|
||||
if (ticket === loadTicket) {
|
||||
const snapshot = this.#createSnapshot(internalRef);
|
||||
if (snapshot.ready || lastSnapshotWasReady) {
|
||||
lastSnapshotWasReady = snapshot.ready;
|
||||
subscriber.next(snapshot);
|
||||
}
|
||||
}
|
||||
},
|
||||
error => {
|
||||
if (ticket === loadTicket) {
|
||||
subscriber.error(Array.isArray(error) ? error[0] : error);
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const onChange = () => {
|
||||
const snapshot = this.#createSnapshot(internalRef);
|
||||
if (lastSnapshotWasReady && !snapshot.ready) {
|
||||
lastSnapshotWasReady = snapshot.ready;
|
||||
subscriber.next(snapshot);
|
||||
}
|
||||
|
||||
if (!snapshot.ready) {
|
||||
loadResource();
|
||||
}
|
||||
};
|
||||
|
||||
if (this.#loader.needsLoading(this.#language, internalRef.id)) {
|
||||
loadResource();
|
||||
}
|
||||
|
||||
this.#languageChangeListeners.add(onChange);
|
||||
return () => {
|
||||
this.#languageChangeListeners.delete(onChange);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
#changeLanguage(language: string): void {
|
||||
if (this.#language !== language) {
|
||||
this.#language = language;
|
||||
this.#i18n.changeLanguage(language);
|
||||
this.#languageChangeListeners.forEach(listener => listener());
|
||||
}
|
||||
}
|
||||
|
||||
#createSnapshot<TMessages extends { [key in string]: string }>(
|
||||
internalRef: InternalTranslationRef<string, TMessages>,
|
||||
): TranslationSnapshot<TMessages> {
|
||||
if (this.#loader.needsLoading(this.#language, internalRef.id)) {
|
||||
return { ready: false };
|
||||
}
|
||||
|
||||
const t = this.#i18n.getFixedT(null, internalRef.id);
|
||||
|
||||
return {
|
||||
ready: true,
|
||||
t: (key, options) => {
|
||||
return t(key as string, { ...options });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
#registerDefaults(internalRef: InternalTranslationRef): void {
|
||||
if (this.#registeredRefs.has(internalRef.id)) {
|
||||
return;
|
||||
}
|
||||
this.#registeredRefs.add(internalRef.id);
|
||||
|
||||
const defaultMessages = internalRef.getDefaultMessages();
|
||||
this.#i18n.addResourceBundle(
|
||||
DEFAULT_LANGUAGE,
|
||||
internalRef.id,
|
||||
defaultMessages,
|
||||
true, // merge with existing translations
|
||||
false, // do not overwrite translations
|
||||
);
|
||||
|
||||
const defaultResource = internalRef.getDefaultResource();
|
||||
if (defaultResource) {
|
||||
this.#loader.addTranslationResource(defaultResource);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -14,4 +14,4 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export * from './AppTranslationImpl';
|
||||
export * from './I18nextTranslationApi';
|
||||
@@ -276,7 +276,7 @@ describe('Integration Test', () => {
|
||||
});
|
||||
},
|
||||
__experimentalTranslations: {
|
||||
supportedLanguages: ['en'],
|
||||
availableLanguages: ['en', 'de'],
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -43,8 +43,11 @@ import {
|
||||
FeatureFlag,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import {
|
||||
AppTranslationApi,
|
||||
appTranslationApiRef,
|
||||
AppLanguageApi,
|
||||
appLanguageApiRef,
|
||||
translationApiRef,
|
||||
TranslationMessages,
|
||||
TranslationResource,
|
||||
} from '@backstage/core-plugin-api/alpha';
|
||||
import { ApiFactoryRegistry, ApiResolver } from '../apis/system';
|
||||
import {
|
||||
@@ -79,8 +82,8 @@ import { resolveRouteBindings } from './resolveRouteBindings';
|
||||
import { isReactRouterBeta } from './isReactRouterBeta';
|
||||
import { InternalAppContext } from './InternalAppContext';
|
||||
import { AppRouter, getBasePath } from './AppRouter';
|
||||
import { AppTranslationProvider } from './AppTranslationProvider';
|
||||
import { AppTranslationApiImpl } from '../apis/implementations/AppTranslationApi';
|
||||
import { AppLanguageSelector } from '../apis/implementations/AppLanguageApi';
|
||||
import { I18nextTranslationApi } from '../apis/implementations/TranslationApi';
|
||||
import { overrideBaseUrlConfigs } from './overrideBaseUrlConfigs';
|
||||
|
||||
type CompatiblePlugin =
|
||||
@@ -162,7 +165,10 @@ export class AppManager implements BackstageApp {
|
||||
private readonly configLoader?: AppConfigLoader;
|
||||
private readonly defaultApis: Iterable<AnyApiFactory>;
|
||||
private readonly bindRoutes: AppOptions['bindRoutes'];
|
||||
private readonly appTranslationApi: AppTranslationApi;
|
||||
private readonly appLanguageApi: AppLanguageApi;
|
||||
private readonly translationResources: Array<
|
||||
TranslationResource | TranslationMessages
|
||||
>;
|
||||
|
||||
private readonly appIdentityProxy = new AppIdentityProxy();
|
||||
private readonly apiFactoryRegistry: ApiFactoryRegistry;
|
||||
@@ -178,9 +184,13 @@ export class AppManager implements BackstageApp {
|
||||
this.defaultApis = options.defaultApis ?? [];
|
||||
this.bindRoutes = options.bindRoutes;
|
||||
this.apiFactoryRegistry = new ApiFactoryRegistry();
|
||||
this.appTranslationApi = AppTranslationApiImpl.create(
|
||||
options.__experimentalTranslations,
|
||||
);
|
||||
this.appLanguageApi = AppLanguageSelector.createWithStorage({
|
||||
defaultLanguage: options.__experimentalTranslations?.defaultLanguage,
|
||||
availableLanguages:
|
||||
options.__experimentalTranslations?.availableLanguages,
|
||||
});
|
||||
this.translationResources =
|
||||
options.__experimentalTranslations?.resources ?? [];
|
||||
}
|
||||
|
||||
getPlugins(): BackstagePlugin[] {
|
||||
@@ -336,26 +346,24 @@ export class AppManager implements BackstageApp {
|
||||
return (
|
||||
<ApiProvider apis={this.getApiHolder()}>
|
||||
<AppContextProvider appContext={appContext}>
|
||||
<AppTranslationProvider>
|
||||
<ThemeProvider>
|
||||
<RoutingProvider
|
||||
routePaths={routing.paths}
|
||||
routeParents={routing.parents}
|
||||
routeObjects={routing.objects}
|
||||
routeBindings={routeBindings}
|
||||
basePath={getBasePath(loadedConfig.api)}
|
||||
<ThemeProvider>
|
||||
<RoutingProvider
|
||||
routePaths={routing.paths}
|
||||
routeParents={routing.parents}
|
||||
routeObjects={routing.objects}
|
||||
routeBindings={routeBindings}
|
||||
basePath={getBasePath(loadedConfig.api)}
|
||||
>
|
||||
<InternalAppContext.Provider
|
||||
value={{
|
||||
routeObjects: routing.objects,
|
||||
appIdentityProxy: this.appIdentityProxy,
|
||||
}}
|
||||
>
|
||||
<InternalAppContext.Provider
|
||||
value={{
|
||||
routeObjects: routing.objects,
|
||||
appIdentityProxy: this.appIdentityProxy,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</InternalAppContext.Provider>
|
||||
</RoutingProvider>
|
||||
</ThemeProvider>
|
||||
</AppTranslationProvider>
|
||||
{children}
|
||||
</InternalAppContext.Provider>
|
||||
</RoutingProvider>
|
||||
</ThemeProvider>
|
||||
</AppContextProvider>
|
||||
</ApiProvider>
|
||||
);
|
||||
@@ -407,9 +415,21 @@ export class AppManager implements BackstageApp {
|
||||
factory: () => this.appIdentityProxy,
|
||||
});
|
||||
this.apiFactoryRegistry.register('static', {
|
||||
api: appTranslationApiRef,
|
||||
api: appLanguageApiRef,
|
||||
deps: {},
|
||||
factory: () => this.appTranslationApi,
|
||||
factory: () => this.appLanguageApi,
|
||||
});
|
||||
|
||||
// The translation API is registered as a default API so that it can be overridden.
|
||||
// It will be up to the implementer of the new API to register translation resources.
|
||||
this.apiFactoryRegistry.register('default', {
|
||||
api: translationApiRef,
|
||||
deps: { languageApi: appLanguageApiRef },
|
||||
factory: ({ languageApi }) =>
|
||||
I18nextTranslationApi.create({
|
||||
languageApi,
|
||||
resources: this.translationResources,
|
||||
}),
|
||||
});
|
||||
|
||||
// It's possible to replace the feature flag API, but since we must have at least
|
||||
|
||||
@@ -283,10 +283,9 @@ export type AppOptions = {
|
||||
*/
|
||||
bindRoutes?(context: { bind: AppRouteBinder }): void;
|
||||
|
||||
// TODO: Change to ExperimentalI18n type when packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.ts
|
||||
__experimentalTranslations?: {
|
||||
fallbackLanguage?: string | string[];
|
||||
supportedLanguages?: string[];
|
||||
defaultLanguage?: string;
|
||||
availableLanguages?: string[];
|
||||
resources?: Array<TranslationMessages | TranslationResource>;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -5,19 +5,27 @@
|
||||
```ts
|
||||
import { ApiRef } from '@backstage/core-plugin-api';
|
||||
import { BackstagePlugin } from '@backstage/core-plugin-api';
|
||||
import { i18n } from 'i18next';
|
||||
import { Observable } from '@backstage/types';
|
||||
import { ReactNode } from 'react';
|
||||
import { TranslationMessages as TranslationMessages_2 } from '@backstage/core-plugin-api/alpha';
|
||||
import { TranslationRef as TranslationRef_2 } from '@backstage/core-plugin-api/alpha';
|
||||
|
||||
// @alpha (undocumented)
|
||||
export type AppTranslationApi = {
|
||||
getI18n(): i18n;
|
||||
addResource(resource: TranslationRef): void;
|
||||
export type AppLanguageApi = {
|
||||
getAvailableLanguages(): {
|
||||
languages: string[];
|
||||
};
|
||||
setLanguage(language?: string): void;
|
||||
getLanguage(): {
|
||||
language: string;
|
||||
};
|
||||
language$(): Observable<{
|
||||
language: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
// @alpha (undocumented)
|
||||
export const appTranslationApiRef: ApiRef<AppTranslationApi>;
|
||||
export const appLanguageApiRef: ApiRef<AppLanguageApi>;
|
||||
|
||||
// @alpha
|
||||
export function createTranslationMessages<
|
||||
@@ -77,6 +85,37 @@ export interface PluginOptionsProviderProps {
|
||||
// @alpha
|
||||
export const PluginProvider: (props: PluginOptionsProviderProps) => JSX.Element;
|
||||
|
||||
// @alpha (undocumented)
|
||||
export type TranslationApi = {
|
||||
getTranslation<
|
||||
TMessages extends {
|
||||
[key in string]: string;
|
||||
},
|
||||
>(
|
||||
translationRef: TranslationRef<string, TMessages>,
|
||||
): TranslationSnapshot<TMessages>;
|
||||
translation$<
|
||||
TMessages extends {
|
||||
[key in string]: string;
|
||||
},
|
||||
>(
|
||||
translationRef: TranslationRef<string, TMessages>,
|
||||
): Observable<TranslationSnapshot<TMessages>>;
|
||||
};
|
||||
|
||||
// @alpha (undocumented)
|
||||
export const translationApiRef: ApiRef<TranslationApi>;
|
||||
|
||||
// @alpha (undocumented)
|
||||
export type TranslationFunction<
|
||||
TMessages extends {
|
||||
[key in string]: string;
|
||||
},
|
||||
> = <TKey extends keyof TMessages>(
|
||||
key: TKey,
|
||||
options?: TranslationOptions,
|
||||
) => TMessages[TKey];
|
||||
|
||||
// @alpha
|
||||
export interface TranslationMessages<
|
||||
TId extends string = string,
|
||||
@@ -188,6 +227,20 @@ export interface TranslationResourceOptions<
|
||||
translations: TTranslations;
|
||||
}
|
||||
|
||||
// @alpha (undocumented)
|
||||
export type TranslationSnapshot<
|
||||
TMessages extends {
|
||||
[key in string]: string;
|
||||
},
|
||||
> =
|
||||
| {
|
||||
ready: false;
|
||||
}
|
||||
| {
|
||||
ready: true;
|
||||
t: TranslationFunction<TMessages>;
|
||||
};
|
||||
|
||||
// @alpha
|
||||
export function usePluginOptions<
|
||||
TPluginOptions extends {} = {},
|
||||
@@ -200,10 +253,9 @@ export const useTranslationRef: <
|
||||
},
|
||||
>(
|
||||
translationRef: TranslationRef<string, TMessages>,
|
||||
) => <TKey extends keyof TMessages & string>(
|
||||
key: TKey,
|
||||
options?: TranslationOptions,
|
||||
) => TMessages[TKey];
|
||||
) => {
|
||||
t: TranslationFunction<TMessages>;
|
||||
};
|
||||
|
||||
// (No @packageDocumentation comment for this package)
|
||||
```
|
||||
|
||||
@@ -53,7 +53,6 @@
|
||||
"history": "^5.0.0",
|
||||
"i18next": "^22.4.15",
|
||||
"prop-types": "^15.7.2",
|
||||
"react-i18next": "^12.3.1",
|
||||
"zen-observable": "^0.10.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
+10
-7
@@ -14,20 +14,23 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { type i18n } from 'i18next';
|
||||
import { TranslationRef } from '../../translation';
|
||||
import { ApiRef, createApiRef } from '@backstage/core-plugin-api';
|
||||
import { Observable } from '@backstage/types';
|
||||
|
||||
/** @alpha */
|
||||
export type AppTranslationApi = {
|
||||
getI18n(): i18n;
|
||||
export type AppLanguageApi = {
|
||||
getAvailableLanguages(): { languages: string[] };
|
||||
|
||||
addResource(resource: TranslationRef): void;
|
||||
setLanguage(language?: string): void;
|
||||
|
||||
getLanguage(): { language: string };
|
||||
|
||||
language$(): Observable<{ language: string }>;
|
||||
};
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
*/
|
||||
export const appTranslationApiRef: ApiRef<AppTranslationApi> = createApiRef({
|
||||
id: 'core.translation',
|
||||
export const appLanguageApiRef: ApiRef<AppLanguageApi> = createApiRef({
|
||||
id: 'core.applanguage',
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ApiRef, createApiRef } from '@backstage/core-plugin-api';
|
||||
import { Observable } from '@backstage/types';
|
||||
import { TranslationRef } from '../../translation';
|
||||
|
||||
/** @alpha */
|
||||
export interface TranslationOptions {
|
||||
/* no options supported for now */
|
||||
}
|
||||
|
||||
/** @alpha */
|
||||
export type TranslationFunction<TMessages extends { [key in string]: string }> =
|
||||
<TKey extends keyof TMessages>(
|
||||
key: TKey,
|
||||
options?: TranslationOptions,
|
||||
) => TMessages[TKey];
|
||||
|
||||
/** @alpha */
|
||||
export type TranslationSnapshot<TMessages extends { [key in string]: string }> =
|
||||
{ ready: false } | { ready: true; t: TranslationFunction<TMessages> };
|
||||
|
||||
/** @alpha */
|
||||
export type TranslationApi = {
|
||||
getTranslation<TMessages extends { [key in string]: string }>(
|
||||
translationRef: TranslationRef<string, TMessages>,
|
||||
): TranslationSnapshot<TMessages>;
|
||||
|
||||
translation$<TMessages extends { [key in string]: string }>(
|
||||
translationRef: TranslationRef<string, TMessages>,
|
||||
): Observable<TranslationSnapshot<TMessages>>;
|
||||
};
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
*/
|
||||
export const translationApiRef: ApiRef<TranslationApi> = createApiRef({
|
||||
id: 'core.translation',
|
||||
});
|
||||
@@ -13,4 +13,11 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export * from './AppTranslationApi';
|
||||
export {
|
||||
translationApiRef,
|
||||
type TranslationApi,
|
||||
type TranslationFunction,
|
||||
type TranslationOptions,
|
||||
type TranslationSnapshot,
|
||||
} from './TranslationApi';
|
||||
export { appLanguageApiRef, type AppLanguageApi } from './AppLanguageApi';
|
||||
|
||||
@@ -130,10 +130,11 @@ export function createTranslationRef<
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function toInternalTranslationRef(
|
||||
ref: TranslationRef,
|
||||
): InternalTranslationRef {
|
||||
const r = ref as InternalTranslationRef;
|
||||
export function toInternalTranslationRef<
|
||||
TId extends string,
|
||||
TMessages extends { [key in string]: string },
|
||||
>(ref: TranslationRef<TId, TMessages>): InternalTranslationRef<TId, TMessages> {
|
||||
const r = ref as InternalTranslationRef<TId, TMessages>;
|
||||
if (r.$$type !== '@backstage/TranslationRef') {
|
||||
throw new Error(`Invalid translation ref, bad type '${r.$$type}'`);
|
||||
}
|
||||
|
||||
@@ -25,13 +25,18 @@ export interface TranslationResource<TId extends string = string> {
|
||||
id: TId;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export type InternalTranslationResourceLoader = () => Promise<{
|
||||
messages: { [key in string]: string | null };
|
||||
}>;
|
||||
|
||||
/** @internal */
|
||||
export interface InternalTranslationResource<TId extends string = string>
|
||||
extends TranslationResource<TId> {
|
||||
version: 'v1';
|
||||
resources: Array<{
|
||||
language: string;
|
||||
loader(): Promise<{ messages: { [key in string]: string | null } }>;
|
||||
loader: InternalTranslationResourceLoader;
|
||||
}>;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,4 @@ export {
|
||||
type TranslationRefOptions,
|
||||
createTranslationRef,
|
||||
} from './TranslationRef';
|
||||
export {
|
||||
type TranslationOptions,
|
||||
useTranslationRef,
|
||||
} from './useTranslationRef';
|
||||
export { useTranslationRef } from './useTranslationRef';
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useApi } from '../apis';
|
||||
import { useTranslationRef } from './useTranslationRef';
|
||||
import { createTranslationRef } from './TranslationRef';
|
||||
|
||||
jest.mock('../apis', () => ({
|
||||
...jest.requireActual('../apis'),
|
||||
useApi: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('useTranslationRef', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return correct t', () => {
|
||||
const translationRef = createTranslationRef({
|
||||
id: 'ref-id',
|
||||
messages: {
|
||||
key1: 'default1',
|
||||
key2: 'default2',
|
||||
},
|
||||
});
|
||||
|
||||
const tMock = jest.fn();
|
||||
tMock.mockReturnValue('translatedValue');
|
||||
|
||||
const i18nMock = {
|
||||
language: 'en',
|
||||
t: tMock,
|
||||
};
|
||||
|
||||
(useApi as jest.Mock).mockReturnValue({
|
||||
addResource: jest.fn(),
|
||||
});
|
||||
|
||||
(useTranslation as jest.Mock).mockReturnValue(i18nMock);
|
||||
|
||||
const { result } = renderHook(() => useTranslationRef(translationRef));
|
||||
|
||||
const t = result.current;
|
||||
|
||||
t('key1', { condition: 'v1' });
|
||||
expect(tMock).toHaveBeenCalledWith('key1', 'default1', { condition: 'v1' });
|
||||
t('key2');
|
||||
expect(tMock).toHaveBeenCalledWith('key2', 'default2', undefined);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,325 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { ReactNode } from 'react';
|
||||
import {
|
||||
MockErrorApi,
|
||||
TestApiProvider,
|
||||
withLogCollector,
|
||||
} from '@backstage/test-utils';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import { createTranslationRef, TranslationRef } from './TranslationRef';
|
||||
import { useTranslationRef } from './useTranslationRef';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { I18nextTranslationApi } from '../../../core-app-api/src/apis/implementations/TranslationApi';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { AppLanguageSelector } from '../../..//core-app-api/src/apis/implementations/AppLanguageApi';
|
||||
import {
|
||||
createTranslationResource,
|
||||
TranslationApi,
|
||||
translationApiRef,
|
||||
} from '../alpha';
|
||||
import { ErrorApi, errorApiRef } from '../apis';
|
||||
|
||||
const plainRef = createTranslationRef({
|
||||
id: 'plain',
|
||||
messages: {
|
||||
key1: 'default1',
|
||||
key2: 'default2',
|
||||
},
|
||||
});
|
||||
|
||||
function makeWrapper(
|
||||
translationApi: TranslationApi,
|
||||
errorApi: ErrorApi = { error$: jest.fn(), post: jest.fn() },
|
||||
) {
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[translationApiRef, translationApi],
|
||||
[errorApiRef, errorApi],
|
||||
]}
|
||||
children={children}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe('useTranslationRef', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should show default translations', () => {
|
||||
const languageApi = AppLanguageSelector.create();
|
||||
const translationApi = I18nextTranslationApi.create({ languageApi });
|
||||
|
||||
const { result } = renderHook(() => useTranslationRef(plainRef), {
|
||||
wrapper: makeWrapper(translationApi),
|
||||
});
|
||||
|
||||
const { t } = result.current;
|
||||
|
||||
expect(t('key1')).toBe('default1');
|
||||
expect(t('key2')).toBe('default2');
|
||||
});
|
||||
|
||||
it('should show load translation resource', async () => {
|
||||
const languageApi = AppLanguageSelector.create();
|
||||
const translationApi = I18nextTranslationApi.create({
|
||||
languageApi,
|
||||
resources: [
|
||||
createTranslationResource({
|
||||
ref: plainRef,
|
||||
translations: {
|
||||
en: () =>
|
||||
Promise.resolve({ default: { key1: 'en1', key2: 'en2' } }),
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const { result, waitForNextUpdate } = renderHook(
|
||||
() => useTranslationRef(plainRef),
|
||||
{
|
||||
wrapper: makeWrapper(translationApi),
|
||||
},
|
||||
);
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
const { t } = result.current;
|
||||
|
||||
expect(t('key1')).toBe('en1');
|
||||
expect(t('key2')).toBe('en2');
|
||||
});
|
||||
|
||||
it('should switch between languages', async () => {
|
||||
const languageApi = AppLanguageSelector.create({
|
||||
availableLanguages: ['en', 'de'],
|
||||
});
|
||||
const translationApi = I18nextTranslationApi.create({
|
||||
languageApi,
|
||||
resources: [
|
||||
createTranslationResource({
|
||||
ref: plainRef,
|
||||
translations: {
|
||||
de: () =>
|
||||
Promise.resolve({ default: { key1: 'de1', key2: 'de2' } }),
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const { result, waitForNextUpdate } = renderHook(
|
||||
() => useTranslationRef(plainRef),
|
||||
{
|
||||
wrapper: makeWrapper(translationApi),
|
||||
},
|
||||
);
|
||||
|
||||
const { t } = result.current;
|
||||
|
||||
expect(t('key1')).toBe('default1');
|
||||
expect(t('key2')).toBe('default2');
|
||||
|
||||
languageApi.setLanguage('de');
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
const { t: t2 } = result.current;
|
||||
|
||||
expect(t2('key1')).toBe('de1');
|
||||
expect(t2('key2')).toBe('de2');
|
||||
});
|
||||
|
||||
it('should load default resource', async () => {
|
||||
const resourceRef = createTranslationRef({
|
||||
id: 'resource',
|
||||
messages: {
|
||||
key1: 'default1',
|
||||
key2: 'default2',
|
||||
},
|
||||
translations: {
|
||||
de: () => Promise.resolve({ default: { key1: 'de1', key2: 'de2' } }),
|
||||
},
|
||||
});
|
||||
|
||||
const languageApi = AppLanguageSelector.create({
|
||||
defaultLanguage: 'de',
|
||||
availableLanguages: ['en', 'de'],
|
||||
});
|
||||
const translationApi = I18nextTranslationApi.create({
|
||||
languageApi,
|
||||
});
|
||||
|
||||
const { result, waitForNextUpdate } = renderHook(
|
||||
() => useTranslationRef(resourceRef),
|
||||
{
|
||||
wrapper: makeWrapper(translationApi),
|
||||
},
|
||||
);
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
const { t } = result.current;
|
||||
|
||||
expect(t('key1')).toBe('de1');
|
||||
expect(t('key2')).toBe('de2');
|
||||
});
|
||||
|
||||
it('should log once and then ignore loading errors', async () => {
|
||||
const ref = createTranslationRef({
|
||||
id: 'test',
|
||||
messages: {
|
||||
key: 'default',
|
||||
},
|
||||
});
|
||||
|
||||
const errorApi = new MockErrorApi({ collect: true });
|
||||
const languageApi = AppLanguageSelector.create();
|
||||
const translationApi = I18nextTranslationApi.create({
|
||||
languageApi,
|
||||
resources: [
|
||||
createTranslationResource({
|
||||
ref,
|
||||
translations: {
|
||||
en: async () => {
|
||||
throw new Error('NOPE');
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const rendered1 = renderHook(() => useTranslationRef(ref), {
|
||||
wrapper: makeWrapper(translationApi, errorApi),
|
||||
});
|
||||
const rendered2 = renderHook(() => useTranslationRef(ref), {
|
||||
wrapper: makeWrapper(translationApi, errorApi),
|
||||
});
|
||||
|
||||
const { error } = await withLogCollector(['error'], async () => {
|
||||
await rendered2.waitForNextUpdate();
|
||||
});
|
||||
|
||||
const msg =
|
||||
"Failed to load translation resource 'test'; caused by Error: NOPE";
|
||||
expect(error).toEqual([msg]);
|
||||
expect(errorApi.getErrors()).toEqual([
|
||||
{
|
||||
error: new Error(msg),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(rendered1.result.current.t('key')).toBe('default');
|
||||
expect(rendered2.result.current.t('key')).toBe('default');
|
||||
});
|
||||
|
||||
it('should log once and then ignore loading errors after initial load', async () => {
|
||||
const ref = createTranslationRef({
|
||||
id: 'test',
|
||||
messages: {
|
||||
key: 'default',
|
||||
},
|
||||
});
|
||||
|
||||
const errorApi = new MockErrorApi({ collect: true });
|
||||
const languageApi = AppLanguageSelector.create({
|
||||
availableLanguages: ['en', 'de'],
|
||||
});
|
||||
const translationApi = I18nextTranslationApi.create({
|
||||
languageApi,
|
||||
resources: [
|
||||
createTranslationResource({
|
||||
ref,
|
||||
translations: {
|
||||
de: async () => {
|
||||
throw new Error('NOPE');
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useTranslationRef(ref), {
|
||||
wrapper: makeWrapper(translationApi, errorApi),
|
||||
});
|
||||
|
||||
expect(result.current.t('key')).toBe('default');
|
||||
languageApi.setLanguage('de');
|
||||
|
||||
const { error } = await withLogCollector(['error'], async () => {
|
||||
const rendered1 = renderHook(() => useTranslationRef(ref), {
|
||||
wrapper: makeWrapper(translationApi, errorApi),
|
||||
});
|
||||
const rendered2 = renderHook(() => useTranslationRef(ref), {
|
||||
wrapper: makeWrapper(translationApi, errorApi),
|
||||
});
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve)); // Wait a long tick
|
||||
|
||||
expect(rendered1.result.current.t('key')).toBe('default');
|
||||
expect(rendered2.result.current.t('key')).toBe('default');
|
||||
});
|
||||
|
||||
const msg =
|
||||
"Failed to load translation resource 'test'; caused by Error: NOPE";
|
||||
expect(error).toEqual([msg]);
|
||||
expect(errorApi.getErrors()).toEqual([
|
||||
{
|
||||
error: new Error(msg),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle translationRef switches', async () => {
|
||||
const ref1 = createTranslationRef({
|
||||
id: 'test1',
|
||||
messages: {
|
||||
key: 'default1',
|
||||
},
|
||||
});
|
||||
const ref2 = createTranslationRef({
|
||||
id: 'test2',
|
||||
messages: {
|
||||
key: 'default2',
|
||||
},
|
||||
});
|
||||
|
||||
const languageApi = AppLanguageSelector.create();
|
||||
const translationApi = I18nextTranslationApi.create({ languageApi });
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ translationRef }) => useTranslationRef(translationRef),
|
||||
{
|
||||
wrapper: ({ children }) => (
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[translationApiRef, translationApi],
|
||||
[errorApiRef, { post: jest.fn() }],
|
||||
]}
|
||||
children={children}
|
||||
/>
|
||||
),
|
||||
initialProps: { translationRef: ref1 as TranslationRef },
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.current.t('key')).toBe('default1');
|
||||
rerender({ translationRef: ref2 });
|
||||
expect(result.current.t('key')).toBe('default2');
|
||||
});
|
||||
});
|
||||
@@ -14,36 +14,92 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { errorApiRef, useApi } from '../apis';
|
||||
import {
|
||||
translationApiRef,
|
||||
TranslationFunction,
|
||||
TranslationSnapshot,
|
||||
} from '../apis/alpha';
|
||||
import { TranslationRef } from './TranslationRef';
|
||||
|
||||
import { useApi } from '../apis';
|
||||
import { appTranslationApiRef } from '../apis/alpha';
|
||||
import { toInternalTranslationRef, TranslationRef } from './TranslationRef';
|
||||
|
||||
/** @alpha */
|
||||
export interface TranslationOptions {
|
||||
/* no options supported for now */
|
||||
}
|
||||
// Make sure we don't fill the logs with loading errors for the same ref
|
||||
const loggedRefs = new WeakSet<TranslationRef<string, {}>>();
|
||||
|
||||
/** @alpha */
|
||||
export const useTranslationRef = <
|
||||
TMessages extends { [key in string]: string },
|
||||
>(
|
||||
translationRef: TranslationRef<string, TMessages>,
|
||||
) => {
|
||||
const translationApi = useApi(appTranslationApiRef);
|
||||
): { t: TranslationFunction<TMessages> } => {
|
||||
const errorApi = useApi(errorApiRef);
|
||||
const translationApi = useApi(translationApiRef);
|
||||
|
||||
const internalRef = toInternalTranslationRef(translationRef);
|
||||
translationApi.addResource(translationRef);
|
||||
const [snapshot, setSnapshot] = useState<TranslationSnapshot<TMessages>>(() =>
|
||||
translationApi.getTranslation(translationRef),
|
||||
);
|
||||
const observable = useMemo(
|
||||
() => translationApi.translation$(translationRef),
|
||||
[translationApi, translationRef],
|
||||
);
|
||||
|
||||
const { t } = useTranslation(internalRef.id, {
|
||||
useSuspense: process.env.NODE_ENV !== 'test',
|
||||
});
|
||||
const onError = useCallback(
|
||||
(error: Error) => {
|
||||
if (!loggedRefs.has(translationRef)) {
|
||||
const errMsg = `Failed to load translation resource '${translationRef.id}'; caused by ${error}`;
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(errMsg);
|
||||
errorApi.post(new Error(errMsg));
|
||||
loggedRefs.add(translationRef);
|
||||
}
|
||||
},
|
||||
[errorApi, translationRef],
|
||||
);
|
||||
|
||||
const defaultMessages = internalRef.getDefaultMessages();
|
||||
useEffect(() => {
|
||||
const subscription = observable.subscribe({
|
||||
next(next) {
|
||||
if (next.ready) {
|
||||
setSnapshot(next);
|
||||
}
|
||||
},
|
||||
error(error) {
|
||||
onError(error);
|
||||
},
|
||||
});
|
||||
|
||||
return <TKey extends keyof TMessages & string>(
|
||||
key: TKey,
|
||||
options?: TranslationOptions,
|
||||
): TMessages[TKey] => t(key, defaultMessages[key], options);
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [observable, onError]);
|
||||
|
||||
// Keep track of if the provided translation ref changes, and in that case update the snapshot
|
||||
const initialRenderRef = useRef(true);
|
||||
useEffect(() => {
|
||||
if (initialRenderRef.current) {
|
||||
initialRenderRef.current = false;
|
||||
} else {
|
||||
setSnapshot(translationApi.getTranslation(translationRef));
|
||||
}
|
||||
}, [translationApi, translationRef]);
|
||||
|
||||
if (!snapshot.ready) {
|
||||
throw new Promise<void>(resolve => {
|
||||
const subscription = observable.subscribe({
|
||||
next(next) {
|
||||
if (next.ready) {
|
||||
subscription.unsubscribe();
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
error(error) {
|
||||
subscription.unsubscribe();
|
||||
onError(error);
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return { t: snapshot.t };
|
||||
};
|
||||
|
||||
@@ -3,13 +3,37 @@
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
import { Observable } from '@backstage/types';
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { default as React_2 } from 'react';
|
||||
import { TranslationApi } from '@backstage/core-plugin-api/alpha';
|
||||
import { TranslationRef } from '@backstage/core-plugin-api/alpha';
|
||||
import { TranslationSnapshot } from '@backstage/core-plugin-api/alpha';
|
||||
|
||||
// @alpha
|
||||
export const MockPluginProvider: ({
|
||||
children,
|
||||
}: PropsWithChildren<{}>) => React_2.JSX.Element;
|
||||
|
||||
// @alpha (undocumented)
|
||||
export class MockTranslationApi implements TranslationApi {
|
||||
// (undocumented)
|
||||
static create(): MockTranslationApi;
|
||||
// (undocumented)
|
||||
getTranslation<
|
||||
TMessages extends {
|
||||
[key in string]: string;
|
||||
},
|
||||
>(
|
||||
translationRef: TranslationRef<string, TMessages>,
|
||||
): TranslationSnapshot<TMessages>;
|
||||
// (undocumented)
|
||||
translation$<
|
||||
TMessages extends {
|
||||
[key in string]: string;
|
||||
},
|
||||
>(): Observable<TranslationSnapshot<TMessages>>;
|
||||
}
|
||||
|
||||
// (No @packageDocumentation comment for this package)
|
||||
```
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
"@testing-library/user-event": "^14.0.0",
|
||||
"@types/react": "^16.13.1 || ^17.0.0",
|
||||
"cross-fetch": "^3.1.5",
|
||||
"i18next": "^22.4.15",
|
||||
"zen-observable": "^0.10.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -15,3 +15,4 @@
|
||||
*/
|
||||
|
||||
export * from './testUtils/MockPluginProvider';
|
||||
export * from './testUtils/apis/TranslationApi';
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { createTranslationRef } from '@backstage/core-plugin-api/alpha';
|
||||
import { MockTranslationApi } from './MockTranslationApi';
|
||||
|
||||
describe('MockTranslationApi', () => {
|
||||
function snapshotWithMessages<TMessages extends { [key in string]: string }>(
|
||||
messages: TMessages,
|
||||
) {
|
||||
const translationApi = MockTranslationApi.create();
|
||||
const ref = createTranslationRef({
|
||||
id: 'test',
|
||||
messages,
|
||||
});
|
||||
const snapshot = translationApi.getTranslation(ref);
|
||||
if (!snapshot.ready) {
|
||||
throw new Error('Translation snapshot is not ready');
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
it('should format plain messages', () => {
|
||||
const snapshot = snapshotWithMessages({
|
||||
foo: 'Foo',
|
||||
bar: 'Bar',
|
||||
baz: 'Baz',
|
||||
});
|
||||
|
||||
expect(snapshot.t('foo')).toBe('Foo');
|
||||
expect(snapshot.t('bar')).toBe('Bar');
|
||||
expect(snapshot.t('baz')).toBe('Baz');
|
||||
});
|
||||
|
||||
it('should support interpolation', () => {
|
||||
const snapshot = snapshotWithMessages({
|
||||
shallow: 'Foo {{ bar }}',
|
||||
multiple: 'Foo {{ bar }} {{ baz }}',
|
||||
deep: 'Foo {{ bar.baz }}',
|
||||
});
|
||||
|
||||
expect(snapshot.t('shallow')).toBe('Foo {{ bar }}');
|
||||
expect(snapshot.t('shallow', { bar: 'Bar' })).toBe('Foo Bar');
|
||||
|
||||
expect(snapshot.t('multiple')).toBe('Foo {{ bar }} {{ baz }}');
|
||||
expect(snapshot.t('multiple', { bar: 'Bar' })).toBe('Foo Bar {{ baz }}');
|
||||
expect(snapshot.t('multiple', { bar: 'Bar', baz: 'Baz' })).toBe(
|
||||
'Foo Bar Baz',
|
||||
);
|
||||
|
||||
expect(snapshot.t('deep')).toBe('Foo {{ bar.baz }}');
|
||||
expect(snapshot.t('deep', { bar: { baz: 'Baz' } })).toBe('Foo Baz');
|
||||
});
|
||||
|
||||
// Escaping isn't as useful in React, since we don't need to escape HTML in strings
|
||||
it('should not escape by default', () => {
|
||||
const snapshot = snapshotWithMessages({
|
||||
foo: 'Foo {{ foo }}',
|
||||
});
|
||||
|
||||
expect(snapshot.t('foo', { foo: '<div>' })).toBe('Foo <div>');
|
||||
expect(
|
||||
snapshot.t('foo', {
|
||||
foo: '<div>',
|
||||
interpolation: { escapeValue: true },
|
||||
}),
|
||||
).toBe('Foo <div>');
|
||||
});
|
||||
|
||||
it('should support nesting', () => {
|
||||
const snapshot = snapshotWithMessages({
|
||||
foo: 'Foo $t(bar) $t(baz)',
|
||||
bar: 'Nested',
|
||||
baz: 'Baz {{ qux }}',
|
||||
});
|
||||
|
||||
expect(snapshot.t('foo', { qux: 'Deep' })).toBe('Foo Nested Baz Deep');
|
||||
});
|
||||
|
||||
it('should support formatting', () => {
|
||||
const snapshot = snapshotWithMessages({
|
||||
plain: '= {{ x }}',
|
||||
number: '= {{ x, number }}',
|
||||
numberFixed: '= {{ x, number(minimumFractionDigits: 2) }}',
|
||||
relativeTime: '= {{ x, relativeTime }}',
|
||||
relativeSeconds: '= {{ x, relativeTime(second) }}',
|
||||
relativeSecondsShort:
|
||||
'= {{ x, relativeTime(range: second; style: short) }}',
|
||||
list: '= {{ x, list }}',
|
||||
});
|
||||
|
||||
expect(snapshot.t('plain', { x: 5 })).toBe('= 5');
|
||||
expect(snapshot.t('number', { x: 5 })).toBe('= 5');
|
||||
expect(snapshot.t('number', { x: 5, minimumFractionDigits: 1 })).toBe(
|
||||
'= 5.0',
|
||||
);
|
||||
expect(snapshot.t('numberFixed', { x: 5 })).toBe('= 5.00');
|
||||
expect(snapshot.t('numberFixed', { x: 5, minimumFractionDigits: 3 })).toBe(
|
||||
'= 5.000',
|
||||
);
|
||||
expect(snapshot.t('relativeTime', { x: 3 })).toBe('= in 3 days');
|
||||
expect(snapshot.t('relativeTime', { x: -3 })).toBe('= 3 days ago');
|
||||
expect(snapshot.t('relativeTime', { x: 15, range: 'weeks' })).toBe(
|
||||
'= in 15 weeks',
|
||||
);
|
||||
expect(
|
||||
snapshot.t('relativeTime', { x: 15, range: 'weeks', style: 'short' }),
|
||||
).toBe('= in 15 wk.');
|
||||
expect(snapshot.t('relativeSeconds', { x: 1 })).toBe('= in 1 second');
|
||||
expect(snapshot.t('relativeSeconds', { x: 2 })).toBe('= in 2 seconds');
|
||||
expect(snapshot.t('relativeSeconds', { x: -3 })).toBe('= 3 seconds ago');
|
||||
expect(snapshot.t('relativeSeconds', { x: 0 })).toBe('= in 0 seconds');
|
||||
expect(snapshot.t('relativeSecondsShort', { x: 1 })).toBe('= in 1 sec.');
|
||||
expect(snapshot.t('relativeSecondsShort', { x: 2 })).toBe('= in 2 sec.');
|
||||
expect(snapshot.t('relativeSecondsShort', { x: -3 })).toBe('= 3 sec. ago');
|
||||
expect(snapshot.t('relativeSecondsShort', { x: 0 })).toBe('= in 0 sec.');
|
||||
expect(snapshot.t('list', { x: ['a'] })).toBe('= a');
|
||||
expect(snapshot.t('list', { x: ['a', 'b'] })).toBe('= a and b');
|
||||
expect(snapshot.t('list', { x: ['a', 'b', 'c'] })).toBe('= a, b, and c');
|
||||
});
|
||||
|
||||
it('should support plurals', () => {
|
||||
const snapshot = snapshotWithMessages({
|
||||
derp_one: 'derp',
|
||||
derp_other: 'derps',
|
||||
derpWithCount_one: '{{ count }} derp',
|
||||
derpWithCount_other: '{{ count }} derps',
|
||||
});
|
||||
|
||||
// TODO(Rugvip): Support plural keys
|
||||
expect(snapshot.t('derp' as any, { count: 1 })).toBe('derp');
|
||||
expect(snapshot.t('derp' as any, { count: 2 })).toBe('derps');
|
||||
expect(snapshot.t('derp' as any, { count: 0 })).toBe('derps');
|
||||
expect(snapshot.t('derpWithCount' as any, { count: 1 })).toBe('1 derp');
|
||||
expect(snapshot.t('derpWithCount' as any, { count: 2 })).toBe('2 derps');
|
||||
expect(snapshot.t('derpWithCount' as any, { count: 0 })).toBe('0 derps');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
TranslationApi,
|
||||
TranslationRef,
|
||||
TranslationSnapshot,
|
||||
} from '@backstage/core-plugin-api/alpha';
|
||||
import { createInstance as createI18n, type i18n as I18n } from 'i18next';
|
||||
import ObservableImpl from 'zen-observable';
|
||||
|
||||
import { Observable } from '@backstage/types';
|
||||
// Internal import to avoid code duplication, this will lead to duplication in build output
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { toInternalTranslationRef } from '../../../../../core-plugin-api/src/translation/TranslationRef';
|
||||
|
||||
const DEFAULT_LANGUAGE = 'en';
|
||||
|
||||
/** @alpha */
|
||||
export class MockTranslationApi implements TranslationApi {
|
||||
static create() {
|
||||
const i18n = createI18n({
|
||||
fallbackLng: DEFAULT_LANGUAGE,
|
||||
supportedLngs: [DEFAULT_LANGUAGE],
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
ns: [],
|
||||
defaultNS: false,
|
||||
fallbackNS: false,
|
||||
|
||||
// Disable resource loading on init, meaning i18n will be ready to use immediately
|
||||
initImmediate: false,
|
||||
});
|
||||
|
||||
i18n.init();
|
||||
if (!i18n.isInitialized) {
|
||||
throw new Error('i18next was unexpectedly not initialized');
|
||||
}
|
||||
|
||||
return new MockTranslationApi(i18n);
|
||||
}
|
||||
|
||||
#i18n: I18n;
|
||||
#registeredRefs = new Set<string>();
|
||||
|
||||
private constructor(i18n: I18n) {
|
||||
this.#i18n = i18n;
|
||||
}
|
||||
|
||||
getTranslation<TMessages extends { [key in string]: string }>(
|
||||
translationRef: TranslationRef<string, TMessages>,
|
||||
): TranslationSnapshot<TMessages> {
|
||||
const internalRef = toInternalTranslationRef(translationRef);
|
||||
|
||||
const t = this.#i18n.getFixedT(null, internalRef.id);
|
||||
|
||||
if (!this.#registeredRefs.has(internalRef.id)) {
|
||||
this.#registeredRefs.add(internalRef.id);
|
||||
this.#i18n.addResourceBundle(
|
||||
DEFAULT_LANGUAGE,
|
||||
internalRef.id,
|
||||
internalRef.getDefaultMessages(),
|
||||
false, // do not merge
|
||||
true, // overwrite existing
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
ready: true,
|
||||
t: (key, options) => {
|
||||
return t(key as string, { ...options });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
translation$<TMessages extends { [key in string]: string }>(): Observable<
|
||||
TranslationSnapshot<TMessages>
|
||||
> {
|
||||
// No need to implement, getTranslation will always return a ready snapshot
|
||||
return new ObservableImpl<TranslationSnapshot<TMessages>>(_subscriber => {
|
||||
return () => {};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { MockTranslationApi } from './MockTranslationApi';
|
||||
@@ -20,10 +20,13 @@ import {
|
||||
fetchApiRef,
|
||||
storageApiRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { translationApiRef } from '@backstage/core-plugin-api/alpha';
|
||||
import { MockErrorApi, MockFetchApi, MockStorageApi } from './apis';
|
||||
import { MockTranslationApi } from './apis/TranslationApi';
|
||||
|
||||
export const mockApis = [
|
||||
createApiFactory(errorApiRef, new MockErrorApi()),
|
||||
createApiFactory(fetchApiRef, new MockFetchApi()),
|
||||
createApiFactory(storageApiRef, MockStorageApi.create()),
|
||||
createApiFactory(translationApiRef, MockTranslationApi.create()),
|
||||
];
|
||||
|
||||
@@ -171,7 +171,7 @@ export const EntityAdrContent = (props: {
|
||||
const scmIntegrations = useApi(scmIntegrationsApiRef);
|
||||
const adrApi = useApi(adrApiRef);
|
||||
const entityHasAdrs = isAdrAvailable(entity);
|
||||
const t = useTranslationRef(adrTranslationRef);
|
||||
const { t } = useTranslationRef(adrTranslationRef);
|
||||
|
||||
const config = useApi(configApiRef);
|
||||
const appSupportConfigured = config?.getOptionalConfig('app.support');
|
||||
|
||||
@@ -57,7 +57,6 @@
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.61",
|
||||
"@types/react": "^16.13.1 || ^17.0.0",
|
||||
"react-i18next": "^12.3.1",
|
||||
"react-use": "^17.2.4",
|
||||
"zen-observable": "^0.10.0"
|
||||
},
|
||||
|
||||
@@ -16,111 +16,66 @@
|
||||
|
||||
import React from 'react';
|
||||
import { screen, fireEvent } from '@testing-library/react';
|
||||
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
|
||||
import { UserSettingsLanguageToggle } from './UserSettingsLanguageToggle';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
jest.mock('@backstage/core-plugin-api/alpha', () => ({
|
||||
...jest.requireActual('@backstage/core-plugin-api/alpha'),
|
||||
useTranslationRef: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('react-i18next', () => ({
|
||||
...jest.requireActual('react-i18next'),
|
||||
useTranslation: jest.fn(),
|
||||
}));
|
||||
import { TestApiProvider, renderInTestApp } from '@backstage/test-utils';
|
||||
import { appLanguageApiRef } from '@backstage/core-plugin-api/alpha';
|
||||
|
||||
describe('UserSettingsLanguageToggle', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render correctly with multiple supported languages', async () => {
|
||||
const messages: Record<string, string> = {
|
||||
en: 'English',
|
||||
fr: 'French',
|
||||
de: 'German',
|
||||
language: 'language',
|
||||
change_the_language: 'Change the language',
|
||||
};
|
||||
it('should not render with only one available language', async () => {
|
||||
const rendered = await renderInTestApp(<UserSettingsLanguageToggle />);
|
||||
|
||||
const i18nMock = {
|
||||
language: 'en',
|
||||
options: {
|
||||
supportedLngs: ['en', 'fr', 'de'],
|
||||
},
|
||||
changeLanguage: jest.fn(),
|
||||
};
|
||||
|
||||
(useTranslation as jest.Mock).mockReturnValue({
|
||||
i18n: i18nMock,
|
||||
});
|
||||
|
||||
(useTranslationRef as jest.Mock).mockReturnValue(
|
||||
(key: string, option: any) =>
|
||||
messages[option?.language || key] || 'translatedValue',
|
||||
);
|
||||
|
||||
await renderInTestApp(<UserSettingsLanguageToggle />);
|
||||
|
||||
expect(screen.getAllByText('Change the language')).toHaveLength(1);
|
||||
expect(screen.getAllByText('English')).toHaveLength(1);
|
||||
expect(screen.getAllByText('French')).toHaveLength(1);
|
||||
expect(screen.getAllByText('German')).toHaveLength(1);
|
||||
expect(rendered.container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('should not render when only one supported language', async () => {
|
||||
const tMock = jest.fn().mockReturnValue('translatedValue');
|
||||
const i18nMock = {
|
||||
language: 'en',
|
||||
options: {
|
||||
supportedLngs: ['en'],
|
||||
},
|
||||
changeLanguage: jest.fn(),
|
||||
it('should render correctly with multiple available languages', async () => {
|
||||
const mockLanguageApi: typeof appLanguageApiRef.T = {
|
||||
getAvailableLanguages: jest
|
||||
.fn()
|
||||
.mockReturnValue({ languages: ['en', 'de'] }),
|
||||
getLanguage: jest.fn().mockReturnValue({ language: 'en' }),
|
||||
language$: jest.fn().mockReturnValue({
|
||||
subscribe: jest.fn().mockReturnValue({ unsubscribe: jest.fn() }),
|
||||
}),
|
||||
setLanguage: jest.fn(),
|
||||
};
|
||||
|
||||
(useTranslationRef as jest.Mock).mockReturnValue(tMock);
|
||||
await renderInTestApp(
|
||||
<TestApiProvider apis={[[appLanguageApiRef, mockLanguageApi]]}>
|
||||
<UserSettingsLanguageToggle />
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
(useTranslation as jest.Mock).mockReturnValue({
|
||||
i18n: i18nMock,
|
||||
});
|
||||
|
||||
await renderInTestApp(<UserSettingsLanguageToggle />);
|
||||
|
||||
expect(screen.queryByText('translatedValue')).toBeNull();
|
||||
expect(screen.queryByText('English')).toBeNull();
|
||||
expect(screen.getByText('Change the language')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle language change', async () => {
|
||||
const messages: Record<string, string> = {
|
||||
en: 'English',
|
||||
fr: 'French',
|
||||
language: 'language',
|
||||
change_the_language: 'Change the language',
|
||||
const mockLanguageApi: typeof appLanguageApiRef.T = {
|
||||
getAvailableLanguages: jest
|
||||
.fn()
|
||||
.mockReturnValue({ languages: ['en', 'de'] }),
|
||||
getLanguage: jest.fn().mockReturnValue({ language: 'en' }),
|
||||
language$: jest.fn().mockReturnValue({
|
||||
subscribe: jest.fn().mockReturnValue({ unsubscribe: jest.fn() }),
|
||||
}),
|
||||
setLanguage: jest.fn(),
|
||||
};
|
||||
|
||||
const i18nMock = {
|
||||
language: 'en',
|
||||
options: {
|
||||
supportedLngs: ['en', 'fr'],
|
||||
},
|
||||
changeLanguage: jest.fn(),
|
||||
};
|
||||
|
||||
(useTranslationRef as jest.Mock).mockReturnValue(
|
||||
(key: string, option: any) =>
|
||||
messages[option?.language || key] || 'translatedValue',
|
||||
await renderInTestApp(
|
||||
<TestApiProvider apis={[[appLanguageApiRef, mockLanguageApi]]}>
|
||||
<UserSettingsLanguageToggle />
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
(useTranslation as jest.Mock).mockReturnValue({
|
||||
i18n: i18nMock,
|
||||
});
|
||||
expect(screen.getByText('Change the language')).toBeInTheDocument();
|
||||
|
||||
await renderInTestApp(<UserSettingsLanguageToggle />);
|
||||
|
||||
fireEvent.click(screen.getByText('French'));
|
||||
fireEvent.click(screen.getByText('de'));
|
||||
|
||||
expect(i18nMock.changeLanguage).toHaveBeenCalledWith('fr');
|
||||
expect(mockLanguageApi.setLanguage).toHaveBeenCalledWith('de');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,8 +14,11 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
useTranslationRef,
|
||||
appLanguageApiRef,
|
||||
} from '@backstage/core-plugin-api/alpha';
|
||||
import ToggleButton from '@material-ui/lab/ToggleButton';
|
||||
import ToggleButtonGroup from '@material-ui/lab/ToggleButtonGroup';
|
||||
import {
|
||||
@@ -26,7 +29,8 @@ import {
|
||||
makeStyles,
|
||||
} from '@material-ui/core';
|
||||
import { userSettingsTranslationRef } from '../../translation';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
import useObservable from 'react-use/lib/useObservable';
|
||||
|
||||
type TooltipToggleButtonProps = {
|
||||
children: JSX.Element;
|
||||
@@ -85,15 +89,18 @@ const TooltipToggleButton = ({
|
||||
/** @public */
|
||||
export const UserSettingsLanguageToggle = () => {
|
||||
const classes = useStyles();
|
||||
const { i18n } = useTranslation();
|
||||
const t = useTranslationRef(userSettingsTranslationRef);
|
||||
const languageApi = useApi(appLanguageApiRef);
|
||||
const { t } = useTranslationRef(userSettingsTranslationRef);
|
||||
|
||||
const supportedLngs = useMemo(
|
||||
() => (i18n.options.supportedLngs || []).filter(lng => lng !== 'cimode'),
|
||||
[i18n],
|
||||
const [languageObservable] = useState(() => languageApi.language$());
|
||||
const { language: currentLanguage } = useObservable(
|
||||
languageObservable,
|
||||
languageApi.getLanguage(),
|
||||
);
|
||||
|
||||
if (supportedLngs.length <= 1) {
|
||||
const { languages } = languageApi.getAvailableLanguages();
|
||||
|
||||
if (languages.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -101,11 +108,7 @@ export const UserSettingsLanguageToggle = () => {
|
||||
_event: React.MouseEvent<HTMLElement>,
|
||||
newLanguage: string | undefined,
|
||||
) => {
|
||||
if (supportedLngs.some(it => it === newLanguage)) {
|
||||
i18n.changeLanguage(newLanguage);
|
||||
} else {
|
||||
i18n.changeLanguage(undefined);
|
||||
}
|
||||
languageApi.setLanguage(newLanguage);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -122,23 +125,17 @@ export const UserSettingsLanguageToggle = () => {
|
||||
<ToggleButtonGroup
|
||||
exclusive
|
||||
size="small"
|
||||
value={i18n.language}
|
||||
value={currentLanguage}
|
||||
onChange={handleSetLanguage}
|
||||
>
|
||||
{supportedLngs.map(lng => {
|
||||
{languages.map(language => {
|
||||
return (
|
||||
<TooltipToggleButton
|
||||
key={lng}
|
||||
title={t('select_lng', {
|
||||
language: lng,
|
||||
})}
|
||||
value={lng}
|
||||
key={language}
|
||||
title={t('select_lng', { language })}
|
||||
value={language}
|
||||
>
|
||||
<>
|
||||
{t('lng', {
|
||||
language: lng,
|
||||
})}
|
||||
</>
|
||||
<>{t('lng', { language })}</>
|
||||
</TooltipToggleButton>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -110,7 +110,7 @@ export const UserSettingsThemeToggle = () => {
|
||||
|
||||
const themeIds = appThemeApi.getInstalledThemes();
|
||||
|
||||
const t = useTranslationRef(userSettingsTranslationRef);
|
||||
const { t } = useTranslationRef(userSettingsTranslationRef);
|
||||
|
||||
const handleSetTheme = (
|
||||
_event: React.MouseEvent<HTMLElement>,
|
||||
|
||||
@@ -3305,7 +3305,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.14.6, @babel/runtime@npm:^7.15.4, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.18.6, @babel/runtime@npm:^7.19.4, @babel/runtime@npm:^7.2.0, @babel/runtime@npm:^7.20.1, @babel/runtime@npm:^7.20.13, @babel/runtime@npm:^7.20.6, @babel/runtime@npm:^7.20.7, @babel/runtime@npm:^7.21.0, @babel/runtime@npm:^7.22.10, @babel/runtime@npm:^7.3.1, @babel/runtime@npm:^7.4.4, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.6.0, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.3, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.8.7, @babel/runtime@npm:^7.9.2":
|
||||
"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.14.6, @babel/runtime@npm:^7.15.4, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.18.6, @babel/runtime@npm:^7.2.0, @babel/runtime@npm:^7.20.1, @babel/runtime@npm:^7.20.13, @babel/runtime@npm:^7.20.6, @babel/runtime@npm:^7.20.7, @babel/runtime@npm:^7.21.0, @babel/runtime@npm:^7.22.10, @babel/runtime@npm:^7.3.1, @babel/runtime@npm:^7.4.4, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.6.0, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.3, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.8.7, @babel/runtime@npm:^7.9.2":
|
||||
version: 7.22.11
|
||||
resolution: "@babel/runtime@npm:7.22.11"
|
||||
dependencies:
|
||||
@@ -3989,10 +3989,8 @@ __metadata:
|
||||
"@types/zen-observable": ^0.8.0
|
||||
history: ^5.0.0
|
||||
i18next: ^22.4.15
|
||||
i18next-browser-languagedetector: ^7.0.2
|
||||
msw: ^1.0.0
|
||||
prop-types: ^15.7.2
|
||||
react-i18next: ^12.3.1
|
||||
react-router-beta: "npm:react-router@6.0.0-beta.0"
|
||||
react-router-dom-beta: "npm:react-router-dom@6.0.0-beta.0"
|
||||
react-router-dom-stable: "npm:react-router-dom@^6.3.0"
|
||||
@@ -4224,7 +4222,6 @@ __metadata:
|
||||
i18next: ^22.4.15
|
||||
msw: ^1.0.0
|
||||
prop-types: ^15.7.2
|
||||
react-i18next: ^12.3.1
|
||||
zen-observable: ^0.10.0
|
||||
peerDependencies:
|
||||
react: ^16.13.1 || ^17.0.0
|
||||
@@ -9882,7 +9879,6 @@ __metadata:
|
||||
"@types/react": ^16.13.1 || ^17.0.0
|
||||
cross-fetch: ^3.1.5
|
||||
msw: ^1.0.0
|
||||
react-i18next: ^12.3.1
|
||||
react-use: ^17.2.4
|
||||
zen-observable: ^0.10.0
|
||||
peerDependencies:
|
||||
@@ -10066,6 +10062,7 @@ __metadata:
|
||||
"@testing-library/user-event": ^14.0.0
|
||||
"@types/react": ^16.13.1 || ^17.0.0
|
||||
cross-fetch: ^3.1.5
|
||||
i18next: ^22.4.15
|
||||
msw: ^1.0.0
|
||||
zen-observable: ^0.10.0
|
||||
peerDependencies:
|
||||
@@ -28287,15 +28284,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"html-parse-stringify@npm:^3.0.1":
|
||||
version: 3.0.1
|
||||
resolution: "html-parse-stringify@npm:3.0.1"
|
||||
dependencies:
|
||||
void-elements: 3.1.0
|
||||
checksum: 334fdebd4b5c355dba8e95284cead6f62bf865a2359da2759b039db58c805646350016d2017875718bc3c4b9bf81a0d11be5ee0cf4774a3a5a7b97cde21cfd67
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"html-webpack-plugin@npm:^5.3.1":
|
||||
version: 5.5.3
|
||||
resolution: "html-webpack-plugin@npm:5.5.3"
|
||||
@@ -28541,15 +28529,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"i18next-browser-languagedetector@npm:^7.0.2":
|
||||
version: 7.1.0
|
||||
resolution: "i18next-browser-languagedetector@npm:7.1.0"
|
||||
dependencies:
|
||||
"@babel/runtime": ^7.19.4
|
||||
checksum: 36981b9a9995ed66387f3735cceffe107ed3cdb6ca278d45fa243fabc65669c0eca095ed4a55a93dac046ca1eb23fd986ec0079723be7ebb8505e6ba25f379bb
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"i18next@npm:^22.4.15":
|
||||
version: 22.5.1
|
||||
resolution: "i18next@npm:22.5.1"
|
||||
@@ -37387,24 +37366,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-i18next@npm:^12.3.1":
|
||||
version: 12.3.1
|
||||
resolution: "react-i18next@npm:12.3.1"
|
||||
dependencies:
|
||||
"@babel/runtime": ^7.20.6
|
||||
html-parse-stringify: ^3.0.1
|
||||
peerDependencies:
|
||||
i18next: ">= 19.0.0"
|
||||
react: ">= 16.8.0"
|
||||
peerDependenciesMeta:
|
||||
react-dom:
|
||||
optional: true
|
||||
react-native:
|
||||
optional: true
|
||||
checksum: fe3f360e5184bc63861734e94bf625a09b9ec0d28fab41779a68758af258fd1737dde25ff7a88ddb66c1571a3e3de5b3403825a91b4949bf9832a00615acb87a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-immutable-proptypes@npm:2.2.0":
|
||||
version: 2.2.0
|
||||
resolution: "react-immutable-proptypes@npm:2.2.0"
|
||||
@@ -42747,13 +42708,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"void-elements@npm:3.1.0":
|
||||
version: 3.1.0
|
||||
resolution: "void-elements@npm:3.1.0"
|
||||
checksum: 0390f818107fa8fce55bb0a5c3f661056001c1d5a2a48c28d582d4d847347c2ab5b7f8272314cac58acf62345126b6b09bea623a185935f6b1c3bbce0dfd7f7f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"vscode-languageserver-types@npm:^3.15.1":
|
||||
version: 3.15.1
|
||||
resolution: "vscode-languageserver-types@npm:3.15.1"
|
||||
|
||||
Reference in New Issue
Block a user