From 848491257a9a4528d12296b4ba18be3361847d8a Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 14 Sep 2023 14:39:58 +0200 Subject: [PATCH 01/33] core-plugin-api: make toInternalTranslationRef forward messages type Signed-off-by: Patrik Oldsberg --- .../core-plugin-api/src/translation/TranslationRef.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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}'`); } From db30db0df260d062ef7d0181bdd0fc35c6d09830 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 14 Sep 2023 14:40:31 +0200 Subject: [PATCH 02/33] core-plugin-api: extract InternalTranslationResourceLoader type Signed-off-by: Patrik Oldsberg --- .../core-plugin-api/src/translation/TranslationResource.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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; }>; } From e149123fa35b26c1f36735653010d9bf372d312b Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 14 Sep 2023 14:40:58 +0200 Subject: [PATCH 03/33] core-app-api: initial AppTranslation refactor with failing concurrency test Signed-off-by: Patrik Oldsberg --- .../AppTranslationImpl.test.ts | 357 +++++++++++++----- .../AppTranslationApi/AppTranslationImpl.ts | 353 +++++++++++------ 2 files changed, 495 insertions(+), 215 deletions(-) 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 index 5b7304d36b..4ea1f26329 100644 --- a/packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.test.ts +++ b/packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.test.ts @@ -19,123 +19,286 @@ import { createTranslationRef, createTranslationResource, } from '@backstage/core-plugin-api/alpha'; -import { AppTranslationApiImpl } from './AppTranslationImpl'; -import i18next from 'i18next'; +import { Observable } from '@backstage/types'; +import { + AppTranslationApiImpl, + TranslationSnapshot, +} from './AppTranslationImpl'; -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']), - }, +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(); + } }, - options: { - fallbackLng: 'en', - supportedLngs: ['zh', 'en'], + 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('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); + it('should get a translation snapshot', () => { + const translationApi = AppTranslationApiImpl.create(); + expect(translationApi.getAvailableLanguages()).toEqual(['en']); - 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); + const snapshot = assertReady(translationApi.getTranslation(plainRef)); + expect(snapshot.t('foo')).toBe('Foo'); }); - 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: '', - }, + it('should get a translation snapshot for ref with translations', async () => { + const translationApi = AppTranslationApiImpl.create({ + supportedLanguages: ['en', 'sv'], }); + expect(translationApi.getAvailableLanguages()).toEqual(['en', 'sv']); - 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); + expect(translationApi.getTranslation(resourceRef).ready).toBe(true); + await translationApi.changeLanguage('sv'); + expect(translationApi.getTranslation(resourceRef).ready).toBe(false); }); - it('should useResources correctly', () => { - const addLazyResourcesMock = jest.spyOn( - AppTranslationApiImpl.prototype, - 'addLazyResources', + it('should wait for translations to be loaded', async () => { + const translationApi = AppTranslationApiImpl.create({ + supportedLanguages: ['en', 'sv'], + }); + expect(translationApi.getTranslation(resourceRef).ready).toBe(true); + await translationApi.changeLanguage('sv'); + expect(translationApi.getTranslation(resourceRef).ready).toBe(false); + + const snapshot = assertReady( + await waitForNext(translationApi.translation$(resourceRef), s => s.ready), ); + expect(snapshot.t('foo')).toBe('Föö'); + }); - const ref = createTranslationRef({ - id: 'ref-id', - messages: { - key1: 'value1', - }, - translations: { - en: () => Promise.resolve({ default: { key1: 'value2' } }), - }, + it('should create an instance with message overrides', () => { + const translationApi = AppTranslationApiImpl.create({ + resources: [ + createTranslationMessages({ + ref: plainRef, + messages: { foo: 'Bar' }, + }), + ], + }); + const snapshot = assertReady(translationApi.getTranslation(plainRef)); + expect(snapshot.t('foo')).toBe('Bar'); + }); + + it('should create an instance and ignore null overrides', () => { + const translationApi = AppTranslationApiImpl.create({ + resources: [ + createTranslationMessages({ + ref: plainRef, + messages: { foo: null }, + }), + ], }); - const instance = AppTranslationApiImpl.create({ - supportedLanguages: ['en'], - }); - instance.addResource(ref); + const snapshot = assertReady(translationApi.getTranslation(plainRef)); + expect(snapshot.t('foo')).toBe('Foo'); + }); - expect(addLazyResourcesMock).toHaveBeenCalledTimes(1); - expect(addLazyResourcesMock.mock.calls[0][0].id).toBe('ref-id'); + it('should create an instance with translation resources', async () => { + const translationApi = AppTranslationApiImpl.create({ + supportedLanguages: ['en', 'sv'], + resources: [ + createTranslationResource({ + ref: plainRef, + translations: { + sv: () => Promise.resolve({ default: { foo: 'Föö' } }), + }, + }), + ], + }); + + await translationApi.changeLanguage('sv'); + + 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 translationApi = AppTranslationApiImpl.create({ + 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 prefer the last loaded resource', async () => { + const translationApi = AppTranslationApiImpl.create({ + supportedLanguages: ['en', 'sv'], + 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' }, + }), + }), + }, + }), + ], + }); + + await translationApi.changeLanguage('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 refuse switch on unsupported languages', async () => { + const translationApi = AppTranslationApiImpl.create({ + supportedLanguages: ['en', 'sv'], + }); + expect(translationApi.getAvailableLanguages()).toEqual(['en', 'sv']); + await translationApi.changeLanguage('sv'); + await expect(translationApi.changeLanguage('de')).rejects.toThrow( + "Failed to change language to 'de', available languages are 'en', 'sv", + ); + }); + + it('should forward loading errors', async () => { + const translationApi = AppTranslationApiImpl.create({ + resources: [ + createTranslationResource({ + ref: plainRef, + translations: { en: () => Promise.reject(new Error('NOPE')) }, + }), + ], + }); + + await expect( + waitForNext(translationApi.translation$(plainRef), s => s.ready), + ).rejects.toThrow('NOPE'); + }); + + it('should only call the loader once', async () => { + const loader = jest + .fn() + .mockResolvedValue({ default: { foo: 'OtherFoo' } }); + const translationApi = AppTranslationApiImpl.create({ + 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 delayedLoader = (msg: string) => () => + new Promise<{ default: { foo: string } }>(resolve => + setTimeout(() => resolve({ default: { foo: msg } }), 100), + ); + const translationApi = AppTranslationApiImpl.create({ + supportedLanguages: ['en', 'sv', 'no'], + resources: [ + createTranslationResource({ + ref: plainRef, + translations: { + en: delayedLoader('foo'), + sv: delayedLoader('Föö'), + no: delayedLoader('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'); + + translationApi.changeLanguage('sv'); + const nextPromise = waitForNext(translationApi.translation$(plainRef)); + translationApi.changeLanguage('no'); + + const snapshot = assertReady(await nextPromise); + expect(snapshot.t('foo')).toBe('Føø'); }); }); diff --git a/packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.ts b/packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.ts index c55c8bd7e8..0a7d1b8983 100644 --- a/packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.ts +++ b/packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.ts @@ -20,21 +20,30 @@ import { 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'; +import { + BackendModule, + createInstance as createI18n, + type 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 } from '../../../../../core-plugin-api/src/translation/TranslationResource'; +import { + toInternalTranslationResource, + InternalTranslationResourceLoader, +} 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'; +import { + toInternalTranslationRef, + InternalTranslationRef, +} from '../../../../../core-plugin-api/src/translation/TranslationRef'; +import { Observable } from '@backstage/types'; const DEFAULT_LANGUAGE = 'en'; /** @alpha */ export type ExperimentalI18n = { - fallbackLanguage?: string | string[]; supportedLanguages?: string[]; resources?: Array; }; @@ -49,140 +58,248 @@ function removeNulls( ); } +/** + * A wrapper around a i18next plugin that helps us lazy load resources that have been + * registered in the app or provided through translation refs. + * + * Since all resources are registered before use it is safe to just look at the + * existing resources when loading a namespace + language tuple. + */ +class LazyResources { + #seen = new Set(); + #loaders = new Map(); + + addResource(resource: TranslationResource) { + if (this.#seen.has(resource)) { + return; + } + this.#seen.add(resource); + 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); + } + } + } + + hasResource(lng: string, ns: string) { + return this.#loaders.has(this.#getLoaderKey(lng, ns)); + } + + async #loadResource(lng: string, ns: string) { + const loader = this.#loaders.get(this.#getLoaderKey(lng, ns)); + if (!loader) { + return undefined; + } + + return loader().then(result => removeNulls(result.messages)); + } + + #getLoaderKey(lng: string, ns: string) { + return `${lng}/${ns}`; + } + + plugin: BackendModule = { + type: 'backend', + init() {}, + read: (lng, ns) => this.#loadResource(lng, ns), + save() {}, + create() {}, + }; +} + +/** @alpha */ +export interface TranslationOptions { + /* no options supported for now */ +} + +export type TranslationSnapshot = + + | { ready: false } + | { + ready: true; + t( + key: TKey, + options?: TranslationOptions, + ): TMessages[TKey]; + }; + /** @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], + const languages = options?.supportedLanguages || [DEFAULT_LANGUAGE]; + if (!languages.includes(DEFAULT_LANGUAGE)) { + throw new Error(`Supported languages must include '${DEFAULT_LANGUAGE}'`); + } + const lazyResources = new LazyResources(); + const i18n = createI18n({ + fallbackLng: DEFAULT_LANGUAGE, + supportedLngs: languages, interpolation: { escapeValue: false, }, - react: { - bindI18n: 'loaded languageChanged', - }, - }); + ns: [], + defaultNS: false, + fallbackNS: false, + }).use(lazyResources.plugin); - return new AppTranslationApiImpl(i18n, options); - } + i18n.init(); - private readonly cache = new Set(); - private readonly lazyCache = new Map>(); - - getI18n() { - return this.i18n; - } - - initMessages(options?: ExperimentalI18n) { - for (const resource of options?.resources || []) { + 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') { - this.addLazyResources(resource); + lazyResources.addResource(resource); } else if (resource.$$type === '@backstage/TranslationMessages') { // Overrides for default messages, created with createTranslationMessages and installed via app - this.addMessages(resource); + i18n.addResourceBundle( + DEFAULT_LANGUAGE, + resource.id, + removeNulls(resource.messages), + true, + false, + ); } } + + return new AppTranslationApiImpl(i18n, lazyResources, languages); } - addResource(translationRef: TranslationRef): void { - const internalRef = toInternalTranslationRef(translationRef); - const defaultResource = internalRef.getDefaultResource(); - if (defaultResource) { - this.addLazyResources(defaultResource); - } + #i18n: i18n; + #lazyResources: LazyResources; + #language: string; + #languages: string[]; + + private constructor( + i18n: i18n, + lazyResources: LazyResources, + languages: string[], + ) { + this.#i18n = i18n; + this.#lazyResources = lazyResources; + this.#language = DEFAULT_LANGUAGE; + this.#languages = languages; } - 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, - ); + getAvailableLanguages(): string[] { + return this.#languages.slice(); } - 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, + async changeLanguage(language?: string): Promise { + const lng = language ?? DEFAULT_LANGUAGE; + if (lng && !this.#languages.includes(lng)) { + throw new Error( + `Failed to change language to '${lng}', available languages are '${this.#languages.join( + "', '", + )}'`, ); } + this.#language = lng; + await this.#i18n.changeLanguage(lng); } - private constructor(private readonly i18n: i18n, options?: ExperimentalI18n) { - this.initMessages(options); + getTranslation( + translationRef: TranslationRef, + ): TranslationSnapshot { + const internalRef = toInternalTranslationRef(translationRef); + + this.#registerDefaultResource(internalRef); + + return this.#createSnapshot(internalRef); + } + + translation$( + translationRef: TranslationRef, + ): Observable> { + const internalRef = toInternalTranslationRef(translationRef); + + this.#registerDefaultResource(internalRef); + + return new ObservableImpl>(subscriber => { + let loadTicket = {}; // To check for stale loads + let lastSnapshotWasReady = false; + + const loadResource = () => { + loadTicket = {}; + const ticket = loadTicket; + this.#i18n.loadNamespaces(internalRef.id, error => { + if (ticket !== loadTicket) { + return; + } + if (error) { + subscriber.error(Array.isArray(error) ? error[0] : error); + } else { + const snapshot = this.#createSnapshot(internalRef); + if (snapshot.ready || lastSnapshotWasReady) { + lastSnapshotWasReady = snapshot.ready; + subscriber.next(snapshot); + } + } + }); + }; + + const onChange = () => { + const snapshot = this.#createSnapshot(internalRef); + if (lastSnapshotWasReady && !snapshot.ready) { + subscriber.next(snapshot); + } + + if (!snapshot.ready) { + loadResource(); + } + }; + + this.#i18n.on('initialized', onChange); + this.#i18n.on('languageChanged', onChange); + + if (this.#needsToLoadResource(internalRef)) { + loadResource(); + } + + return () => { + this.#i18n.off('initialized', onChange); + this.#i18n.off('languageChanged', onChange); + }; + }); + } + + #createSnapshot( + internalRef: InternalTranslationRef, + ): TranslationSnapshot { + if (this.#needsToLoadResource(internalRef)) { + return { ready: false }; + } + + const t = this.#i18n.getFixedT(null, internalRef.id); + const defaultMessages = internalRef.getDefaultMessages() as TMessages; + + return { + ready: true, + t: (key, options) => { + return t(key as string, { + ...options, + defaultValue: defaultMessages[key], + }); + }, + }; + } + + #needsToLoadResource({ id }: InternalTranslationRef): boolean { + if (!this.#lazyResources.hasResource(this.#language, id)) { + return false; + } + return !this.#i18n.hasResourceBundle(this.#language, id); + } + + #registerDefaultResource(internalRef: InternalTranslationRef): void { + const defaultResource = internalRef.getDefaultResource(); + if (defaultResource) { + this.#lazyResources.addResource(defaultResource); + } } } From a76aaf87bfe4779dd86d7acfaf7817aa3aede982 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 14 Sep 2023 15:24:29 +0200 Subject: [PATCH 04/33] core-app-api: implement custom resource loader for i18n Signed-off-by: Patrik Oldsberg --- .../AppTranslationImpl.test.ts | 49 +++++- .../AppTranslationApi/AppTranslationImpl.ts | 164 +++++++++++------- 2 files changed, 139 insertions(+), 74 deletions(-) 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 index 4ea1f26329..33a119faaa 100644 --- a/packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.test.ts +++ b/packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.test.ts @@ -270,19 +270,15 @@ describe('AppTranslationApiImpl', () => { }); it('should handle interrupted loads gracefully', async () => { - const delayedLoader = (msg: string) => () => - new Promise<{ default: { foo: string } }>(resolve => - setTimeout(() => resolve({ default: { foo: msg } }), 100), - ); const translationApi = AppTranslationApiImpl.create({ supportedLanguages: ['en', 'sv', 'no'], resources: [ createTranslationResource({ ref: plainRef, translations: { - en: delayedLoader('foo'), - sv: delayedLoader('Föö'), - no: delayedLoader('Føø'), + en: () => Promise.resolve({ default: { foo: 'foo' } }), + sv: () => Promise.resolve({ default: { foo: 'Föö' } }), + no: () => Promise.resolve({ default: { foo: 'Føø' } }), }, }), ], @@ -301,4 +297,43 @@ describe('AppTranslationApiImpl', () => { const snapshot = assertReady(await nextPromise); expect(snapshot.t('foo')).toBe('Føø'); }); + + it('should only emit changes', async () => { + const translationApi = AppTranslationApiImpl.create({ + supportedLanguages: ['en', 'dk', 'sv', 'no'], + 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') { + translationApi.changeLanguage('dk'); // Not visible + translationApi.changeLanguage('sv'); + } else if (translation === 'Föö') { + translationApi.changeLanguage('no'); + } else if (translation === 'Føø') { + resolve(); + subscription.unsubscribe(); + } + }, + }); + }); + + expect(translations).toEqual(['foo', null, 'Föö', null, 'Føø']); + }); }); diff --git a/packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.ts b/packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.ts index 0a7d1b8983..67b82d3998 100644 --- a/packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.ts +++ b/packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.ts @@ -20,11 +20,7 @@ import { TranslationRef, TranslationResource, } from '@backstage/core-plugin-api/alpha'; -import { - BackendModule, - createInstance as createI18n, - type i18n, -} from 'i18next'; +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 @@ -59,17 +55,30 @@ function removeNulls( } /** - * A wrapper around a i18next plugin that helps us lazy load resources that have been - * registered in the app or provided through translation refs. - * - * Since all resources are registered before use it is safe to just look at the - * existing resources when loading a namespace + language tuple. + * 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 LazyResources { +class ResourceLoader { + /** The resources that have been registered */ #seen = new Set(); + + /** 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(); - addResource(resource: TranslationResource) { + constructor( + private readonly onLoad: (loaded: { + language: string; + namespace: string; + messages: Record; + }) => void, + ) {} + + addTranslationResource(resource: TranslationResource) { if (this.#seen.has(resource)) { return; } @@ -86,30 +95,45 @@ class LazyResources { } } - hasResource(lng: string, ns: string) { - return this.#loaders.has(this.#getLoaderKey(lng, ns)); + #getLoaderKey(language: string, namespace: string) { + return `${language}/${namespace}`; } - async #loadResource(lng: string, ns: string) { - const loader = this.#loaders.get(this.#getLoaderKey(lng, ns)); + needsLoading(language: string, namespace: string) { + const key = this.#getLoaderKey(language, namespace); + const loader = this.#loaders.get(key); if (!loader) { - return undefined; + return false; } - return loader().then(result => removeNulls(result.messages)); + return !this.#loaded.has(key); } - #getLoaderKey(lng: string, ns: string) { - return `${lng}/${ns}`; - } + async load(language: string, namespace: string): Promise { + const key = this.#getLoaderKey(language, namespace); - plugin: BackendModule = { - type: 'backend', - init() {}, - read: (lng, ns) => this.#loadResource(lng, ns), - save() {}, - create() {}, - }; + 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); + }); + this.#loading.set(key, load); + await load; + } } /** @alpha */ @@ -135,7 +159,6 @@ export class AppTranslationApiImpl implements AppTranslationApi { if (!languages.includes(DEFAULT_LANGUAGE)) { throw new Error(`Supported languages must include '${DEFAULT_LANGUAGE}'`); } - const lazyResources = new LazyResources(); const i18n = createI18n({ fallbackLng: DEFAULT_LANGUAGE, supportedLngs: languages, @@ -145,43 +168,49 @@ export class AppTranslationApiImpl implements AppTranslationApi { ns: [], defaultNS: false, fallbackNS: false, - }).use(lazyResources.plugin); + }); i18n.init(); + 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') { - lazyResources.addResource(resource); + 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, - false, + true, // merge with existing translations + false, // do not overwrite translations ); } } - return new AppTranslationApiImpl(i18n, lazyResources, languages); + return new AppTranslationApiImpl(i18n, loader, languages); } - #i18n: i18n; - #lazyResources: LazyResources; + #i18n: I18n; + #loader: ResourceLoader; #language: string; #languages: string[]; - private constructor( - i18n: i18n, - lazyResources: LazyResources, - languages: string[], - ) { + private constructor(i18n: I18n, loader: ResourceLoader, languages: string[]) { this.#i18n = i18n; - this.#lazyResources = lazyResources; + this.#loader = loader; this.#language = DEFAULT_LANGUAGE; this.#languages = languages; } @@ -227,25 +256,28 @@ export class AppTranslationApiImpl implements AppTranslationApi { const loadResource = () => { loadTicket = {}; const ticket = loadTicket; - this.#i18n.loadNamespaces(internalRef.id, error => { - if (ticket !== loadTicket) { - return; - } - if (error) { - subscriber.error(Array.isArray(error) ? error[0] : error); - } else { - const snapshot = this.#createSnapshot(internalRef); - if (snapshot.ready || lastSnapshotWasReady) { - lastSnapshotWasReady = snapshot.ready; - subscriber.next(snapshot); + 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); } @@ -254,15 +286,20 @@ export class AppTranslationApiImpl implements AppTranslationApi { } }; - this.#i18n.on('initialized', onChange); + const wasInitialized = this.#i18n.isInitialized; + if (!wasInitialized) { + this.#i18n.on('initialized', onChange); + } this.#i18n.on('languageChanged', onChange); - if (this.#needsToLoadResource(internalRef)) { + if (this.#loader.needsLoading(this.#language, internalRef.id)) { loadResource(); } return () => { - this.#i18n.off('initialized', onChange); + if (!wasInitialized) { + this.#i18n.off('initialized', onChange); + } this.#i18n.off('languageChanged', onChange); }; }); @@ -271,7 +308,7 @@ export class AppTranslationApiImpl implements AppTranslationApi { #createSnapshot( internalRef: InternalTranslationRef, ): TranslationSnapshot { - if (this.#needsToLoadResource(internalRef)) { + if (this.#loader.needsLoading(this.#language, internalRef.id)) { return { ready: false }; } @@ -289,17 +326,10 @@ export class AppTranslationApiImpl implements AppTranslationApi { }; } - #needsToLoadResource({ id }: InternalTranslationRef): boolean { - if (!this.#lazyResources.hasResource(this.#language, id)) { - return false; - } - return !this.#i18n.hasResourceBundle(this.#language, id); - } - #registerDefaultResource(internalRef: InternalTranslationRef): void { const defaultResource = internalRef.getDefaultResource(); if (defaultResource) { - this.#lazyResources.addResource(defaultResource); + this.#loader.addTranslationResource(defaultResource); } } } From 1e845085920a2ea2757e7352cf4bd75249ed338d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 14 Sep 2023 15:33:48 +0200 Subject: [PATCH 05/33] core-plugin-api: split AppTranslationApi into AppLanguageApi and TranslationApi Signed-off-by: Patrik Oldsberg --- .../AppTranslationApi/AppTranslationImpl.ts | 16 ------ ...AppTranslationApi.ts => AppLanguageApi.ts} | 17 ++++--- .../src/apis/definitions/TranslationApi.ts | 51 +++++++++++++++++++ .../src/apis/definitions/alpha.ts | 3 +- 4 files changed, 63 insertions(+), 24 deletions(-) rename packages/core-plugin-api/src/apis/definitions/{AppTranslationApi.ts => AppLanguageApi.ts} (66%) create mode 100644 packages/core-plugin-api/src/apis/definitions/TranslationApi.ts diff --git a/packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.ts b/packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.ts index 67b82d3998..018e363140 100644 --- a/packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.ts +++ b/packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.ts @@ -136,22 +136,6 @@ class ResourceLoader { } } -/** @alpha */ -export interface TranslationOptions { - /* no options supported for now */ -} - -export type TranslationSnapshot = - - | { ready: false } - | { - ready: true; - t( - key: TKey, - options?: TranslationOptions, - ): TMessages[TKey]; - }; - /** @alpha */ export class AppTranslationApiImpl implements AppTranslationApi { static create(options?: ExperimentalI18n) { 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..10a9c86b11 --- /dev/null +++ b/packages/core-plugin-api/src/apis/definitions/TranslationApi.ts @@ -0,0 +1,51 @@ +/* + * 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 */ +} + +export type TranslationFunction = + ( + key: TKey, + options?: TranslationOptions, + ) => TMessages[TKey]; + +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..fa28aef46a 100644 --- a/packages/core-plugin-api/src/apis/definitions/alpha.ts +++ b/packages/core-plugin-api/src/apis/definitions/alpha.ts @@ -13,4 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export * from './AppTranslationApi'; +export * from './TranslationApi'; +export * from './AppLanguageApi'; From 49c352603bbc9e0d9a112f618e879dae5410d92d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 14 Sep 2023 17:13:20 +0200 Subject: [PATCH 06/33] core-plugin-api: add AppLanguageApi implementation Signed-off-by: Patrik Oldsberg --- .../AppLanguageSelector.test.ts | 154 ++++++++++++++++++ .../AppLanguageApi/AppLanguageSelector.ts | 122 ++++++++++++++ .../implementations/AppLanguageApi/index.ts | 20 +++ 3 files changed, 296 insertions(+) create mode 100644 packages/core-app-api/src/apis/implementations/AppLanguageApi/AppLanguageSelector.test.ts create mode 100644 packages/core-app-api/src/apis/implementations/AppLanguageApi/AppLanguageSelector.ts create mode 100644 packages/core-app-api/src/apis/implementations/AppLanguageApi/index.ts 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..e24ccee0d2 --- /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.createWithStorage(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.createWithStorage({ + 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.createWithStorage({ + 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..17bffdb9ab --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/AppLanguageApi/AppLanguageSelector.ts @@ -0,0 +1,122 @@ +/* + * 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'; +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 createWithStorage(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}'`); + } + + let initialLanguage = languages[0]; + + if (!window.localStorage) { + return new AppLanguageSelector(languages, initialLanguage); + } + + const storedLanguage = + window.localStorage.getItem(STORAGE_KEY) ?? undefined; + if (storedLanguage && languages.includes(storedLanguage)) { + initialLanguage = storedLanguage; + } + + const selector = new AppLanguageSelector(languages, initialLanguage); + + 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: initialLanguage, + }); + } + + 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/apis/implementations/AppLanguageApi/index.ts b/packages/core-app-api/src/apis/implementations/AppLanguageApi/index.ts new file mode 100644 index 0000000000..769bab2a31 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/AppLanguageApi/index.ts @@ -0,0 +1,20 @@ +/* + * 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 { + AppLanguageSelector, + type AppLanguageSelectorOptions, +} from './AppLanguageSelector'; From 207f17acea8c03476f21b285804b5f1c44b1fc10 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 14 Sep 2023 17:17:59 +0200 Subject: [PATCH 07/33] core-app-api: rename AppTranslationApi to I18nextTranslationApi + initial updates Signed-off-by: Patrik Oldsberg --- .../I18nextTranslationApi.test.ts} | 34 +++++++++---------- .../I18nextTranslationApi.ts} | 7 ++-- .../index.ts | 2 +- 3 files changed, 21 insertions(+), 22 deletions(-) rename packages/core-app-api/src/apis/implementations/{AppTranslationApi/AppTranslationImpl.test.ts => TranslationApi/I18nextTranslationApi.test.ts} (91%) rename packages/core-app-api/src/apis/implementations/{AppTranslationApi/AppTranslationImpl.ts => TranslationApi/I18nextTranslationApi.ts} (98%) rename packages/core-app-api/src/apis/implementations/{AppTranslationApi => TranslationApi}/index.ts (93%) diff --git a/packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.test.ts b/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.test.ts similarity index 91% rename from packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.test.ts rename to packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.test.ts index 33a119faaa..bf503c0a94 100644 --- a/packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.test.ts +++ b/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.test.ts @@ -18,12 +18,10 @@ import { createTranslationMessages, createTranslationRef, createTranslationResource, + TranslationSnapshot, } from '@backstage/core-plugin-api/alpha'; import { Observable } from '@backstage/types'; -import { - AppTranslationApiImpl, - TranslationSnapshot, -} from './AppTranslationImpl'; +import { I18nextTranslationApi } from './I18nextTranslationApi'; const plainRef = createTranslationRef({ id: 'plain', @@ -74,13 +72,13 @@ function assertReady( return snapshot; } -describe('AppTranslationApiImpl', () => { +describe('I18nextTranslationApi', () => { afterEach(() => { jest.clearAllMocks(); }); it('should get a translation snapshot', () => { - const translationApi = AppTranslationApiImpl.create(); + const translationApi = I18nextTranslationApi.create(); expect(translationApi.getAvailableLanguages()).toEqual(['en']); const snapshot = assertReady(translationApi.getTranslation(plainRef)); @@ -88,7 +86,7 @@ describe('AppTranslationApiImpl', () => { }); it('should get a translation snapshot for ref with translations', async () => { - const translationApi = AppTranslationApiImpl.create({ + const translationApi = I18nextTranslationApi.create({ supportedLanguages: ['en', 'sv'], }); expect(translationApi.getAvailableLanguages()).toEqual(['en', 'sv']); @@ -99,7 +97,7 @@ describe('AppTranslationApiImpl', () => { }); it('should wait for translations to be loaded', async () => { - const translationApi = AppTranslationApiImpl.create({ + const translationApi = I18nextTranslationApi.create({ supportedLanguages: ['en', 'sv'], }); expect(translationApi.getTranslation(resourceRef).ready).toBe(true); @@ -113,7 +111,7 @@ describe('AppTranslationApiImpl', () => { }); it('should create an instance with message overrides', () => { - const translationApi = AppTranslationApiImpl.create({ + const translationApi = I18nextTranslationApi.create({ resources: [ createTranslationMessages({ ref: plainRef, @@ -126,7 +124,7 @@ describe('AppTranslationApiImpl', () => { }); it('should create an instance and ignore null overrides', () => { - const translationApi = AppTranslationApiImpl.create({ + const translationApi = I18nextTranslationApi.create({ resources: [ createTranslationMessages({ ref: plainRef, @@ -140,7 +138,7 @@ describe('AppTranslationApiImpl', () => { }); it('should create an instance with translation resources', async () => { - const translationApi = AppTranslationApiImpl.create({ + const translationApi = I18nextTranslationApi.create({ supportedLanguages: ['en', 'sv'], resources: [ createTranslationResource({ @@ -163,7 +161,7 @@ describe('AppTranslationApiImpl', () => { }); it('should wait for default language translations to be loaded', async () => { - const translationApi = AppTranslationApiImpl.create({ + const translationApi = I18nextTranslationApi.create({ resources: [ createTranslationResource({ ref: plainRef, @@ -181,7 +179,7 @@ describe('AppTranslationApiImpl', () => { }); it('should prefer the last loaded resource', async () => { - const translationApi = AppTranslationApiImpl.create({ + const translationApi = I18nextTranslationApi.create({ supportedLanguages: ['en', 'sv'], resources: [ createTranslationResource({ @@ -216,7 +214,7 @@ describe('AppTranslationApiImpl', () => { }); it('should refuse switch on unsupported languages', async () => { - const translationApi = AppTranslationApiImpl.create({ + const translationApi = I18nextTranslationApi.create({ supportedLanguages: ['en', 'sv'], }); expect(translationApi.getAvailableLanguages()).toEqual(['en', 'sv']); @@ -227,7 +225,7 @@ describe('AppTranslationApiImpl', () => { }); it('should forward loading errors', async () => { - const translationApi = AppTranslationApiImpl.create({ + const translationApi = I18nextTranslationApi.create({ resources: [ createTranslationResource({ ref: plainRef, @@ -245,7 +243,7 @@ describe('AppTranslationApiImpl', () => { const loader = jest .fn() .mockResolvedValue({ default: { foo: 'OtherFoo' } }); - const translationApi = AppTranslationApiImpl.create({ + const translationApi = I18nextTranslationApi.create({ resources: [ createTranslationResource({ ref: plainRef, @@ -270,7 +268,7 @@ describe('AppTranslationApiImpl', () => { }); it('should handle interrupted loads gracefully', async () => { - const translationApi = AppTranslationApiImpl.create({ + const translationApi = I18nextTranslationApi.create({ supportedLanguages: ['en', 'sv', 'no'], resources: [ createTranslationResource({ @@ -299,7 +297,7 @@ describe('AppTranslationApiImpl', () => { }); it('should only emit changes', async () => { - const translationApi = AppTranslationApiImpl.create({ + const translationApi = I18nextTranslationApi.create({ supportedLanguages: ['en', 'dk', 'sv', 'no'], resources: [ createTranslationResource({ diff --git a/packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.ts b/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.ts similarity index 98% rename from packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.ts rename to packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.ts index 018e363140..2a5b570300 100644 --- a/packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.ts +++ b/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.ts @@ -15,10 +15,11 @@ */ import { - AppTranslationApi, + 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'; @@ -137,7 +138,7 @@ class ResourceLoader { } /** @alpha */ -export class AppTranslationApiImpl implements AppTranslationApi { +export class I18nextTranslationApi implements TranslationApi { static create(options?: ExperimentalI18n) { const languages = options?.supportedLanguages || [DEFAULT_LANGUAGE]; if (!languages.includes(DEFAULT_LANGUAGE)) { @@ -184,7 +185,7 @@ export class AppTranslationApiImpl implements AppTranslationApi { } } - return new AppTranslationApiImpl(i18n, loader, languages); + return new I18nextTranslationApi(i18n, loader, languages); } #i18n: I18n; 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'; From 7bb7a0fa1d309b9c68dc0d37dbf60db41a15a770 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 14 Sep 2023 17:23:01 +0200 Subject: [PATCH 08/33] core-app-api: AppLanguageSelector tweaks to allow creation without storage Signed-off-by: Patrik Oldsberg --- .../AppLanguageSelector.test.ts | 6 ++--- .../AppLanguageApi/AppLanguageSelector.ts | 27 ++++++++++--------- 2 files changed, 17 insertions(+), 16 deletions(-) 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 index e24ccee0d2..702d5e642a 100644 --- a/packages/core-app-api/src/apis/implementations/AppLanguageApi/AppLanguageSelector.test.ts +++ b/packages/core-app-api/src/apis/implementations/AppLanguageApi/AppLanguageSelector.test.ts @@ -26,7 +26,7 @@ describe('AppLanguageSelector', () => { }); it('should select language', async () => { - const selector = AppLanguageSelector.createWithStorage(baseOptions); + const selector = AppLanguageSelector.create(baseOptions); expect(selector.getAvailableLanguages()).toEqual({ languages: ['en', 'de'], @@ -49,7 +49,7 @@ describe('AppLanguageSelector', () => { it('should return a new array of languages', () => { const languages = ['en', 'de']; - const selector = AppLanguageSelector.createWithStorage({ + const selector = AppLanguageSelector.create({ availableLanguages: languages, }); @@ -65,7 +65,7 @@ describe('AppLanguageSelector', () => { it('should skip duplicates', async () => { const languages = ['en', 'de']; - const selector = AppLanguageSelector.createWithStorage({ + const selector = AppLanguageSelector.create({ availableLanguages: languages, }); diff --git a/packages/core-app-api/src/apis/implementations/AppLanguageApi/AppLanguageSelector.ts b/packages/core-app-api/src/apis/implementations/AppLanguageApi/AppLanguageSelector.ts index 17bffdb9ab..9e69554412 100644 --- a/packages/core-app-api/src/apis/implementations/AppLanguageApi/AppLanguageSelector.ts +++ b/packages/core-app-api/src/apis/implementations/AppLanguageApi/AppLanguageSelector.ts @@ -25,7 +25,6 @@ const DEFAULT_LANGUAGE = 'en'; /** @alpha */ export interface AppLanguageSelectorOptions { - defaultLanguage?: string; availableLanguages?: string[]; } @@ -35,8 +34,8 @@ export interface AppLanguageSelectorOptions { * @alpha */ export class AppLanguageSelector implements AppLanguageApi { - static createWithStorage(options: AppLanguageSelectorOptions) { - const languages = options.availableLanguages ?? [DEFAULT_LANGUAGE]; + 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( @@ -48,20 +47,22 @@ export class AppLanguageSelector implements AppLanguageApi { throw new Error(`Supported languages must include '${DEFAULT_LANGUAGE}'`); } - let initialLanguage = languages[0]; + return new AppLanguageSelector(languages); + } + + static createWithStorage(options?: AppLanguageSelectorOptions) { + const selector = AppLanguageSelector.create(options); if (!window.localStorage) { - return new AppLanguageSelector(languages, initialLanguage); + return selector; } - const storedLanguage = - window.localStorage.getItem(STORAGE_KEY) ?? undefined; + const storedLanguage = window.localStorage.getItem(STORAGE_KEY); + const { languages } = selector.getAvailableLanguages(); if (storedLanguage && languages.includes(storedLanguage)) { - initialLanguage = storedLanguage; + selector.setLanguage(storedLanguage); } - const selector = new AppLanguageSelector(languages, initialLanguage); - selector.language$().subscribe(({ language }) => { if (language !== window.localStorage.getItem(STORAGE_KEY)) { window.localStorage.setItem(STORAGE_KEY, language); @@ -84,11 +85,11 @@ export class AppLanguageSelector implements AppLanguageApi { #language: string; #subject: BehaviorSubject<{ language: string }>; - private constructor(languages: string[], initialLanguage: string) { + private constructor(languages: string[]) { this.#languages = languages; - this.#language = initialLanguage; + this.#language = languages[0]; this.#subject = new BehaviorSubject<{ language: string }>({ - language: initialLanguage, + language: this.#language, }); } From 03bd6ee6360951e734f8b625a5d5bc9e7d15accf Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 14 Sep 2023 17:38:19 +0200 Subject: [PATCH 09/33] core-app-api: refactor TranslationApi to use AppLanguageApi Signed-off-by: Patrik Oldsberg --- .../AppLanguageApi/AppLanguageSelector.ts | 2 +- .../I18nextTranslationApi.test.ts | 79 +++++++++++-------- .../TranslationApi/I18nextTranslationApi.ts | 60 +++++++------- 3 files changed, 77 insertions(+), 64 deletions(-) diff --git a/packages/core-app-api/src/apis/implementations/AppLanguageApi/AppLanguageSelector.ts b/packages/core-app-api/src/apis/implementations/AppLanguageApi/AppLanguageSelector.ts index 9e69554412..fa906c4337 100644 --- a/packages/core-app-api/src/apis/implementations/AppLanguageApi/AppLanguageSelector.ts +++ b/packages/core-app-api/src/apis/implementations/AppLanguageApi/AppLanguageSelector.ts @@ -21,7 +21,7 @@ import { Observable } from '@backstage/types'; import { BehaviorSubject } from '../../../lib'; const STORAGE_KEY = 'language'; -const DEFAULT_LANGUAGE = 'en'; +export const DEFAULT_LANGUAGE = 'en'; /** @alpha */ export interface AppLanguageSelectorOptions { 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 index bf503c0a94..2dd9f2cd56 100644 --- a/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.test.ts +++ b/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.test.ts @@ -21,6 +21,7 @@ import { TranslationSnapshot, } from '@backstage/core-plugin-api/alpha'; import { Observable } from '@backstage/types'; +import { AppLanguageSelector } from '../AppLanguageApi'; import { I18nextTranslationApi } from './I18nextTranslationApi'; const plainRef = createTranslationRef({ @@ -78,30 +79,34 @@ describe('I18nextTranslationApi', () => { }); it('should get a translation snapshot', () => { - const translationApi = I18nextTranslationApi.create(); - expect(translationApi.getAvailableLanguages()).toEqual(['en']); + 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 translationApi = I18nextTranslationApi.create({ - supportedLanguages: ['en', 'sv'], + const languageApi = AppLanguageSelector.create({ + availableLanguages: ['en', 'sv'], }); - expect(translationApi.getAvailableLanguages()).toEqual(['en', 'sv']); + const translationApi = I18nextTranslationApi.create({ languageApi }); expect(translationApi.getTranslation(resourceRef).ready).toBe(true); - await translationApi.changeLanguage('sv'); + languageApi.setLanguage('sv'); + await 'a tick'; expect(translationApi.getTranslation(resourceRef).ready).toBe(false); }); it('should wait for translations to be loaded', async () => { - const translationApi = I18nextTranslationApi.create({ - supportedLanguages: ['en', 'sv'], + const languageApi = AppLanguageSelector.create({ + availableLanguages: ['en', 'sv'], }); + const translationApi = I18nextTranslationApi.create({ languageApi }); expect(translationApi.getTranslation(resourceRef).ready).toBe(true); - await translationApi.changeLanguage('sv'); + languageApi.setLanguage('sv'); + await 'a tick'; expect(translationApi.getTranslation(resourceRef).ready).toBe(false); const snapshot = assertReady( @@ -111,7 +116,9 @@ describe('I18nextTranslationApi', () => { }); it('should create an instance with message overrides', () => { + const languageApi = AppLanguageSelector.create(); const translationApi = I18nextTranslationApi.create({ + languageApi, resources: [ createTranslationMessages({ ref: plainRef, @@ -124,7 +131,9 @@ describe('I18nextTranslationApi', () => { }); it('should create an instance and ignore null overrides', () => { + const languageApi = AppLanguageSelector.create(); const translationApi = I18nextTranslationApi.create({ + languageApi, resources: [ createTranslationMessages({ ref: plainRef, @@ -138,8 +147,11 @@ describe('I18nextTranslationApi', () => { }); it('should create an instance with translation resources', async () => { + const languageApi = AppLanguageSelector.create({ + availableLanguages: ['en', 'sv'], + }); const translationApi = I18nextTranslationApi.create({ - supportedLanguages: ['en', 'sv'], + languageApi, resources: [ createTranslationResource({ ref: plainRef, @@ -150,7 +162,8 @@ describe('I18nextTranslationApi', () => { ], }); - await translationApi.changeLanguage('sv'); + languageApi.setLanguage('sv'); + await 'a tick'; expect(translationApi.getTranslation(plainRef).ready).toBe(false); @@ -161,7 +174,9 @@ describe('I18nextTranslationApi', () => { }); it('should wait for default language translations to be loaded', async () => { + const languageApi = AppLanguageSelector.create(); const translationApi = I18nextTranslationApi.create({ + languageApi, resources: [ createTranslationResource({ ref: plainRef, @@ -179,8 +194,11 @@ describe('I18nextTranslationApi', () => { }); it('should prefer the last loaded resource', async () => { + const languageApi = AppLanguageSelector.create({ + availableLanguages: ['en', 'sv'], + }); const translationApi = I18nextTranslationApi.create({ - supportedLanguages: ['en', 'sv'], + languageApi, resources: [ createTranslationResource({ ref: resourceRef, @@ -204,7 +222,7 @@ describe('I18nextTranslationApi', () => { ], }); - await translationApi.changeLanguage('sv'); + languageApi.setLanguage('sv'); const snapshot = assertReady( await waitForNext(translationApi.translation$(resourceRef), s => s.ready), @@ -213,19 +231,10 @@ describe('I18nextTranslationApi', () => { expect(snapshot.t('bar')).toBe('Bär'); }); - it('should refuse switch on unsupported languages', async () => { - const translationApi = I18nextTranslationApi.create({ - supportedLanguages: ['en', 'sv'], - }); - expect(translationApi.getAvailableLanguages()).toEqual(['en', 'sv']); - await translationApi.changeLanguage('sv'); - await expect(translationApi.changeLanguage('de')).rejects.toThrow( - "Failed to change language to 'de', available languages are 'en', 'sv", - ); - }); - it('should forward loading errors', async () => { + const languageApi = AppLanguageSelector.create(); const translationApi = I18nextTranslationApi.create({ + languageApi, resources: [ createTranslationResource({ ref: plainRef, @@ -240,10 +249,12 @@ describe('I18nextTranslationApi', () => { }); 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, @@ -268,8 +279,11 @@ describe('I18nextTranslationApi', () => { }); it('should handle interrupted loads gracefully', async () => { + const languageApi = AppLanguageSelector.create({ + availableLanguages: ['en', 'sv', 'no'], + }); const translationApi = I18nextTranslationApi.create({ - supportedLanguages: ['en', 'sv', 'no'], + languageApi, resources: [ createTranslationResource({ ref: plainRef, @@ -288,17 +302,20 @@ describe('I18nextTranslationApi', () => { ); expect(enSnapshot.t('foo')).toBe('foo'); - translationApi.changeLanguage('sv'); + languageApi.setLanguage('sv'); const nextPromise = waitForNext(translationApi.translation$(plainRef)); - translationApi.changeLanguage('no'); + 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({ - supportedLanguages: ['en', 'dk', 'sv', 'no'], + languageApi, resources: [ createTranslationResource({ ref: plainRef, @@ -320,10 +337,10 @@ describe('I18nextTranslationApi', () => { translations.push(translation); if (translation === 'foo') { - translationApi.changeLanguage('dk'); // Not visible - translationApi.changeLanguage('sv'); + languageApi.setLanguage('dk'); // Not visible + languageApi.setLanguage('sv'); } else if (translation === 'Föö') { - translationApi.changeLanguage('no'); + languageApi.setLanguage('no'); } else if (translation === 'Føø') { resolve(); subscription.unsubscribe(); diff --git a/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.ts b/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.ts index 2a5b570300..e1757f5ff5 100644 --- a/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.ts +++ b/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.ts @@ -15,6 +15,7 @@ */ import { + AppLanguageApi, TranslationApi, TranslationMessages, TranslationRef, @@ -36,14 +37,13 @@ import { InternalTranslationRef, } from '../../../../../core-plugin-api/src/translation/TranslationRef'; import { Observable } from '@backstage/types'; - -const DEFAULT_LANGUAGE = 'en'; +import { DEFAULT_LANGUAGE } from '../AppLanguageApi/AppLanguageSelector'; /** @alpha */ -export type ExperimentalI18n = { - supportedLanguages?: string[]; +export interface I18nextTranslationApiOptions { + languageApi: AppLanguageApi; resources?: Array; -}; +} function removeNulls( messages: Record, @@ -139,11 +139,9 @@ class ResourceLoader { /** @alpha */ export class I18nextTranslationApi implements TranslationApi { - static create(options?: ExperimentalI18n) { - const languages = options?.supportedLanguages || [DEFAULT_LANGUAGE]; - if (!languages.includes(DEFAULT_LANGUAGE)) { - throw new Error(`Supported languages must include '${DEFAULT_LANGUAGE}'`); - } + static create(options: I18nextTranslationApiOptions) { + const { languages } = options.languageApi.getAvailableLanguages(); + const i18n = createI18n({ fallbackLng: DEFAULT_LANGUAGE, supportedLngs: languages, @@ -185,36 +183,27 @@ export class I18nextTranslationApi implements TranslationApi { } } - return new I18nextTranslationApi(i18n, loader, languages); + 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; - #languages: string[]; - private constructor(i18n: I18n, loader: ResourceLoader, languages: string[]) { + private constructor(i18n: I18n, loader: ResourceLoader, language: string) { this.#i18n = i18n; this.#loader = loader; - this.#language = DEFAULT_LANGUAGE; - this.#languages = languages; - } - - getAvailableLanguages(): string[] { - return this.#languages.slice(); - } - - async changeLanguage(language?: string): Promise { - const lng = language ?? DEFAULT_LANGUAGE; - if (lng && !this.#languages.includes(lng)) { - throw new Error( - `Failed to change language to '${lng}', available languages are '${this.#languages.join( - "', '", - )}'`, - ); - } - this.#language = lng; - await this.#i18n.changeLanguage(lng); + this.#language = language; } getTranslation( @@ -290,6 +279,13 @@ export class I18nextTranslationApi implements TranslationApi { }); } + #changeLanguage(language: string): void { + if (this.#language !== language) { + this.#language = language; + this.#i18n.changeLanguage(language); + } + } + #createSnapshot( internalRef: InternalTranslationRef, ): TranslationSnapshot { From 9aeea0ef734fe44a668e16e7c6c8c9701a1a1171 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 14 Sep 2023 17:49:54 +0200 Subject: [PATCH 10/33] core-app-api: update AppManager to use AppLanguageApi and TranslationApi Signed-off-by: Patrik Oldsberg --- .../core-app-api/src/app/AppManager.test.tsx | 2 +- packages/core-app-api/src/app/AppManager.tsx | 32 +++++++++++++------ packages/core-app-api/src/app/types.ts | 4 +-- 3 files changed, 25 insertions(+), 13 deletions(-) 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..abba46764d 100644 --- a/packages/core-app-api/src/app/AppManager.tsx +++ b/packages/core-app-api/src/app/AppManager.tsx @@ -43,8 +43,10 @@ import { FeatureFlag, } from '@backstage/core-plugin-api'; import { - AppTranslationApi, - appTranslationApiRef, + AppLanguageApi, + appLanguageApiRef, + TranslationApi, + translationApiRef, } from '@backstage/core-plugin-api/alpha'; import { ApiFactoryRegistry, ApiResolver } from '../apis/system'; import { @@ -80,7 +82,8 @@ 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,8 @@ 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 translationApi: TranslationApi; private readonly appIdentityProxy = new AppIdentityProxy(); private readonly apiFactoryRegistry: ApiFactoryRegistry; @@ -178,9 +182,14 @@ 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({ + availableLanguages: + options.__experimentalTranslations?.availableLanguages, + }); + this.translationApi = I18nextTranslationApi.create({ + languageApi: this.appLanguageApi, + resources: options.__experimentalTranslations?.resources, + }); } getPlugins(): BackstagePlugin[] { @@ -407,9 +416,14 @@ export class AppManager implements BackstageApp { factory: () => this.appIdentityProxy, }); this.apiFactoryRegistry.register('static', { - api: appTranslationApiRef, + api: appLanguageApiRef, deps: {}, - factory: () => this.appTranslationApi, + factory: () => this.appLanguageApi, + }); + this.apiFactoryRegistry.register('static', { + api: translationApiRef, + deps: {}, + factory: () => this.translationApi, }); // 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..42f7ff0ef2 100644 --- a/packages/core-app-api/src/app/types.ts +++ b/packages/core-app-api/src/app/types.ts @@ -283,10 +283,8 @@ 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[]; + availableLanguages?: string[]; resources?: Array; }; }; From 51faaaefc30424f23dda1e6b20e0874df4e134be Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 14 Sep 2023 17:50:36 +0200 Subject: [PATCH 11/33] core-app-api: remove AppTranslationProvider Signed-off-by: Patrik Oldsberg --- packages/core-app-api/src/app/AppManager.tsx | 37 +++++++++---------- .../src/app/AppTranslationProvider.tsx | 27 -------------- 2 files changed, 17 insertions(+), 47 deletions(-) delete mode 100644 packages/core-app-api/src/app/AppTranslationProvider.tsx diff --git a/packages/core-app-api/src/app/AppManager.tsx b/packages/core-app-api/src/app/AppManager.tsx index abba46764d..ac386c5140 100644 --- a/packages/core-app-api/src/app/AppManager.tsx +++ b/packages/core-app-api/src/app/AppManager.tsx @@ -81,7 +81,6 @@ import { resolveRouteBindings } from './resolveRouteBindings'; import { isReactRouterBeta } from './isReactRouterBeta'; import { InternalAppContext } from './InternalAppContext'; import { AppRouter, getBasePath } from './AppRouter'; -import { AppTranslationProvider } from './AppTranslationProvider'; import { AppLanguageSelector } from '../apis/implementations/AppLanguageApi'; import { I18nextTranslationApi } from '../apis/implementations/TranslationApi'; import { overrideBaseUrlConfigs } from './overrideBaseUrlConfigs'; @@ -345,26 +344,24 @@ export class AppManager implements BackstageApp { return ( - - - + + - - {children} - - - - + {children} + + + ); diff --git a/packages/core-app-api/src/app/AppTranslationProvider.tsx b/packages/core-app-api/src/app/AppTranslationProvider.tsx deleted file mode 100644 index 3d5e7e57e1..0000000000 --- a/packages/core-app-api/src/app/AppTranslationProvider.tsx +++ /dev/null @@ -1,27 +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 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}; -} From 8bc4ecf03e7e4e3780637ad1c4fc747eee851212 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 14 Sep 2023 17:52:23 +0200 Subject: [PATCH 12/33] core-app-api: remove unused i18n deps Signed-off-by: Patrik Oldsberg --- packages/core-app-api/package.json | 2 -- yarn.lock | 13 +------------ 2 files changed, 1 insertion(+), 14 deletions(-) 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/yarn.lock b/yarn.lock index bbb27f2c4a..567e440ab2 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" @@ -28539,15 +28537,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" From d93a3cdd1b5435d8a285aa8b5dd875869e5256f6 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 14 Sep 2023 19:53:58 +0200 Subject: [PATCH 13/33] core-app-api: add support for explicit configuration of default language Signed-off-by: Patrik Oldsberg --- .../AppLanguageApi/AppLanguageSelector.ts | 14 ++++++++--- .../I18nextTranslationApi.test.ts | 23 +++++++++++++++++++ .../TranslationApi/I18nextTranslationApi.ts | 5 ++++ packages/core-app-api/src/app/AppManager.tsx | 1 + packages/core-app-api/src/app/types.ts | 1 + 5 files changed, 41 insertions(+), 3 deletions(-) diff --git a/packages/core-app-api/src/apis/implementations/AppLanguageApi/AppLanguageSelector.ts b/packages/core-app-api/src/apis/implementations/AppLanguageApi/AppLanguageSelector.ts index fa906c4337..93f3d6517c 100644 --- a/packages/core-app-api/src/apis/implementations/AppLanguageApi/AppLanguageSelector.ts +++ b/packages/core-app-api/src/apis/implementations/AppLanguageApi/AppLanguageSelector.ts @@ -25,6 +25,7 @@ export const DEFAULT_LANGUAGE = 'en'; /** @alpha */ export interface AppLanguageSelectorOptions { + defaultLanguage?: string; availableLanguages?: string[]; } @@ -47,7 +48,14 @@ export class AppLanguageSelector implements AppLanguageApi { throw new Error(`Supported languages must include '${DEFAULT_LANGUAGE}'`); } - return new AppLanguageSelector(languages); + 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) { @@ -85,9 +93,9 @@ export class AppLanguageSelector implements AppLanguageApi { #language: string; #subject: BehaviorSubject<{ language: string }>; - private constructor(languages: string[]) { + private constructor(languages: string[], initialLanguage: string) { this.#languages = languages; - this.#language = languages[0]; + this.#language = initialLanguage; this.#subject = new BehaviorSubject<{ language: string }>({ language: this.#language, }); 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 index 2dd9f2cd56..4b0861af30 100644 --- a/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.test.ts +++ b/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.test.ts @@ -193,6 +193,29 @@ describe('I18nextTranslationApi', () => { 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'], diff --git a/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.ts b/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.ts index e1757f5ff5..2b9c0493b2 100644 --- a/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.ts +++ b/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.ts @@ -155,6 +155,11 @@ export class I18nextTranslationApi implements TranslationApi { i18n.init(); + const { language: initialLanguage } = options.languageApi.getLanguage(); + if (initialLanguage !== DEFAULT_LANGUAGE) { + i18n.changeLanguage(initialLanguage); + } + const loader = new ResourceLoader(loaded => { i18n.addResourceBundle( loaded.language, diff --git a/packages/core-app-api/src/app/AppManager.tsx b/packages/core-app-api/src/app/AppManager.tsx index ac386c5140..048576cd4f 100644 --- a/packages/core-app-api/src/app/AppManager.tsx +++ b/packages/core-app-api/src/app/AppManager.tsx @@ -182,6 +182,7 @@ export class AppManager implements BackstageApp { this.bindRoutes = options.bindRoutes; this.apiFactoryRegistry = new ApiFactoryRegistry(); this.appLanguageApi = AppLanguageSelector.createWithStorage({ + defaultLanguage: options.__experimentalTranslations?.defaultLanguage, availableLanguages: options.__experimentalTranslations?.availableLanguages, }); diff --git a/packages/core-app-api/src/app/types.ts b/packages/core-app-api/src/app/types.ts index 42f7ff0ef2..f203ce9278 100644 --- a/packages/core-app-api/src/app/types.ts +++ b/packages/core-app-api/src/app/types.ts @@ -284,6 +284,7 @@ export type AppOptions = { bindRoutes?(context: { bind: AppRouteBinder }): void; __experimentalTranslations?: { + defaultLanguage?: string; availableLanguages?: string[]; resources?: Array; }; From b50c8e8237c5d200c3320d11922d171e55ce9d17 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 14 Sep 2023 19:54:23 +0200 Subject: [PATCH 14/33] core-plugin-api: refactor useTranslationRef to use new API Signed-off-by: Patrik Oldsberg --- .../src/translation/useTranslationRef.test.ts | 68 ------- .../translation/useTranslationRef.test.tsx | 178 ++++++++++++++++++ .../src/translation/useTranslationRef.ts | 65 +++++-- 3 files changed, 223 insertions(+), 88 deletions(-) delete mode 100644 packages/core-plugin-api/src/translation/useTranslationRef.test.ts create mode 100644 packages/core-plugin-api/src/translation/useTranslationRef.test.tsx 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..1d2682e2fb --- /dev/null +++ b/packages/core-plugin-api/src/translation/useTranslationRef.test.tsx @@ -0,0 +1,178 @@ +/* + * 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 from 'react'; +import { TestApiProvider } from '@backstage/test-utils'; +import { renderHook } from '@testing-library/react-hooks'; +import { createTranslationRef } 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, translationApiRef } from '../alpha'; + +const plainRef = createTranslationRef({ + id: 'plain', + messages: { + key1: 'default1', + key2: 'default2', + }, +}); + +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: ({ children }) => ( + + ), + }); + + 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: ({ children }) => ( + + ), + }, + ); + + 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: ({ children }) => ( + + ), + }, + ); + + 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: ({ children }) => ( + + ), + }, + ); + + await waitForNextUpdate(); + + const { t } = result.current; + + expect(t('key1')).toBe('de1'); + expect(t('key2')).toBe('de2'); + }); +}); diff --git a/packages/core-plugin-api/src/translation/useTranslationRef.ts b/packages/core-plugin-api/src/translation/useTranslationRef.ts index a11ab9c25b..9919ab3b38 100644 --- a/packages/core-plugin-api/src/translation/useTranslationRef.ts +++ b/packages/core-plugin-api/src/translation/useTranslationRef.ts @@ -14,36 +14,61 @@ * limitations under the License. */ -import { useTranslation } from 'react-i18next'; +import { useEffect, useState } from 'react'; import { useApi } from '../apis'; -import { appTranslationApiRef } from '../apis/alpha'; -import { toInternalTranslationRef, TranslationRef } from './TranslationRef'; - -/** @alpha */ -export interface TranslationOptions { - /* no options supported for now */ -} +import { + translationApiRef, + TranslationFunction, + TranslationSnapshot, +} from '../apis/alpha'; +import { TranslationRef } from './TranslationRef'; /** @alpha */ export const useTranslationRef = < TMessages extends { [key in string]: string }, >( translationRef: TranslationRef, -) => { - const translationApi = useApi(appTranslationApiRef); +): { t: TranslationFunction } => { + const translationApi = useApi(translationApiRef); - const internalRef = toInternalTranslationRef(translationRef); - translationApi.addResource(translationRef); + const [error, setError] = useState(); + const [snapshot, setSnapshot] = useState>(() => + translationApi.getTranslation(translationRef), + ); + const [observable] = useState(() => + translationApi.translation$(translationRef), + ); - const { t } = useTranslation(internalRef.id, { - useSuspense: process.env.NODE_ENV !== 'test', - }); + useEffect(() => { + const subscription = observable.subscribe({ + next(next) { + if (next.ready) { + setSnapshot(next); + } + }, + error: setError, + }); - const defaultMessages = internalRef.getDefaultMessages(); + return () => { + subscription.unsubscribe(); + }; + }, [observable]); - return ( - key: TKey, - options?: TranslationOptions, - ): TMessages[TKey] => t(key, defaultMessages[key], options); + if (error) { + throw error; + } + + if (!snapshot.ready) { + throw new Promise(resolve => { + const subscription = observable.subscribe(next => { + if (next.ready) { + subscription.unsubscribe(); + resolve(); + } + }); + }); + } + + return { t: snapshot.t }; }; From d3c63d0c29a51a075759b0e50fa0a81f1fc562c5 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 14 Sep 2023 21:10:01 +0200 Subject: [PATCH 15/33] core-plugin-api: update translation exports Signed-off-by: Patrik Oldsberg --- .../src/apis/definitions/TranslationApi.ts | 2 ++ packages/core-plugin-api/src/apis/definitions/alpha.ts | 10 ++++++++-- packages/core-plugin-api/src/translation/index.ts | 5 +---- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/core-plugin-api/src/apis/definitions/TranslationApi.ts b/packages/core-plugin-api/src/apis/definitions/TranslationApi.ts index 10a9c86b11..a77d471945 100644 --- a/packages/core-plugin-api/src/apis/definitions/TranslationApi.ts +++ b/packages/core-plugin-api/src/apis/definitions/TranslationApi.ts @@ -23,12 +23,14 @@ 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 }; diff --git a/packages/core-plugin-api/src/apis/definitions/alpha.ts b/packages/core-plugin-api/src/apis/definitions/alpha.ts index fa28aef46a..e3179fd4b2 100644 --- a/packages/core-plugin-api/src/apis/definitions/alpha.ts +++ b/packages/core-plugin-api/src/apis/definitions/alpha.ts @@ -13,5 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export * from './TranslationApi'; -export * from './AppLanguageApi'; +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/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'; From c4af7014dac69c2772723d3eead34a8af8f3a1a7 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 14 Sep 2023 21:10:24 +0200 Subject: [PATCH 16/33] core-app-api: make it possible to override the TranslationApi Signed-off-by: Patrik Oldsberg --- packages/core-app-api/src/app/AppManager.tsx | 26 +++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/packages/core-app-api/src/app/AppManager.tsx b/packages/core-app-api/src/app/AppManager.tsx index 048576cd4f..7a941b7986 100644 --- a/packages/core-app-api/src/app/AppManager.tsx +++ b/packages/core-app-api/src/app/AppManager.tsx @@ -45,8 +45,9 @@ import { import { AppLanguageApi, appLanguageApiRef, - TranslationApi, translationApiRef, + TranslationMessages, + TranslationResource, } from '@backstage/core-plugin-api/alpha'; import { ApiFactoryRegistry, ApiResolver } from '../apis/system'; import { @@ -165,7 +166,9 @@ export class AppManager implements BackstageApp { private readonly defaultApis: Iterable; private readonly bindRoutes: AppOptions['bindRoutes']; private readonly appLanguageApi: AppLanguageApi; - private readonly translationApi: TranslationApi; + private readonly translationResources: Array< + TranslationResource | TranslationMessages + >; private readonly appIdentityProxy = new AppIdentityProxy(); private readonly apiFactoryRegistry: ApiFactoryRegistry; @@ -186,10 +189,8 @@ export class AppManager implements BackstageApp { availableLanguages: options.__experimentalTranslations?.availableLanguages, }); - this.translationApi = I18nextTranslationApi.create({ - languageApi: this.appLanguageApi, - resources: options.__experimentalTranslations?.resources, - }); + this.translationResources = + options.__experimentalTranslations?.resources ?? []; } getPlugins(): BackstagePlugin[] { @@ -418,10 +419,17 @@ export class AppManager implements BackstageApp { deps: {}, factory: () => this.appLanguageApi, }); - this.apiFactoryRegistry.register('static', { + + // 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: {}, - factory: () => this.translationApi, + 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 From a7c01da53070d180b5877b078aae5dc566dbf0f4 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 14 Sep 2023 21:11:50 +0200 Subject: [PATCH 17/33] test-utils: add built-in TranslationApi mock Signed-off-by: Patrik Oldsberg --- packages/test-utils/package.json | 1 + .../apis/TranslationApi/MockTranslationApi.ts | 84 +++++++++++++++++++ .../testUtils/apis/TranslationApi/index.ts | 17 ++++ .../test-utils/src/testUtils/apis/index.ts | 1 + packages/test-utils/src/testUtils/mockApis.ts | 9 +- yarn.lock | 1 + 6 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 packages/test-utils/src/testUtils/apis/TranslationApi/MockTranslationApi.ts create mode 100644 packages/test-utils/src/testUtils/apis/TranslationApi/index.ts 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/testUtils/apis/TranslationApi/MockTranslationApi.ts b/packages/test-utils/src/testUtils/apis/TranslationApi/MockTranslationApi.ts new file mode 100644 index 0000000000..19b4f08479 --- /dev/null +++ b/packages/test-utils/src/testUtils/apis/TranslationApi/MockTranslationApi.ts @@ -0,0 +1,84 @@ +/* + * 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, + }); + + i18n.init(); + + return new MockTranslationApi(i18n); + } + + #i18n: I18n; + + private constructor(i18n: I18n) { + this.#i18n = i18n; + } + + getTranslation( + translationRef: TranslationRef, + ): TranslationSnapshot { + const internalRef = toInternalTranslationRef(translationRef); + + const t = this.#i18n.getFixedT(null, internalRef.id); + const defaultMessages = internalRef.getDefaultMessages() as TMessages; + + return { + ready: true, + t: (key, options) => { + return t(key as string, { + ...options, + defaultValue: defaultMessages[key], + }); + }, + }; + } + + 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/apis/index.ts b/packages/test-utils/src/testUtils/apis/index.ts index e122a4b432..ac56bd6e18 100644 --- a/packages/test-utils/src/testUtils/apis/index.ts +++ b/packages/test-utils/src/testUtils/apis/index.ts @@ -20,3 +20,4 @@ export * from './ErrorApi'; export * from './FetchApi'; export * from './PermissionApi'; export * from './StorageApi'; +export * from './TranslationApi'; diff --git a/packages/test-utils/src/testUtils/mockApis.ts b/packages/test-utils/src/testUtils/mockApis.ts index 0fbfdf5967..9f3b62907c 100644 --- a/packages/test-utils/src/testUtils/mockApis.ts +++ b/packages/test-utils/src/testUtils/mockApis.ts @@ -20,10 +20,17 @@ import { fetchApiRef, storageApiRef, } from '@backstage/core-plugin-api'; -import { MockErrorApi, MockFetchApi, MockStorageApi } from './apis'; +import { translationApiRef } from '@backstage/core-plugin-api/alpha'; +import { + MockErrorApi, + MockFetchApi, + MockStorageApi, + MockTranslationApi, +} from './apis'; export const mockApis = [ createApiFactory(errorApiRef, new MockErrorApi()), createApiFactory(fetchApiRef, new MockFetchApi()), createApiFactory(storageApiRef, MockStorageApi.create()), + createApiFactory(translationApiRef, MockTranslationApi.create()), ]; diff --git a/yarn.lock b/yarn.lock index 567e440ab2..2a037184fa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10063,6 +10063,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: From 28dc3e56c36a155bb787a694fb79154462b07644 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 14 Sep 2023 21:12:41 +0200 Subject: [PATCH 18/33] adr: update to use new translation API Signed-off-by: Patrik Oldsberg --- .../adr/src/components/EntityAdrContent/EntityAdrContent.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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'); From 41b10267d741b1cc9c12bdddba699da79896d9bd Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 14 Sep 2023 21:12:29 +0200 Subject: [PATCH 19/33] user-settings: update to use new translation APIs Signed-off-by: Patrik Oldsberg --- plugins/user-settings/package.json | 1 - .../UserSettingsLanguageToggle.test.tsx | 119 ++++++------------ .../General/UserSettingsLanguageToggle.tsx | 49 ++++---- .../General/UserSettingsThemeToggle.tsx | 2 +- yarn.lock | 1 - 5 files changed, 61 insertions(+), 111 deletions(-) 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 2a037184fa..ac000ff20c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9879,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: From 2a901d83ab25d245a286e62608dcdfbf8094c31d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 14 Sep 2023 21:14:06 +0200 Subject: [PATCH 20/33] core-plugin-api: remove react-i18next dependency Signed-off-by: Patrik Oldsberg --- packages/core-plugin-api/package.json | 1 - yarn.lock | 35 --------------------------- 2 files changed, 36 deletions(-) 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/yarn.lock b/yarn.lock index ac000ff20c..67674c22ba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4222,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 @@ -28283,15 +28282,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" @@ -37374,24 +37364,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" @@ -42734,13 +42706,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" From a9f7e17e2d5b82a722a6af8d87d1ca2dfd26fd40 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 15 Sep 2023 10:41:17 +0200 Subject: [PATCH 21/33] core-app-api: add failing formatting tests for TranslationApi Signed-off-by: Patrik Oldsberg --- .../I18nextTranslationApi.test.ts | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) 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 index 4b0861af30..9929bacfd1 100644 --- a/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.test.ts +++ b/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.test.ts @@ -374,4 +374,135 @@ describe('I18nextTranslationApi', () => { 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: narrow) }}', + 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 1s'); + expect(snapshot.t('relativeSecondsShort', { x: 2 })).toBe('= in 2s'); + expect(snapshot.t('relativeSecondsShort', { x: -3 })).toBe('= 3s ago'); + expect(snapshot.t('relativeSecondsShort', { x: 0 })).toBe('= in 0s'); + 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'); + }); + }); }); From b18cf9a531037a1b4cd1b48a1a4b563f96c5bb5c Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 15 Sep 2023 10:41:58 +0200 Subject: [PATCH 22/33] core-app-api: switch default message strategy to make all messages available Signed-off-by: Patrik Oldsberg --- .../I18nextTranslationApi.test.ts | 9 ++--- .../TranslationApi/I18nextTranslationApi.ts | 36 +++++++++++-------- 2 files changed, 26 insertions(+), 19 deletions(-) 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 index 9929bacfd1..00669121d5 100644 --- a/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.test.ts +++ b/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.test.ts @@ -121,13 +121,14 @@ describe('I18nextTranslationApi', () => { languageApi, resources: [ createTranslationMessages({ - ref: plainRef, - messages: { foo: 'Bar' }, + ref: resourceRef, + messages: { foo: 'Foo Override' }, }), ], }); - const snapshot = assertReady(translationApi.getTranslation(plainRef)); - expect(snapshot.t('foo')).toBe('Bar'); + 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', () => { diff --git a/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.ts b/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.ts index 2b9c0493b2..dfcfa9081e 100644 --- a/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.ts +++ b/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.ts @@ -61,9 +61,6 @@ function removeNulls( * loaded or not, so instead we implement our own resource loader. */ class ResourceLoader { - /** The resources that have been registered */ - #seen = new Set(); - /** Loaded resources by loader key */ #loaded = new Set(); /** Resource loading promises by loader key */ @@ -80,10 +77,6 @@ class ResourceLoader { ) {} addTranslationResource(resource: TranslationResource) { - if (this.#seen.has(resource)) { - return; - } - this.#seen.add(resource); const internalResource = toInternalTranslationResource(resource); for (const entry of internalResource.resources) { const key = this.#getLoaderKey(entry.language, internalResource.id); @@ -205,6 +198,9 @@ export class I18nextTranslationApi implements TranslationApi { #loader: ResourceLoader; #language: string; + /** Keep track of which refs we have registered default resources for */ + #registeredRefs = new Set(); + private constructor(i18n: I18n, loader: ResourceLoader, language: string) { this.#i18n = i18n; this.#loader = loader; @@ -216,7 +212,7 @@ export class I18nextTranslationApi implements TranslationApi { ): TranslationSnapshot { const internalRef = toInternalTranslationRef(translationRef); - this.#registerDefaultResource(internalRef); + this.#registerDefaults(internalRef); return this.#createSnapshot(internalRef); } @@ -226,7 +222,7 @@ export class I18nextTranslationApi implements TranslationApi { ): Observable> { const internalRef = toInternalTranslationRef(translationRef); - this.#registerDefaultResource(internalRef); + this.#registerDefaults(internalRef); return new ObservableImpl>(subscriber => { let loadTicket = {}; // To check for stale loads @@ -299,20 +295,30 @@ export class I18nextTranslationApi implements TranslationApi { } const t = this.#i18n.getFixedT(null, internalRef.id); - const defaultMessages = internalRef.getDefaultMessages() as TMessages; return { ready: true, t: (key, options) => { - return t(key as string, { - ...options, - defaultValue: defaultMessages[key], - }); + return t(key as string, { ...options }); }, }; } - #registerDefaultResource(internalRef: InternalTranslationRef): void { + #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); From 813a464543bb79c794487399ed9e85444eaa2611 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 15 Sep 2023 10:42:46 +0200 Subject: [PATCH 23/33] test-utils: refactor MockTranslationApi default messages handling + tests Signed-off-by: Patrik Oldsberg --- .../TranslationApi/MockTranslationApi.test.ts | 151 ++++++++++++++++++ .../apis/TranslationApi/MockTranslationApi.ts | 18 ++- 2 files changed, 164 insertions(+), 5 deletions(-) create mode 100644 packages/test-utils/src/testUtils/apis/TranslationApi/MockTranslationApi.test.ts 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..1ed06a9138 --- /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: narrow) }}', + 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 1s'); + expect(snapshot.t('relativeSecondsShort', { x: 2 })).toBe('= in 2s'); + expect(snapshot.t('relativeSecondsShort', { x: -3 })).toBe('= 3s ago'); + expect(snapshot.t('relativeSecondsShort', { x: 0 })).toBe('= in 0s'); + 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 index 19b4f08479..04fc55bcbc 100644 --- a/packages/test-utils/src/testUtils/apis/TranslationApi/MockTranslationApi.ts +++ b/packages/test-utils/src/testUtils/apis/TranslationApi/MockTranslationApi.ts @@ -49,6 +49,7 @@ export class MockTranslationApi implements TranslationApi { } #i18n: I18n; + #registeredRefs = new Set(); private constructor(i18n: I18n) { this.#i18n = i18n; @@ -60,15 +61,22 @@ export class MockTranslationApi implements TranslationApi { const internalRef = toInternalTranslationRef(translationRef); const t = this.#i18n.getFixedT(null, internalRef.id); - const defaultMessages = internalRef.getDefaultMessages() as TMessages; + + 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, - defaultValue: defaultMessages[key], - }); + return t(key as string, { ...options }); }, }; } From 6cfc2ff152794c2d93bc287ca9f50a2cd2518f8e Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 15 Sep 2023 13:06:42 +0200 Subject: [PATCH 24/33] test-utils: export MockTranslationApi through alpha Signed-off-by: Patrik Oldsberg --- packages/test-utils/src/alpha.ts | 1 + packages/test-utils/src/testUtils/apis/index.ts | 1 - packages/test-utils/src/testUtils/mockApis.ts | 8 ++------ 3 files changed, 3 insertions(+), 7 deletions(-) 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/index.ts b/packages/test-utils/src/testUtils/apis/index.ts index ac56bd6e18..e122a4b432 100644 --- a/packages/test-utils/src/testUtils/apis/index.ts +++ b/packages/test-utils/src/testUtils/apis/index.ts @@ -20,4 +20,3 @@ export * from './ErrorApi'; export * from './FetchApi'; export * from './PermissionApi'; export * from './StorageApi'; -export * from './TranslationApi'; diff --git a/packages/test-utils/src/testUtils/mockApis.ts b/packages/test-utils/src/testUtils/mockApis.ts index 9f3b62907c..361d569b17 100644 --- a/packages/test-utils/src/testUtils/mockApis.ts +++ b/packages/test-utils/src/testUtils/mockApis.ts @@ -21,12 +21,8 @@ import { storageApiRef, } from '@backstage/core-plugin-api'; import { translationApiRef } from '@backstage/core-plugin-api/alpha'; -import { - MockErrorApi, - MockFetchApi, - MockStorageApi, - MockTranslationApi, -} from './apis'; +import { MockErrorApi, MockFetchApi, MockStorageApi } from './apis'; +import { MockTranslationApi } from './apis/TranslationApi'; export const mockApis = [ createApiFactory(errorApiRef, new MockErrorApi()), From 11fa356e40005c28521977c44bf8f8881b91704b Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 15 Sep 2023 13:07:53 +0200 Subject: [PATCH 25/33] API report updates for TranslationApi refactor Signed-off-by: Patrik Oldsberg --- packages/core-app-api/api-report.md | 4 +- packages/core-plugin-api/alpha-api-report.md | 70 +++++++++++++++++--- packages/test-utils/alpha-api-report.md | 24 +++++++ 3 files changed, 87 insertions(+), 11 deletions(-) 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-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/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) ``` From 7633812431fe35a58ff244f1105fb061a7b9cc0d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 15 Sep 2023 13:32:09 +0200 Subject: [PATCH 26/33] docs: update internationalization docs Signed-off-by: Patrik Oldsberg --- docs/plugins/internationalization.md | 55 ++++++++++++++++------------ 1 file changed, 31 insertions(+), 24 deletions(-) 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'), + }, + }), + ], + }, - ... -}) + }) ``` From b5fbddc15dca8c8c305545e1e296b44b249cfae5 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 15 Sep 2023 13:40:32 +0200 Subject: [PATCH 27/33] changesets: add changeset for MockTranslationApi Signed-off-by: Patrik Oldsberg --- .changeset/tender-seals-yawn.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/tender-seals-yawn.md 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. From cc8b0535473117560185dbfdc3bd252a673ae401 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 16 Sep 2023 11:37:21 +0200 Subject: [PATCH 28/33] update TranslationApi formatting tests for node 16 Signed-off-by: Patrik Oldsberg --- .../TranslationApi/I18nextTranslationApi.test.ts | 12 +++++++----- .../apis/TranslationApi/MockTranslationApi.test.ts | 10 +++++----- 2 files changed, 12 insertions(+), 10 deletions(-) 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 index 00669121d5..425b2d74e2 100644 --- a/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.test.ts +++ b/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.test.ts @@ -455,7 +455,7 @@ describe('I18nextTranslationApi', () => { relativeTime: '= {{ x, relativeTime }}', relativeSeconds: '= {{ x, relativeTime(second) }}', relativeSecondsShort: - '= {{ x, relativeTime(range: second; style: narrow) }}', + '= {{ x, relativeTime(range: second; style: short) }}', list: '= {{ x, list }}', }); @@ -480,10 +480,12 @@ describe('I18nextTranslationApi', () => { 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 1s'); - expect(snapshot.t('relativeSecondsShort', { x: 2 })).toBe('= in 2s'); - expect(snapshot.t('relativeSecondsShort', { x: -3 })).toBe('= 3s ago'); - expect(snapshot.t('relativeSecondsShort', { x: 0 })).toBe('= in 0s'); + 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'); diff --git a/packages/test-utils/src/testUtils/apis/TranslationApi/MockTranslationApi.test.ts b/packages/test-utils/src/testUtils/apis/TranslationApi/MockTranslationApi.test.ts index 1ed06a9138..6b6ef2eb13 100644 --- a/packages/test-utils/src/testUtils/apis/TranslationApi/MockTranslationApi.test.ts +++ b/packages/test-utils/src/testUtils/apis/TranslationApi/MockTranslationApi.test.ts @@ -98,7 +98,7 @@ describe('MockTranslationApi', () => { relativeTime: '= {{ x, relativeTime }}', relativeSeconds: '= {{ x, relativeTime(second) }}', relativeSecondsShort: - '= {{ x, relativeTime(range: second; style: narrow) }}', + '= {{ x, relativeTime(range: second; style: short) }}', list: '= {{ x, list }}', }); @@ -123,10 +123,10 @@ describe('MockTranslationApi', () => { 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 1s'); - expect(snapshot.t('relativeSecondsShort', { x: 2 })).toBe('= in 2s'); - expect(snapshot.t('relativeSecondsShort', { x: -3 })).toBe('= 3s ago'); - expect(snapshot.t('relativeSecondsShort', { x: 0 })).toBe('= in 0s'); + 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'); From 7b3a58f0b6c08ffaf7205ff2b603a3e13237aa34 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 16 Sep 2023 12:02:36 +0200 Subject: [PATCH 29/33] refactor TranslationApi implementations to initialize immediately + own language listener Signed-off-by: Patrik Oldsberg --- .../TranslationApi/I18nextTranslationApi.ts | 21 ++++++++++--------- .../apis/TranslationApi/MockTranslationApi.ts | 6 ++++++ 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.ts b/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.ts index dfcfa9081e..b55ef44071 100644 --- a/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.ts +++ b/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.ts @@ -144,9 +144,15 @@ export class I18nextTranslationApi implements TranslationApi { 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) { @@ -200,6 +206,8 @@ export class I18nextTranslationApi implements TranslationApi { /** 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; @@ -261,21 +269,13 @@ export class I18nextTranslationApi implements TranslationApi { } }; - const wasInitialized = this.#i18n.isInitialized; - if (!wasInitialized) { - this.#i18n.on('initialized', onChange); - } - this.#i18n.on('languageChanged', onChange); - if (this.#loader.needsLoading(this.#language, internalRef.id)) { loadResource(); } + this.#languageChangeListeners.add(onChange); return () => { - if (!wasInitialized) { - this.#i18n.off('initialized', onChange); - } - this.#i18n.off('languageChanged', onChange); + this.#languageChangeListeners.delete(onChange); }; }); } @@ -284,6 +284,7 @@ export class I18nextTranslationApi implements TranslationApi { if (this.#language !== language) { this.#language = language; this.#i18n.changeLanguage(language); + this.#languageChangeListeners.forEach(listener => listener()); } } diff --git a/packages/test-utils/src/testUtils/apis/TranslationApi/MockTranslationApi.ts b/packages/test-utils/src/testUtils/apis/TranslationApi/MockTranslationApi.ts index 04fc55bcbc..0ffea6a1bd 100644 --- a/packages/test-utils/src/testUtils/apis/TranslationApi/MockTranslationApi.ts +++ b/packages/test-utils/src/testUtils/apis/TranslationApi/MockTranslationApi.ts @@ -41,9 +41,15 @@ export class MockTranslationApi implements TranslationApi { 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); } From 83fb7c5e16ef77fb00a175c3736d828180283731 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 16 Sep 2023 12:33:46 +0200 Subject: [PATCH 30/33] core-app-api: avoid trying to load failed translations again Signed-off-by: Patrik Oldsberg --- .../TranslationApi/I18nextTranslationApi.test.ts | 5 ++++- .../TranslationApi/I18nextTranslationApi.ts | 14 ++++++++++---- 2 files changed, 14 insertions(+), 5 deletions(-) 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 index 425b2d74e2..2b96f4cc4f 100644 --- a/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.test.ts +++ b/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.test.ts @@ -255,7 +255,7 @@ describe('I18nextTranslationApi', () => { expect(snapshot.t('bar')).toBe('Bär'); }); - it('should forward loading errors', async () => { + it('should forward loading errors and then ignore them', async () => { const languageApi = AppLanguageSelector.create(); const translationApi = I18nextTranslationApi.create({ languageApi, @@ -270,6 +270,9 @@ describe('I18nextTranslationApi', () => { 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 () => { diff --git a/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.ts b/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.ts index b55ef44071..49fb12de46 100644 --- a/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.ts +++ b/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.ts @@ -121,10 +121,16 @@ class ResourceLoader { return; } - const load = loader().then(result => { - this.onLoad({ language, namespace, messages: result.messages }); - this.#loaded.add(key); - }); + 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; } From f31de6be7ca20e807cfaf07a0f945bf3c66f8fd9 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 16 Sep 2023 12:34:18 +0200 Subject: [PATCH 31/33] core-plugin-api: make useTranslationRef handle and log errors Signed-off-by: Patrik Oldsberg --- .../translation/useTranslationRef.test.tsx | 89 +++++++++++++------ .../src/translation/useTranslationRef.ts | 23 ++++- 2 files changed, 81 insertions(+), 31 deletions(-) diff --git a/packages/core-plugin-api/src/translation/useTranslationRef.test.tsx b/packages/core-plugin-api/src/translation/useTranslationRef.test.tsx index 1d2682e2fb..7b04f6935e 100644 --- a/packages/core-plugin-api/src/translation/useTranslationRef.test.tsx +++ b/packages/core-plugin-api/src/translation/useTranslationRef.test.tsx @@ -14,8 +14,8 @@ * limitations under the License. */ -import React from 'react'; -import { TestApiProvider } from '@backstage/test-utils'; +import React, { ReactNode } from 'react'; +import { TestApiProvider, withLogCollector } from '@backstage/test-utils'; import { renderHook } from '@testing-library/react-hooks'; import { createTranslationRef } from './TranslationRef'; import { useTranslationRef } from './useTranslationRef'; @@ -23,7 +23,11 @@ import { useTranslationRef } from './useTranslationRef'; 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, translationApiRef } from '../alpha'; +import { + createTranslationResource, + TranslationApi, + translationApiRef, +} from '../alpha'; const plainRef = createTranslationRef({ id: 'plain', @@ -33,6 +37,15 @@ const plainRef = createTranslationRef({ }, }); +function makeWrapper(translationApi: TranslationApi) { + return ({ children }: { children: ReactNode }) => ( + + ); +} + describe('useTranslationRef', () => { beforeEach(() => { jest.clearAllMocks(); @@ -43,12 +56,7 @@ describe('useTranslationRef', () => { const translationApi = I18nextTranslationApi.create({ languageApi }); const { result } = renderHook(() => useTranslationRef(plainRef), { - wrapper: ({ children }) => ( - - ), + wrapper: makeWrapper(translationApi), }); const { t } = result.current; @@ -75,12 +83,7 @@ describe('useTranslationRef', () => { const { result, waitForNextUpdate } = renderHook( () => useTranslationRef(plainRef), { - wrapper: ({ children }) => ( - - ), + wrapper: makeWrapper(translationApi), }, ); @@ -112,12 +115,7 @@ describe('useTranslationRef', () => { const { result, waitForNextUpdate } = renderHook( () => useTranslationRef(plainRef), { - wrapper: ({ children }) => ( - - ), + wrapper: makeWrapper(translationApi), }, ); @@ -159,12 +157,7 @@ describe('useTranslationRef', () => { const { result, waitForNextUpdate } = renderHook( () => useTranslationRef(resourceRef), { - wrapper: ({ children }) => ( - - ), + wrapper: makeWrapper(translationApi), }, ); @@ -175,4 +168,46 @@ describe('useTranslationRef', () => { 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 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), + }); + const rendered2 = renderHook(() => useTranslationRef(ref), { + wrapper: makeWrapper(translationApi), + }); + + const { error } = await withLogCollector(['error'], async () => { + await rendered2.waitForNextUpdate(); + }); + + expect(error).toEqual([ + "Failed to load translation resource 'test'; caused by Error: NOPE", + ]); + + expect(rendered1.result.current.t('key')).toBe('default'); + expect(rendered2.result.current.t('key')).toBe('default'); + }); }); diff --git a/packages/core-plugin-api/src/translation/useTranslationRef.ts b/packages/core-plugin-api/src/translation/useTranslationRef.ts index 9919ab3b38..4f09d0b970 100644 --- a/packages/core-plugin-api/src/translation/useTranslationRef.ts +++ b/packages/core-plugin-api/src/translation/useTranslationRef.ts @@ -24,6 +24,9 @@ import { } from '../apis/alpha'; import { TranslationRef } from './TranslationRef'; +// 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 }, @@ -61,11 +64,23 @@ export const useTranslationRef = < if (!snapshot.ready) { throw new Promise(resolve => { - const subscription = observable.subscribe(next => { - if (next.ready) { - subscription.unsubscribe(); + const subscription = observable.subscribe({ + next(next) { + if (next.ready) { + subscription.unsubscribe(); + resolve(); + } + }, + error(err) { + if (!loggedRefs.has(translationRef)) { + // eslint-disable-next-line no-console + console.error( + `Failed to load translation resource '${translationRef.id}'; caused by ${err}`, + ); + loggedRefs.add(translationRef); + } resolve(); - } + }, }); }); } From a3a7d64dbdfd6e01554759d742b56f27b80e2730 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 16 Sep 2023 18:02:47 +0200 Subject: [PATCH 32/33] core-plugin-api: further error handling improvements + fixes and tests for useTranslationRef Signed-off-by: Patrik Oldsberg --- .../translation/useTranslationRef.test.tsx | 88 +++++++++++++++++-- .../src/translation/useTranslationRef.ts | 46 +++++----- 2 files changed, 107 insertions(+), 27 deletions(-) diff --git a/packages/core-plugin-api/src/translation/useTranslationRef.test.tsx b/packages/core-plugin-api/src/translation/useTranslationRef.test.tsx index 7b04f6935e..70d0c89a97 100644 --- a/packages/core-plugin-api/src/translation/useTranslationRef.test.tsx +++ b/packages/core-plugin-api/src/translation/useTranslationRef.test.tsx @@ -15,7 +15,11 @@ */ import React, { ReactNode } from 'react'; -import { TestApiProvider, withLogCollector } from '@backstage/test-utils'; +import { + MockErrorApi, + TestApiProvider, + withLogCollector, +} from '@backstage/test-utils'; import { renderHook } from '@testing-library/react-hooks'; import { createTranslationRef } from './TranslationRef'; import { useTranslationRef } from './useTranslationRef'; @@ -28,6 +32,7 @@ import { TranslationApi, translationApiRef, } from '../alpha'; +import { ErrorApi, errorApiRef } from '../apis'; const plainRef = createTranslationRef({ id: 'plain', @@ -37,10 +42,16 @@ const plainRef = createTranslationRef({ }, }); -function makeWrapper(translationApi: TranslationApi) { +function makeWrapper( + translationApi: TranslationApi, + errorApi: ErrorApi = { error$: jest.fn(), post: jest.fn() }, +) { return ({ children }: { children: ReactNode }) => ( ); @@ -177,6 +188,7 @@ describe('useTranslationRef', () => { }, }); + const errorApi = new MockErrorApi({ collect: true }); const languageApi = AppLanguageSelector.create(); const translationApi = I18nextTranslationApi.create({ languageApi, @@ -193,21 +205,83 @@ describe('useTranslationRef', () => { }); const rendered1 = renderHook(() => useTranslationRef(ref), { - wrapper: makeWrapper(translationApi), + wrapper: makeWrapper(translationApi, errorApi), }); const rendered2 = renderHook(() => useTranslationRef(ref), { - wrapper: makeWrapper(translationApi), + wrapper: makeWrapper(translationApi, errorApi), }); const { error } = await withLogCollector(['error'], async () => { await rendered2.waitForNextUpdate(); }); - expect(error).toEqual([ - "Failed to load translation resource 'test'; caused by Error: NOPE", + 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), + }, + ]); + }); }); diff --git a/packages/core-plugin-api/src/translation/useTranslationRef.ts b/packages/core-plugin-api/src/translation/useTranslationRef.ts index 4f09d0b970..50d6d4d85a 100644 --- a/packages/core-plugin-api/src/translation/useTranslationRef.ts +++ b/packages/core-plugin-api/src/translation/useTranslationRef.ts @@ -14,9 +14,8 @@ * limitations under the License. */ -import { useEffect, useState } from 'react'; - -import { useApi } from '../apis'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { errorApiRef, useApi } from '../apis'; import { translationApiRef, TranslationFunction, @@ -33,14 +32,28 @@ export const useTranslationRef = < >( translationRef: TranslationRef, ): { t: TranslationFunction } => { + const errorApi = useApi(errorApiRef); const translationApi = useApi(translationApiRef); - const [error, setError] = useState(); const [snapshot, setSnapshot] = useState>(() => translationApi.getTranslation(translationRef), ); - const [observable] = useState(() => - translationApi.translation$(translationRef), + const observable = useMemo( + () => translationApi.translation$(translationRef), + [translationApi, translationRef], + ); + + 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], ); useEffect(() => { @@ -50,17 +63,15 @@ export const useTranslationRef = < setSnapshot(next); } }, - error: setError, + error(error) { + onError(error); + }, }); return () => { subscription.unsubscribe(); }; - }, [observable]); - - if (error) { - throw error; - } + }, [observable, onError]); if (!snapshot.ready) { throw new Promise(resolve => { @@ -71,14 +82,9 @@ export const useTranslationRef = < resolve(); } }, - error(err) { - if (!loggedRefs.has(translationRef)) { - // eslint-disable-next-line no-console - console.error( - `Failed to load translation resource '${translationRef.id}'; caused by ${err}`, - ); - loggedRefs.add(translationRef); - } + error(error) { + subscription.unsubscribe(); + onError(error); resolve(); }, }); From ed83454fb8f48dcc63db193795e4201752285564 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 16 Sep 2023 18:14:57 +0200 Subject: [PATCH 33/33] core-plugin-api: make useTranslationRef handle translation ref update Signed-off-by: Patrik Oldsberg --- .../translation/useTranslationRef.test.tsx | 40 ++++++++++++++++++- .../src/translation/useTranslationRef.ts | 12 +++++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/packages/core-plugin-api/src/translation/useTranslationRef.test.tsx b/packages/core-plugin-api/src/translation/useTranslationRef.test.tsx index 70d0c89a97..a8dbdc0402 100644 --- a/packages/core-plugin-api/src/translation/useTranslationRef.test.tsx +++ b/packages/core-plugin-api/src/translation/useTranslationRef.test.tsx @@ -21,7 +21,7 @@ import { withLogCollector, } from '@backstage/test-utils'; import { renderHook } from '@testing-library/react-hooks'; -import { createTranslationRef } from './TranslationRef'; +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'; @@ -284,4 +284,42 @@ describe('useTranslationRef', () => { }, ]); }); + + 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 50d6d4d85a..b874ac93d8 100644 --- a/packages/core-plugin-api/src/translation/useTranslationRef.ts +++ b/packages/core-plugin-api/src/translation/useTranslationRef.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { errorApiRef, useApi } from '../apis'; import { translationApiRef, @@ -73,6 +73,16 @@ export const useTranslationRef = < }; }, [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({