diff --git a/.changeset/tender-seals-yawn.md b/.changeset/tender-seals-yawn.md new file mode 100644 index 0000000000..c709d9b6fb --- /dev/null +++ b/.changeset/tender-seals-yawn.md @@ -0,0 +1,5 @@ +--- +'@backstage/test-utils': patch +--- + +Add a new `MockTranslationApi` as an `/alpha` export. diff --git a/docs/plugins/internationalization.md b/docs/plugins/internationalization.md index eac6ad85e2..9f816ea52b 100644 --- a/docs/plugins/internationalization.md +++ b/docs/plugins/internationalization.md @@ -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 ( - + + + ); ``` ## 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'), + }, + }), + ], + }, - ... -}) + }) ``` diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md index 1b5a5b4798..bd99874a71 100644 --- a/packages/core-app-api/api-report.md +++ b/packages/core-app-api/api-report.md @@ -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; }; }; diff --git a/packages/core-app-api/package.json b/packages/core-app-api/package.json index c63f64c1ce..99210bc361 100644 --- a/packages/core-app-api/package.json +++ b/packages/core-app-api/package.json @@ -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" diff --git a/packages/core-app-api/src/apis/implementations/AppLanguageApi/AppLanguageSelector.test.ts b/packages/core-app-api/src/apis/implementations/AppLanguageApi/AppLanguageSelector.test.ts new file mode 100644 index 0000000000..702d5e642a --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/AppLanguageApi/AppLanguageSelector.test.ts @@ -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(); + 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'", + ); + }); +}); diff --git a/packages/core-app-api/src/apis/implementations/AppLanguageApi/AppLanguageSelector.ts b/packages/core-app-api/src/apis/implementations/AppLanguageApi/AppLanguageSelector.ts new file mode 100644 index 0000000000..93f3d6517c --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/AppLanguageApi/AppLanguageSelector.ts @@ -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; + } +} diff --git a/packages/core-app-api/src/app/AppTranslationProvider.tsx b/packages/core-app-api/src/apis/implementations/AppLanguageApi/index.ts similarity index 55% rename from packages/core-app-api/src/app/AppTranslationProvider.tsx rename to packages/core-app-api/src/apis/implementations/AppLanguageApi/index.ts index 3d5e7e57e1..769bab2a31 100644 --- a/packages/core-app-api/src/app/AppTranslationProvider.tsx +++ b/packages/core-app-api/src/apis/implementations/AppLanguageApi/index.ts @@ -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 {children}; -} +export { + AppLanguageSelector, + type AppLanguageSelectorOptions, +} from './AppLanguageSelector'; diff --git a/packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.test.ts b/packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.test.ts deleted file mode 100644 index 5b7304d36b..0000000000 --- a/packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.test.ts +++ /dev/null @@ -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'); - }); -}); diff --git a/packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.ts b/packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.ts deleted file mode 100644 index c55c8bd7e8..0000000000 --- a/packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.ts +++ /dev/null @@ -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; -}; - -function removeNulls( - messages: Record, -): Record { - 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(); - private readonly lazyCache = new Map>(); - - 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 | 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); - } -} diff --git a/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.test.ts b/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.test.ts new file mode 100644 index 0000000000..2b96f4cc4f --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.test.ts @@ -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( + observable: Observable, + predicate?: (result: T) => boolean, +): Promise { + 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( + snapshot: TranslationSnapshot, +) { + 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(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(); + await new Promise(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: '
' })).toBe('Foo
'); + expect( + snapshot.t('foo', { + foo: '
', + 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'); + }); + }); +}); diff --git a/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.ts b/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.ts new file mode 100644 index 0000000000..49fb12de46 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.ts @@ -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; +} + +function removeNulls( + messages: Record, +): Record { + 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(); + /** Resource loading promises by loader key */ + #loading = new Map>(); + /** Loaders for each resource language */ + #loaders = new Map(); + + constructor( + private readonly onLoad: (loaded: { + language: string; + namespace: string; + messages: Record; + }) => 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 { + 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(); + /** 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( + translationRef: TranslationRef, + ): TranslationSnapshot { + const internalRef = toInternalTranslationRef(translationRef); + + this.#registerDefaults(internalRef); + + return this.#createSnapshot(internalRef); + } + + translation$( + translationRef: TranslationRef, + ): Observable> { + const internalRef = toInternalTranslationRef(translationRef); + + this.#registerDefaults(internalRef); + + return new ObservableImpl>(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( + internalRef: InternalTranslationRef, + ): TranslationSnapshot { + 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); + } + } +} diff --git a/packages/core-app-api/src/apis/implementations/AppTranslationApi/index.ts b/packages/core-app-api/src/apis/implementations/TranslationApi/index.ts similarity index 93% rename from packages/core-app-api/src/apis/implementations/AppTranslationApi/index.ts rename to packages/core-app-api/src/apis/implementations/TranslationApi/index.ts index 91d76413d8..7613d79495 100644 --- a/packages/core-app-api/src/apis/implementations/AppTranslationApi/index.ts +++ b/packages/core-app-api/src/apis/implementations/TranslationApi/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export * from './AppTranslationImpl'; +export * from './I18nextTranslationApi'; diff --git a/packages/core-app-api/src/app/AppManager.test.tsx b/packages/core-app-api/src/app/AppManager.test.tsx index 2eddcfc3ae..98ce0b93e2 100644 --- a/packages/core-app-api/src/app/AppManager.test.tsx +++ b/packages/core-app-api/src/app/AppManager.test.tsx @@ -276,7 +276,7 @@ describe('Integration Test', () => { }); }, __experimentalTranslations: { - supportedLanguages: ['en'], + availableLanguages: ['en', 'de'], }, }); diff --git a/packages/core-app-api/src/app/AppManager.tsx b/packages/core-app-api/src/app/AppManager.tsx index bd6a1ba3d0..7a941b7986 100644 --- a/packages/core-app-api/src/app/AppManager.tsx +++ b/packages/core-app-api/src/app/AppManager.tsx @@ -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; 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 ( - - - + + - - {children} - - - - + {children} + + + ); @@ -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 diff --git a/packages/core-app-api/src/app/types.ts b/packages/core-app-api/src/app/types.ts index 0adb93cfd1..f203ce9278 100644 --- a/packages/core-app-api/src/app/types.ts +++ b/packages/core-app-api/src/app/types.ts @@ -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; }; }; diff --git a/packages/core-plugin-api/alpha-api-report.md b/packages/core-plugin-api/alpha-api-report.md index f186c36b7f..d689184083 100644 --- a/packages/core-plugin-api/alpha-api-report.md +++ b/packages/core-plugin-api/alpha-api-report.md @@ -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; +export const appLanguageApiRef: ApiRef; // @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, + ): TranslationSnapshot; + translation$< + TMessages extends { + [key in string]: string; + }, + >( + translationRef: TranslationRef, + ): Observable>; +}; + +// @alpha (undocumented) +export const translationApiRef: ApiRef; + +// @alpha (undocumented) +export type TranslationFunction< + TMessages extends { + [key in string]: string; + }, +> = ( + 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; + }; + // @alpha export function usePluginOptions< TPluginOptions extends {} = {}, @@ -200,10 +253,9 @@ export const useTranslationRef: < }, >( translationRef: TranslationRef, -) => ( - key: TKey, - options?: TranslationOptions, -) => TMessages[TKey]; +) => { + t: TranslationFunction; +}; // (No @packageDocumentation comment for this package) ``` diff --git a/packages/core-plugin-api/package.json b/packages/core-plugin-api/package.json index 300caa7ef0..53e993f0cf 100644 --- a/packages/core-plugin-api/package.json +++ b/packages/core-plugin-api/package.json @@ -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": { diff --git a/packages/core-plugin-api/src/apis/definitions/AppTranslationApi.ts b/packages/core-plugin-api/src/apis/definitions/AppLanguageApi.ts similarity index 66% rename from packages/core-plugin-api/src/apis/definitions/AppTranslationApi.ts rename to packages/core-plugin-api/src/apis/definitions/AppLanguageApi.ts index 44f85c0c89..1207e84755 100644 --- a/packages/core-plugin-api/src/apis/definitions/AppTranslationApi.ts +++ b/packages/core-plugin-api/src/apis/definitions/AppLanguageApi.ts @@ -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 = createApiRef({ - id: 'core.translation', +export const appLanguageApiRef: ApiRef = createApiRef({ + id: 'core.applanguage', }); diff --git a/packages/core-plugin-api/src/apis/definitions/TranslationApi.ts b/packages/core-plugin-api/src/apis/definitions/TranslationApi.ts new file mode 100644 index 0000000000..a77d471945 --- /dev/null +++ b/packages/core-plugin-api/src/apis/definitions/TranslationApi.ts @@ -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 = + ( + key: TKey, + options?: TranslationOptions, + ) => TMessages[TKey]; + +/** @alpha */ +export type TranslationSnapshot = + { ready: false } | { ready: true; t: TranslationFunction }; + +/** @alpha */ +export type TranslationApi = { + getTranslation( + translationRef: TranslationRef, + ): TranslationSnapshot; + + translation$( + translationRef: TranslationRef, + ): Observable>; +}; + +/** + * @alpha + */ +export const translationApiRef: ApiRef = createApiRef({ + id: 'core.translation', +}); diff --git a/packages/core-plugin-api/src/apis/definitions/alpha.ts b/packages/core-plugin-api/src/apis/definitions/alpha.ts index 691af96e32..e3179fd4b2 100644 --- a/packages/core-plugin-api/src/apis/definitions/alpha.ts +++ b/packages/core-plugin-api/src/apis/definitions/alpha.ts @@ -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'; diff --git a/packages/core-plugin-api/src/translation/TranslationRef.ts b/packages/core-plugin-api/src/translation/TranslationRef.ts index 748473f92d..9ebf39a423 100644 --- a/packages/core-plugin-api/src/translation/TranslationRef.ts +++ b/packages/core-plugin-api/src/translation/TranslationRef.ts @@ -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): InternalTranslationRef { + const r = ref as InternalTranslationRef; if (r.$$type !== '@backstage/TranslationRef') { throw new Error(`Invalid translation ref, bad type '${r.$$type}'`); } diff --git a/packages/core-plugin-api/src/translation/TranslationResource.ts b/packages/core-plugin-api/src/translation/TranslationResource.ts index 50d10546cd..0184c55b98 100644 --- a/packages/core-plugin-api/src/translation/TranslationResource.ts +++ b/packages/core-plugin-api/src/translation/TranslationResource.ts @@ -25,13 +25,18 @@ export interface TranslationResource { id: TId; } +/** @internal */ +export type InternalTranslationResourceLoader = () => Promise<{ + messages: { [key in string]: string | null }; +}>; + /** @internal */ export interface InternalTranslationResource extends TranslationResource { version: 'v1'; resources: Array<{ language: string; - loader(): Promise<{ messages: { [key in string]: string | null } }>; + loader: InternalTranslationResourceLoader; }>; } diff --git a/packages/core-plugin-api/src/translation/index.ts b/packages/core-plugin-api/src/translation/index.ts index a03bf4b1f7..121ec62c72 100644 --- a/packages/core-plugin-api/src/translation/index.ts +++ b/packages/core-plugin-api/src/translation/index.ts @@ -29,7 +29,4 @@ export { type TranslationRefOptions, createTranslationRef, } from './TranslationRef'; -export { - type TranslationOptions, - useTranslationRef, -} from './useTranslationRef'; +export { useTranslationRef } from './useTranslationRef'; diff --git a/packages/core-plugin-api/src/translation/useTranslationRef.test.ts b/packages/core-plugin-api/src/translation/useTranslationRef.test.ts deleted file mode 100644 index 54e4440928..0000000000 --- a/packages/core-plugin-api/src/translation/useTranslationRef.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/packages/core-plugin-api/src/translation/useTranslationRef.test.tsx b/packages/core-plugin-api/src/translation/useTranslationRef.test.tsx new file mode 100644 index 0000000000..a8dbdc0402 --- /dev/null +++ b/packages/core-plugin-api/src/translation/useTranslationRef.test.tsx @@ -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 }) => ( + + ); +} + +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 }) => ( + + ), + initialProps: { translationRef: ref1 as TranslationRef }, + }, + ); + + expect(result.current.t('key')).toBe('default1'); + rerender({ translationRef: ref2 }); + expect(result.current.t('key')).toBe('default2'); + }); +}); diff --git a/packages/core-plugin-api/src/translation/useTranslationRef.ts b/packages/core-plugin-api/src/translation/useTranslationRef.ts index a11ab9c25b..b874ac93d8 100644 --- a/packages/core-plugin-api/src/translation/useTranslationRef.ts +++ b/packages/core-plugin-api/src/translation/useTranslationRef.ts @@ -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>(); /** @alpha */ export const useTranslationRef = < TMessages extends { [key in string]: string }, >( translationRef: TranslationRef, -) => { - const translationApi = useApi(appTranslationApiRef); +): { t: TranslationFunction } => { + const errorApi = useApi(errorApiRef); + const translationApi = useApi(translationApiRef); - const internalRef = toInternalTranslationRef(translationRef); - translationApi.addResource(translationRef); + const [snapshot, setSnapshot] = useState>(() => + 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 ( - 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(resolve => { + const subscription = observable.subscribe({ + next(next) { + if (next.ready) { + subscription.unsubscribe(); + resolve(); + } + }, + error(error) { + subscription.unsubscribe(); + onError(error); + resolve(); + }, + }); + }); + } + + return { t: snapshot.t }; }; diff --git a/packages/test-utils/alpha-api-report.md b/packages/test-utils/alpha-api-report.md index c37d79279c..4a9fd2e5a4 100644 --- a/packages/test-utils/alpha-api-report.md +++ b/packages/test-utils/alpha-api-report.md @@ -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, + ): TranslationSnapshot; + // (undocumented) + translation$< + TMessages extends { + [key in string]: string; + }, + >(): Observable>; +} + // (No @packageDocumentation comment for this package) ``` diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index ccfbb30a20..5a291d605d 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -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": { diff --git a/packages/test-utils/src/alpha.ts b/packages/test-utils/src/alpha.ts index 0b2a6a8d1c..c6801993bc 100644 --- a/packages/test-utils/src/alpha.ts +++ b/packages/test-utils/src/alpha.ts @@ -15,3 +15,4 @@ */ export * from './testUtils/MockPluginProvider'; +export * from './testUtils/apis/TranslationApi'; diff --git a/packages/test-utils/src/testUtils/apis/TranslationApi/MockTranslationApi.test.ts b/packages/test-utils/src/testUtils/apis/TranslationApi/MockTranslationApi.test.ts new file mode 100644 index 0000000000..6b6ef2eb13 --- /dev/null +++ b/packages/test-utils/src/testUtils/apis/TranslationApi/MockTranslationApi.test.ts @@ -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( + 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: '
' })).toBe('Foo
'); + expect( + snapshot.t('foo', { + foo: '
', + 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'); + }); +}); diff --git a/packages/test-utils/src/testUtils/apis/TranslationApi/MockTranslationApi.ts b/packages/test-utils/src/testUtils/apis/TranslationApi/MockTranslationApi.ts new file mode 100644 index 0000000000..0ffea6a1bd --- /dev/null +++ b/packages/test-utils/src/testUtils/apis/TranslationApi/MockTranslationApi.ts @@ -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(); + + private constructor(i18n: I18n) { + this.#i18n = i18n; + } + + getTranslation( + translationRef: TranslationRef, + ): TranslationSnapshot { + 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$(): Observable< + TranslationSnapshot + > { + // No need to implement, getTranslation will always return a ready snapshot + return new ObservableImpl>(_subscriber => { + return () => {}; + }); + } +} diff --git a/packages/test-utils/src/testUtils/apis/TranslationApi/index.ts b/packages/test-utils/src/testUtils/apis/TranslationApi/index.ts new file mode 100644 index 0000000000..2c10347545 --- /dev/null +++ b/packages/test-utils/src/testUtils/apis/TranslationApi/index.ts @@ -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'; diff --git a/packages/test-utils/src/testUtils/mockApis.ts b/packages/test-utils/src/testUtils/mockApis.ts index 0fbfdf5967..361d569b17 100644 --- a/packages/test-utils/src/testUtils/mockApis.ts +++ b/packages/test-utils/src/testUtils/mockApis.ts @@ -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()), ]; diff --git a/plugins/adr/src/components/EntityAdrContent/EntityAdrContent.tsx b/plugins/adr/src/components/EntityAdrContent/EntityAdrContent.tsx index 8081043a9f..b1abd4f02c 100644 --- a/plugins/adr/src/components/EntityAdrContent/EntityAdrContent.tsx +++ b/plugins/adr/src/components/EntityAdrContent/EntityAdrContent.tsx @@ -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'); diff --git a/plugins/user-settings/package.json b/plugins/user-settings/package.json index db2dad223a..1ca1929cfc 100644 --- a/plugins/user-settings/package.json +++ b/plugins/user-settings/package.json @@ -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" }, diff --git a/plugins/user-settings/src/components/General/UserSettingsLanguageToggle.test.tsx b/plugins/user-settings/src/components/General/UserSettingsLanguageToggle.test.tsx index 51b277c8f8..6e3a01c3c5 100644 --- a/plugins/user-settings/src/components/General/UserSettingsLanguageToggle.test.tsx +++ b/plugins/user-settings/src/components/General/UserSettingsLanguageToggle.test.tsx @@ -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 = { - 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(); - 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(); - - 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( + + + , + ); - (useTranslation as jest.Mock).mockReturnValue({ - i18n: i18nMock, - }); - - await renderInTestApp(); - - 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 = { - 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( + + + , ); - (useTranslation as jest.Mock).mockReturnValue({ - i18n: i18nMock, - }); + expect(screen.getByText('Change the language')).toBeInTheDocument(); await renderInTestApp(); - fireEvent.click(screen.getByText('French')); + fireEvent.click(screen.getByText('de')); - expect(i18nMock.changeLanguage).toHaveBeenCalledWith('fr'); + expect(mockLanguageApi.setLanguage).toHaveBeenCalledWith('de'); }); }); diff --git a/plugins/user-settings/src/components/General/UserSettingsLanguageToggle.tsx b/plugins/user-settings/src/components/General/UserSettingsLanguageToggle.tsx index 3c5772ab16..1f5bb025ee 100644 --- a/plugins/user-settings/src/components/General/UserSettingsLanguageToggle.tsx +++ b/plugins/user-settings/src/components/General/UserSettingsLanguageToggle.tsx @@ -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, 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 = () => { - {supportedLngs.map(lng => { + {languages.map(language => { return ( - <> - {t('lng', { - language: lng, - })} - + <>{t('lng', { language })} ); })} diff --git a/plugins/user-settings/src/components/General/UserSettingsThemeToggle.tsx b/plugins/user-settings/src/components/General/UserSettingsThemeToggle.tsx index 42490ec118..a97355eb07 100644 --- a/plugins/user-settings/src/components/General/UserSettingsThemeToggle.tsx +++ b/plugins/user-settings/src/components/General/UserSettingsThemeToggle.tsx @@ -110,7 +110,7 @@ export const UserSettingsThemeToggle = () => { const themeIds = appThemeApi.getInstalledThemes(); - const t = useTranslationRef(userSettingsTranslationRef); + const { t } = useTranslationRef(userSettingsTranslationRef); const handleSetTheme = ( _event: React.MouseEvent, diff --git a/yarn.lock b/yarn.lock index 0c66d7f245..704645ff70 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"