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:
Patrik Oldsberg
2023-09-18 17:40:03 +02:00
committed by GitHub
39 changed files with 2133 additions and 677 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/test-utils': patch
---
Add a new `MockTranslationApi` as an `/alpha` export.
+31 -24
View File
@@ -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'),
+ },
+ }),
+ ],
+ },
...
})
})
```
+2 -2
View File
@@ -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>;
};
};
-2
View File
@@ -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"
@@ -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;
}
}
@@ -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';
@@ -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');
});
});
@@ -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);
}
}
@@ -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 &lt;div&gt;');
});
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,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);
}
}
}
@@ -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'],
},
});
+49 -29
View File
@@ -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
+2 -3
View File
@@ -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>;
};
};
+61 -9
View File
@@ -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)
```
-1
View File
@@ -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": {
@@ -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 };
};
+24
View File
@@ -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)
```
+1
View File
@@ -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": {
+1
View File
@@ -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 &lt;div&gt;');
});
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');
-1
View File
@@ -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>,
+2 -48
View File
@@ -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"