From 6e30769cc62764121319546228b00cef1859002a Mon Sep 17 00:00:00 2001 From: rui ma Date: Mon, 4 Sep 2023 23:51:08 +0800 Subject: [PATCH] feat: Introduced experimental support for internationalization. Signed-off-by: rui ma --- .changeset/yellow-yaks-behave.md | 7 + docs/plugins/internationalization.md | 61 ++++++ packages/core-app-api/alpha-api-report.md | 82 ++++++++ packages/core-app-api/api-report.md | 15 ++ packages/core-app-api/package.json | 22 +- packages/core-app-api/src/alpha.ts | 17 ++ .../AppTranslationApi/AppTranslation.test.ts | 180 ++++++++++++++++ .../AppTranslationApi/AppTranslationImpl.ts | 194 ++++++++++++++++++ .../AppTranslationApi/index.ts | 17 ++ .../core-app-api/src/app/AppManager.test.tsx | 40 ++++ packages/core-app-api/src/app/AppManager.tsx | 51 +++-- .../src/app/AppTranslationProvider.tsx | 27 +++ .../src/app/TranslationResource.ts | 34 +++ packages/core-app-api/src/app/types.ts | 28 +++ packages/core-plugin-api/alpha-api-report.md | 102 +++++++++ packages/core-plugin-api/package.json | 2 + packages/core-plugin-api/src/alpha.ts | 2 + packages/core-plugin-api/src/apis/alpha.ts | 16 ++ .../src/apis/definitions/AppTranslationApi.ts | 35 ++++ .../src/apis/definitions/alpha.ts | 16 ++ .../src/translation/TranslationRef.test.ts | 60 ++++++ .../src/translation/TranslationRef.ts | 61 ++++++ .../core-plugin-api/src/translation/index.ts | 19 ++ .../core-plugin-api/src/translation/types.ts | 45 ++++ .../src/translation/useTranslationRef.test.ts | 68 ++++++ .../src/translation/useTranslationRef.ts | 41 ++++ plugins/user-settings/alpha-api-report.md | 26 +++ plugins/user-settings/api-report.md | 3 + plugins/user-settings/package.json | 20 +- plugins/user-settings/src/alpha.ts | 16 ++ .../General/UserSettingsAppearanceCard.tsx | 2 + .../UserSettingsLanguageToggle.test.tsx | 125 +++++++++++ .../General/UserSettingsLanguageToggle.tsx | 149 ++++++++++++++ .../General/UserSettingsThemeToggle.test.tsx | 19 +- .../General/UserSettingsThemeToggle.tsx | 35 ++-- .../src/components/General/index.ts | 1 + plugins/user-settings/src/translation.ts | 37 ++++ yarn.lock | 60 +++++- 38 files changed, 1698 insertions(+), 37 deletions(-) create mode 100644 .changeset/yellow-yaks-behave.md create mode 100644 docs/plugins/internationalization.md create mode 100644 packages/core-app-api/alpha-api-report.md create mode 100644 packages/core-app-api/src/alpha.ts create mode 100644 packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslation.test.ts create mode 100644 packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.ts create mode 100644 packages/core-app-api/src/apis/implementations/AppTranslationApi/index.ts create mode 100644 packages/core-app-api/src/app/AppTranslationProvider.tsx create mode 100644 packages/core-app-api/src/app/TranslationResource.ts create mode 100644 packages/core-plugin-api/src/apis/alpha.ts create mode 100644 packages/core-plugin-api/src/apis/definitions/AppTranslationApi.ts create mode 100644 packages/core-plugin-api/src/apis/definitions/alpha.ts create mode 100644 packages/core-plugin-api/src/translation/TranslationRef.test.ts create mode 100644 packages/core-plugin-api/src/translation/TranslationRef.ts create mode 100644 packages/core-plugin-api/src/translation/index.ts create mode 100644 packages/core-plugin-api/src/translation/types.ts create mode 100644 packages/core-plugin-api/src/translation/useTranslationRef.test.ts create mode 100644 packages/core-plugin-api/src/translation/useTranslationRef.ts create mode 100644 plugins/user-settings/alpha-api-report.md create mode 100644 plugins/user-settings/src/alpha.ts create mode 100644 plugins/user-settings/src/components/General/UserSettingsLanguageToggle.test.tsx create mode 100644 plugins/user-settings/src/components/General/UserSettingsLanguageToggle.tsx create mode 100644 plugins/user-settings/src/translation.ts diff --git a/.changeset/yellow-yaks-behave.md b/.changeset/yellow-yaks-behave.md new file mode 100644 index 0000000000..546d74c3e4 --- /dev/null +++ b/.changeset/yellow-yaks-behave.md @@ -0,0 +1,7 @@ +--- +'@backstage/core-plugin-api': minor +'@backstage/core-app-api': minor +'@backstage/plugin-user-settings': patch +--- + +Introduced experimental support for internationalization. diff --git a/docs/plugins/internationalization.md b/docs/plugins/internationalization.md new file mode 100644 index 0000000000..eac6ad85e2 --- /dev/null +++ b/docs/plugins/internationalization.md @@ -0,0 +1,61 @@ +--- +id: internationalization +title: Internationalization (Experimental) +description: Documentation on adding internationalization to the plugin +--- + +## Overview + +The Backstage core function provides internationalization for plugins + +## For a plugin developer + +When you are creating your plugin, you have the possibility to use `createTranslationRef` to define all messages for your plugin. For example + +```typescript jsx +import { createTranslationRef } from '@backstage/core-plugin-api/alpha'; + +/** @alpha */ +export const userSettingsTranslationRef = createTranslationRef({ + id: 'user-settings', + messages: { + language: 'Language', + change_the_language: 'Change the language', + }, +}); +``` + +And the using this messages in your components like: + +```typescript jsx +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, ++ messages: { ++ zh: { ++ select_lng: '选择中文-app', ++ }, ++ }, ++ }), ++ ], ++ }, + ... +}) +``` diff --git a/packages/core-app-api/alpha-api-report.md b/packages/core-app-api/alpha-api-report.md new file mode 100644 index 0000000000..57a15f3894 --- /dev/null +++ b/packages/core-app-api/alpha-api-report.md @@ -0,0 +1,82 @@ +## API Report File for "@backstage/core-app-api" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { AppTranslationApi } from '@backstage/core-plugin-api/alpha'; +import { i18n } from 'i18next'; +import { TranslationRef } from '@backstage/core-plugin-api/alpha'; + +// @alpha (undocumented) +export class AppTranslationApiImpl implements AppTranslationApi { + // (undocumented) + addLazyResources>( + translationRef: TranslationRef, + initResources?: Record< + string, + () => Promise<{ + messages: TranslationMessages; + }> + >, + ): void; + // (undocumented) + addResources>( + translationRef: TranslationRef, + initResources?: TranslationMessages>, + ): void; + // (undocumented) + addResourcesByRef>( + translationRef: TranslationRef, + ): void; + // (undocumented) + static create(options?: ExperimentalI18n): AppTranslationApiImpl; + // (undocumented) + getI18n(): i18n; + // (undocumented) + initMessages(options?: ExperimentalI18n): void; +} + +// @alpha (undocumented) +export function createTranslationResource(options: { + ref: T; + messages?: TranslationMessages; + lazyMessages: Record< + string, + () => Promise<{ + messages: TranslationMessages; + }> + >; +}): { + ref: T; + messages?: TranslationMessages | undefined; + lazyMessages: Record< + string, + () => Promise<{ + messages: TranslationMessages; + }> + >; +}; + +// @alpha (undocumented) +export type ExperimentalI18n = { + supportedLanguages: string[]; + fallbackLanguage?: string | string[]; + messages?: Array<{ + ref: TranslationRef; + messages?: TranslationMessages; + lazyMessages: Record< + string, + () => Promise<{ + messages: TranslationMessages; + }> + >; + }>; +}; + +// @alpha (undocumented) +export type TranslationMessages = T extends TranslationRef + ? Record> + : never; + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md index 8b344e748c..2f6f02f37a 100644 --- a/packages/core-app-api/api-report.md +++ b/packages/core-app-api/api-report.md @@ -64,6 +64,7 @@ import { SessionState } from '@backstage/core-plugin-api'; import { StorageApi } from '@backstage/core-plugin-api'; import { StorageValueSnapshot } from '@backstage/core-plugin-api'; import { SubRouteRef } from '@backstage/core-plugin-api'; +import { TranslationRef } from '@backstage/core-plugin-api/alpha'; // @public export class AlertApiForwarder implements AlertApi { @@ -220,6 +221,20 @@ export type AppOptions = { themes: (Partial & Omit)[]; configLoader?: AppConfigLoader; bindRoutes?(context: { bind: AppRouteBinder }): void; + __experimentalI18n?: { + supportedLanguages: string[]; + fallbackLanguage?: string | string[]; + messages?: Array<{ + ref: TranslationRef; + messages?: TranslationMessages; + lazyMessages: Record< + string, + () => Promise<{ + messages: TranslationMessages; + }> + >; + }>; + }; }; // @public diff --git a/packages/core-app-api/package.json b/packages/core-app-api/package.json index 8e3cd7adae..5888c6dc0e 100644 --- a/packages/core-app-api/package.json +++ b/packages/core-app-api/package.json @@ -3,9 +3,22 @@ "description": "Core app API used by Backstage apps", "version": "1.10.0-next.1", "publishConfig": { - "access": "public", - "main": "dist/index.esm.js", - "types": "dist/index.d.ts" + "access": "public" + }, + "exports": { + ".": "./src/index.ts", + "./alpha": "./src/alpha.ts", + "./package.json": "./package.json" + }, + "typesVersions": { + "*": { + "alpha": [ + "src/alpha.ts" + ], + "package.json": [ + "package.json" + ] + } }, "backstage": { "role": "web-library" @@ -39,7 +52,10 @@ "@types/prop-types": "^15.7.3", "@types/react": "^16.13.1 || ^17.0.0", "history": "^5.0.0", + "i18next": "^22.4.15", + "i18next-browser-languagedetector": "^7.0.2", "prop-types": "^15.7.2", + "react-i18next": "^12.3.1", "react-use": "^17.2.4", "zen-observable": "^0.10.0", "zod": "^3.21.4" diff --git a/packages/core-app-api/src/alpha.ts b/packages/core-app-api/src/alpha.ts new file mode 100644 index 0000000000..288676f509 --- /dev/null +++ b/packages/core-app-api/src/alpha.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 * from './apis/implementations/AppTranslationApi'; +export * from './app/TranslationResource'; diff --git a/packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslation.test.ts b/packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslation.test.ts new file mode 100644 index 0000000000..85f8c5e3eb --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslation.test.ts @@ -0,0 +1,180 @@ +/* + * 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 { AppTranslationApiImpl } from './AppTranslationImpl'; +import i18next from 'i18next'; + +jest.mock('i18next', () => ({ + createInstance: jest.fn(() => ({ + use: jest.fn(() => ({ + init: jest.fn(), + use: jest.fn(), + addResourceBundle: jest.fn(), + reloadResources: jest.fn(), + emit: jest.fn(), + services: { + languageUtils: { + getFallbackCodes: jest.fn().mockReturnValue(['en']), + }, + }, + options: { + fallbackLng: 'en', + }, + })), + })), +})); + +describe('AppTranslationApiImpl', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should create i18n instance and init with options', () => { + const i18nMock = i18next.createInstance() as any; + const useReturnMock = i18nMock.use(); + jest.spyOn(i18nMock, 'use').mockReturnValue(useReturnMock); + jest.spyOn(i18next, 'createInstance').mockReturnValue(i18nMock); + + const instance = AppTranslationApiImpl.create({ + supportedLanguages: ['en', 'zh'], + }); + + expect(i18next.createInstance).toHaveBeenCalled(); + expect(i18nMock.use).toHaveBeenCalled(); + expect(useReturnMock.init).toHaveBeenCalledWith({ + fallbackLng: 'en', + supportedLngs: ['en', 'zh'], + interpolation: { + escapeValue: false, + }, + react: { + bindI18n: 'loaded languageChanged', + }, + }); + expect(instance).toBeInstanceOf(AppTranslationApiImpl); + }); + + it('should init messages correctly', () => { + const useResourcesMock = jest.spyOn( + AppTranslationApiImpl.prototype, + 'addResources', + ); + const useLazyResourcesMock = jest.spyOn( + AppTranslationApiImpl.prototype, + 'addLazyResources', + ); + const ref = createTranslationRef({ + id: 'ref-id', + messages: { + key1: '', + }, + }); + + const options = { + supportedLanguages: ['en'], + messages: [ + { + ref, + messages: { + en: { key1: 'value1' }, + }, + lazyMessages: { + en: () => Promise.resolve({ key2: 'value2' }), + } as any, + }, + ], + }; + + const instance = AppTranslationApiImpl.create({ + supportedLanguages: ['en'], + }); + instance.initMessages(options); + + expect(useResourcesMock).toHaveBeenCalledWith( + options.messages[0].ref, + options.messages[0].messages, + ); + expect(useLazyResourcesMock).toHaveBeenCalledWith( + options.messages[0].ref, + options.messages[0].lazyMessages, + ); + }); + + it('should useResources correctly', () => { + const i18nMock = i18next.createInstance() as any; + const useReturnMock = i18nMock.use(); + jest.spyOn(i18nMock, 'use').mockReturnValue(useReturnMock); + jest.spyOn(i18next, 'createInstance').mockReturnValue(i18nMock); + + const ref = createTranslationRef({ + id: 'ref-id', + messages: { + key1: 'value1', + }, + resources: { + en: { + key1: 'value2', + }, + }, + }); + + const instance = AppTranslationApiImpl.create({ + supportedLanguages: ['en'], + }); + instance.addResources(ref); + + expect(useReturnMock.addResourceBundle).toHaveBeenCalledWith( + 'en', + 'ref-id', + { key1: 'value2' }, + true, + false, + ); + }); + + it('should useLazyResources correctly', () => { + const i18nMock = i18next.createInstance() as any; + const useReturnMock = i18nMock.use(); + + jest.spyOn(i18nMock, 'use').mockReturnValue(useReturnMock); + jest.spyOn(i18next, 'createInstance').mockReturnValue(i18nMock); + + const ref = createTranslationRef({ + id: 'ref-id', + messages: { + key1: 'value1', + }, + lazyResources: { + en: () => Promise.resolve({ messages: { key1: 'value2' } }), + }, + }); + + const instance = AppTranslationApiImpl.create({ + supportedLanguages: ['en'], + }); + instance.addLazyResources(ref); + setTimeout(() => { + expect(useReturnMock.addResourceBundle).toHaveBeenCalledWith( + 'en', + 'ref-id', + { key1: 'value2' }, + true, + false, + ); + }); + }); +}); diff --git a/packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.ts b/packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.ts new file mode 100644 index 0000000000..c65611fd9f --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.ts @@ -0,0 +1,194 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + AppTranslationApi, + TranslationRef, +} from '@backstage/core-plugin-api/alpha'; +import i18next, { type i18n } from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import LanguageDetector from 'i18next-browser-languagedetector'; + +import { TranslationMessages } from '../../../alpha'; + +/** @alpha */ +export type ExperimentalI18n = { + supportedLanguages: string[]; + fallbackLanguage?: string | string[]; + messages?: Array<{ + ref: TranslationRef; + messages?: TranslationMessages; + lazyMessages: Record< + string, + () => Promise<{ messages: TranslationMessages }> + >; + }>; +}; + +/** @alpha */ +export class AppTranslationApiImpl implements AppTranslationApi { + static create(options?: ExperimentalI18n) { + const i18n = i18next.createInstance().use(initReactI18next); + + i18n.use(LanguageDetector); + + i18n.init({ + fallbackLng: options?.fallbackLanguage || 'en', + supportedLngs: options?.supportedLanguages || ['en'], + interpolation: { + escapeValue: false, + }, + react: { + bindI18n: 'loaded languageChanged', + }, + }); + + return new AppTranslationApiImpl(i18n, options); + } + + private readonly cache = new WeakSet(); + private readonly lazyCache = new WeakMap>(); + + getI18n() { + return this.i18n; + } + + initMessages(options?: ExperimentalI18n) { + if (options?.messages?.length) { + options.messages.forEach(appMessage => { + if (appMessage.messages) { + this.addResources(appMessage.ref, appMessage.messages); + } + + if (appMessage.lazyMessages) { + this.addLazyResources(appMessage.ref, appMessage.lazyMessages); + } + }); + } + } + + addResourcesByRef>( + translationRef: TranslationRef, + ): void { + this.addResources(translationRef); + this.addLazyResources(translationRef); + } + + addResources>( + translationRef: TranslationRef, + initResources?: TranslationMessages>, + ) { + const resources = initResources || translationRef.getResources(); + if (!resources || this.cache.has(translationRef)) { + return; + } + this.cache.add(translationRef); + Object.entries(resources).forEach(([language, messages]) => { + this.i18n.addResourceBundle( + language, + translationRef.getId(), + messages, + true, + false, + ); + }); + } + + addLazyResources>( + translationRef: TranslationRef, + initResources?: Record< + string, + () => Promise<{ messages: TranslationMessages }> + >, + ) { + let cache = this.lazyCache.get(translationRef); + + if (!cache) { + cache = new Set(); + this.lazyCache.set(translationRef, cache); + } + + const { + language: currentLanguage, + services, + options, + addResourceBundle, + reloadResources, + } = this.i18n; + + if (cache.has(currentLanguage)) { + return; + } + + const namespace = translationRef.getId(); + const lazyResources = initResources || translationRef.getLazyResources(); + + const fallbackLanguages = services.languageUtils.getFallbackCodes( + options.fallbackLng, + currentLanguage, + ) as string[]; + + Promise.allSettled( + [...fallbackLanguages, currentLanguage].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 = lazyResources?.[language]; + + if (!loadLazyResources) { + await loadBackend; + return; + } + + const [result] = await Promise.allSettled([ + loadLazyResources(), + loadBackend, + ]); + + if (result.status === 'rejected') { + throw result.reason; + } + + addResourceBundle( + language, + namespace, + result.value.messages, + true, + false, + ); + } + } + + private constructor(private readonly i18n: i18n, options?: ExperimentalI18n) { + this.initMessages(options); + } +} diff --git a/packages/core-app-api/src/apis/implementations/AppTranslationApi/index.ts b/packages/core-app-api/src/apis/implementations/AppTranslationApi/index.ts new file mode 100644 index 0000000000..91d76413d8 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/AppTranslationApi/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 * from './AppTranslationImpl'; diff --git a/packages/core-app-api/src/app/AppManager.test.tsx b/packages/core-app-api/src/app/AppManager.test.tsx index db0d3ea9af..1b68cfd6ca 100644 --- a/packages/core-app-api/src/app/AppManager.test.tsx +++ b/packages/core-app-api/src/app/AppManager.test.tsx @@ -260,6 +260,46 @@ describe('Integration Test', () => { expect(screen.getByText('extLink4: ')).toBeInTheDocument(); }); + it('runs success with __experimentalI18n', async () => { + const app = new AppManager({ + apis: [noOpAnalyticsApi], + defaultApis: [], + themes, + icons, + plugins: [], + components, + configLoader: async () => [], + bindRoutes: ({ bind }) => { + bind(plugin1.externalRoutes, { + extRouteRef1: plugin1RouteRef, + extRouteRef2: plugin2RouteRef, + }); + }, + __experimentalI18n: { + supportedLanguages: ['en'], + }, + }); + + const Provider = app.getProvider(); + const Router = app.getRouter(); + + await renderWithEffects( + + + + } /> + } /> + + + , + ); + + expect(screen.getByText('extLink1: /')).toBeInTheDocument(); + expect(screen.getByText('extLink2: /foo')).toBeInTheDocument(); + expect(screen.getByText('extLink3: ')).toBeInTheDocument(); + expect(screen.getByText('extLink4: ')).toBeInTheDocument(); + }); + it('should wait for the config to load before calling feature flags', async () => { const storageFlags = new LocalStorageFeatureFlags(); jest.spyOn(storageFlags, 'registerFlag'); diff --git a/packages/core-app-api/src/app/AppManager.tsx b/packages/core-app-api/src/app/AppManager.tsx index 470f7f8d75..de078c0ffc 100644 --- a/packages/core-app-api/src/app/AppManager.tsx +++ b/packages/core-app-api/src/app/AppManager.tsx @@ -42,6 +42,10 @@ import { BackstagePlugin, FeatureFlag, } from '@backstage/core-plugin-api'; +import { + AppTranslationApi, + appTranslationApiRef, +} from '@backstage/core-plugin-api/alpha'; import { ApiFactoryRegistry, ApiResolver } from '../apis/system'; import { childDiscoverer, @@ -75,6 +79,8 @@ import { resolveRouteBindings } from './resolveRouteBindings'; import { isReactRouterBeta } from './isReactRouterBeta'; import { InternalAppContext } from './InternalAppContext'; import { AppRouter, getBasePath } from './AppRouter'; +import { AppTranslationProvider } from './AppTranslationProvider'; +import { AppTranslationApiImpl } from '../apis/implementations/AppTranslationApi'; type CompatiblePlugin = | BackstagePlugin @@ -209,6 +215,7 @@ export class AppManager implements BackstageApp { private readonly configLoader?: AppConfigLoader; private readonly defaultApis: Iterable; private readonly bindRoutes: AppOptions['bindRoutes']; + private readonly appTranslationApi: AppTranslationApi; private readonly appIdentityProxy = new AppIdentityProxy(); private readonly apiFactoryRegistry: ApiFactoryRegistry; @@ -224,6 +231,9 @@ export class AppManager implements BackstageApp { this.defaultApis = options.defaultApis ?? []; this.bindRoutes = options.bindRoutes; this.apiFactoryRegistry = new ApiFactoryRegistry(); + this.appTranslationApi = AppTranslationApiImpl.create( + options.__experimentalI18n, + ); } getPlugins(): BackstagePlugin[] { @@ -379,24 +389,26 @@ export class AppManager implements BackstageApp { return ( - - - + + - {children} - - - + + {children} + + + + ); @@ -447,6 +459,11 @@ export class AppManager implements BackstageApp { deps: {}, factory: () => this.appIdentityProxy, }); + this.apiFactoryRegistry.register('static', { + api: appTranslationApiRef, + deps: {}, + factory: () => this.appTranslationApi, + }); // It's possible to replace the feature flag API, but since we must have at least // one implementation we add it here directly instead of through the defaultApis. diff --git a/packages/core-app-api/src/app/AppTranslationProvider.tsx b/packages/core-app-api/src/app/AppTranslationProvider.tsx new file mode 100644 index 0000000000..3d5e7e57e1 --- /dev/null +++ b/packages/core-app-api/src/app/AppTranslationProvider.tsx @@ -0,0 +1,27 @@ +/* + * 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}; +} diff --git a/packages/core-app-api/src/app/TranslationResource.ts b/packages/core-app-api/src/app/TranslationResource.ts new file mode 100644 index 0000000000..e5d34de587 --- /dev/null +++ b/packages/core-app-api/src/app/TranslationResource.ts @@ -0,0 +1,34 @@ +/* + * 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 { TranslationRef } from '@backstage/core-plugin-api/alpha'; + +/** @alpha */ +export type TranslationMessages = T extends TranslationRef + ? Record> + : never; + +/** @alpha */ +export function createTranslationResource(options: { + ref: T; + messages?: TranslationMessages; + lazyMessages: Record< + string, + () => Promise<{ messages: TranslationMessages }> + >; +}) { + return options; +} diff --git a/packages/core-app-api/src/app/types.ts b/packages/core-app-api/src/app/types.ts index 7f38e2d006..89104ebf12 100644 --- a/packages/core-app-api/src/app/types.ts +++ b/packages/core-app-api/src/app/types.ts @@ -27,6 +27,7 @@ import { FeatureFlag, } from '@backstage/core-plugin-api'; import { AppConfig } from '@backstage/config'; +import { TranslationRef } from '@backstage/core-plugin-api/alpha'; /** * Props for the `BootErrorPage` component of {@link AppComponents}. @@ -177,6 +178,16 @@ export type AppRouteBinder = < >, ) => void; +/** + * TODO: To be remove when TranslationMessages in packages/core-app-api/src/app/TranslationResource.ts + * come to be public + * + * @ignore + * */ +type TranslationMessages = T extends TranslationRef + ? Record> + : never; + /** * The options accepted by {@link createSpecializedApp}. * @@ -278,6 +289,23 @@ export type AppOptions = { * ``` */ bindRoutes?(context: { bind: AppRouteBinder }): void; + + /** + * TODO: Change to ExperimentalI18n type when packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.ts + * become to public + */ + __experimentalI18n?: { + supportedLanguages: string[]; + fallbackLanguage?: string | string[]; + messages?: Array<{ + ref: TranslationRef; + messages?: TranslationMessages; + lazyMessages: Record< + string, + () => Promise<{ messages: TranslationMessages }> + >; + }>; + }; }; /** diff --git a/packages/core-plugin-api/alpha-api-report.md b/packages/core-plugin-api/alpha-api-report.md index 379e186df8..57cf4a2cb4 100644 --- a/packages/core-plugin-api/alpha-api-report.md +++ b/packages/core-plugin-api/alpha-api-report.md @@ -3,9 +3,29 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { ApiRef } from '@backstage/core-plugin-api'; import { BackstagePlugin } from '@backstage/core-plugin-api'; +import { i18n } from 'i18next'; import { ReactNode } from 'react'; +// @alpha (undocumented) +export type AppTranslationApi = { + getI18n(): i18n; + addResourcesByRef>( + translationRef: TranslationRef, + ): void; +}; + +// @alpha (undocumented) +export const appTranslationApiRef: ApiRef; + +// @alpha (undocumented) +export const createTranslationRef: < + Messages extends Record = {}, +>( + config: TranslationRefConfig, +) => TranslationRef; + // @alpha export interface PluginOptionsProviderProps { // (undocumented) @@ -17,10 +37,92 @@ export interface PluginOptionsProviderProps { // @alpha export const PluginProvider: (props: PluginOptionsProviderProps) => JSX.Element; +// @alpha (undocumented) +export type TranslationOptions< + Messages extends Record = Record, +> = Messages; + +// @alpha (undocumented) +export interface TranslationRef< + Messages extends Record = Record, +> { + // (undocumented) + getDefaultMessages(): Messages; + // (undocumented) + getId(): string; + // (undocumented) + getLazyResources(): + | Record< + string, + () => Promise<{ + messages: Messages; + }> + > + | undefined; + // (undocumented) + getResources(): Record | undefined; +} + +// @alpha (undocumented) +export interface TranslationRefConfig< + Messages extends Record, +> { + // (undocumented) + id: string; + // (undocumented) + lazyResources?: Record< + string, + () => Promise<{ + messages: Messages; + }> + >; + // (undocumented) + messages: Messages; + // (undocumented) + resources?: Record; +} + +// @alpha (undocumented) +export class TranslationRefImpl> + implements TranslationRef +{ + // (undocumented) + static create>( + config: TranslationRefConfig, + ): TranslationRefImpl; + // (undocumented) + getDefaultMessages(): Messages; + // (undocumented) + getId(): string; + // (undocumented) + getLazyResources(): + | Record< + string, + () => Promise<{ + messages: Messages; + }> + > + | undefined; + // (undocumented) + getResources(): Record | undefined; + // (undocumented) + toString(): string; +} + // @alpha export function usePluginOptions< TPluginOptions extends {} = {}, >(): TPluginOptions; +// @alpha (undocumented) +export const useTranslationRef: < + Messages extends Record, +>( + translationRef: TranslationRef, +) => ( + key: Tkey, + options?: TranslationOptions, +) => Messages[Tkey]; + // (No @packageDocumentation comment for this package) ``` diff --git a/packages/core-plugin-api/package.json b/packages/core-plugin-api/package.json index 0c14d525a9..422a01b2f7 100644 --- a/packages/core-plugin-api/package.json +++ b/packages/core-plugin-api/package.json @@ -50,7 +50,9 @@ "@backstage/version-bridge": "workspace:^", "@types/react": "^16.13.1 || ^17.0.0", "history": "^5.0.0", + "i18next": "^22.4.15", "prop-types": "^15.7.2", + "react-i18next": "^12.3.1", "zen-observable": "^0.10.0" }, "peerDependencies": { diff --git a/packages/core-plugin-api/src/alpha.ts b/packages/core-plugin-api/src/alpha.ts index 09983a48e9..505fd9b818 100644 --- a/packages/core-plugin-api/src/alpha.ts +++ b/packages/core-plugin-api/src/alpha.ts @@ -15,3 +15,5 @@ */ export * from './plugin-options'; +export * from './translation'; +export * from './apis/alpha'; diff --git a/packages/core-plugin-api/src/apis/alpha.ts b/packages/core-plugin-api/src/apis/alpha.ts new file mode 100644 index 0000000000..b22d3df574 --- /dev/null +++ b/packages/core-plugin-api/src/apis/alpha.ts @@ -0,0 +1,16 @@ +/* + * 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 * from './definitions/alpha'; diff --git a/packages/core-plugin-api/src/apis/definitions/AppTranslationApi.ts b/packages/core-plugin-api/src/apis/definitions/AppTranslationApi.ts new file mode 100644 index 0000000000..463aee02bf --- /dev/null +++ b/packages/core-plugin-api/src/apis/definitions/AppTranslationApi.ts @@ -0,0 +1,35 @@ +/* + * 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 { type i18n } from 'i18next'; +import { TranslationRef } from '../../translation'; +import { ApiRef, createApiRef } from '@backstage/core-plugin-api'; + +/** @alpha */ +export type AppTranslationApi = { + getI18n(): i18n; + + addResourcesByRef>( + translationRef: TranslationRef, + ): void; +}; + +/** + * @alpha + */ +export const appTranslationApiRef: ApiRef = createApiRef({ + id: 'core.apptranslation', +}); diff --git a/packages/core-plugin-api/src/apis/definitions/alpha.ts b/packages/core-plugin-api/src/apis/definitions/alpha.ts new file mode 100644 index 0000000000..691af96e32 --- /dev/null +++ b/packages/core-plugin-api/src/apis/definitions/alpha.ts @@ -0,0 +1,16 @@ +/* + * 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 * from './AppTranslationApi'; diff --git a/packages/core-plugin-api/src/translation/TranslationRef.test.ts b/packages/core-plugin-api/src/translation/TranslationRef.test.ts new file mode 100644 index 0000000000..155624a122 --- /dev/null +++ b/packages/core-plugin-api/src/translation/TranslationRef.test.ts @@ -0,0 +1,60 @@ +/* + * 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 { TranslationRefImpl, createTranslationRef } from './TranslationRef'; + +describe('TranslationRefImpl', () => { + it('should create a TranslationRef instance', () => { + const config = { + id: 'testId', + messages: { key: 'value' }, + }; + + const translationRef = TranslationRefImpl.create(config); + + expect(translationRef.getId()).toBe('testId'); + expect(translationRef.getDefaultMessages()).toEqual({ key: 'value' }); + }); + + it('should create a TranslationRef instance using the factory function', () => { + const config = { + id: 'testId', + messages: { key: 'value' }, + }; + + const translationRef = createTranslationRef(config); + + expect(translationRef.getId()).toBe('testId'); + expect(translationRef.getDefaultMessages()).toEqual({ key: 'value' }); + }); + + it('should get lazy resources', async () => { + const config = { + id: 'testId', + messages: { key: 'value' }, + lazyResources: { + en: () => Promise.resolve({ messages: { key: 'value' } }), + }, + }; + + const translationRef = TranslationRefImpl.create(config); + + const lazyResources = translationRef.getLazyResources(); + + const messages = await lazyResources?.en(); + + expect(messages).toEqual({ messages: { key: 'value' } }); + }); +}); diff --git a/packages/core-plugin-api/src/translation/TranslationRef.ts b/packages/core-plugin-api/src/translation/TranslationRef.ts new file mode 100644 index 0000000000..d01f7ca261 --- /dev/null +++ b/packages/core-plugin-api/src/translation/TranslationRef.ts @@ -0,0 +1,61 @@ +/* + * 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 { TranslationRef, TranslationRefConfig } from './types'; + +/** @alpha */ +export class TranslationRefImpl> + implements TranslationRef +{ + static create>( + config: TranslationRefConfig, + ) { + return new TranslationRefImpl(config); + } + + getId() { + return this.config.id; + } + + getDefaultMessages(): Messages { + return this.config.messages; + } + + getLazyResources(): + | Record Promise<{ messages: Messages }>> + | undefined { + return this.config.lazyResources; + } + + getResources(): Record | undefined { + return this.config.resources; + } + + toString() { + return `TranslationRef(${this.getId()})`; + } + + private constructor( + private readonly config: TranslationRefConfig, + ) {} +} + +/** @alpha */ +export const createTranslationRef = < + Messages extends Record = {}, +>( + config: TranslationRefConfig, +): TranslationRef => TranslationRefImpl.create(config); diff --git a/packages/core-plugin-api/src/translation/index.ts b/packages/core-plugin-api/src/translation/index.ts new file mode 100644 index 0000000000..9eaef57206 --- /dev/null +++ b/packages/core-plugin-api/src/translation/index.ts @@ -0,0 +1,19 @@ +/* + * 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 * from './TranslationRef'; +export * from './types'; +export * from './useTranslationRef'; diff --git a/packages/core-plugin-api/src/translation/types.ts b/packages/core-plugin-api/src/translation/types.ts new file mode 100644 index 0000000000..4811c82eb7 --- /dev/null +++ b/packages/core-plugin-api/src/translation/types.ts @@ -0,0 +1,45 @@ +/* + * 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. + */ + +/** @alpha */ +export interface TranslationRefConfig< + Messages extends Record, +> { + id: string; + messages: Messages; + lazyResources?: Record Promise<{ messages: Messages }>>; + resources?: Record; +} + +/** @alpha */ +export interface TranslationRef< + Messages extends Record = Record, +> { + getId(): string; + + getDefaultMessages(): Messages; + + getResources(): Record | undefined; + + getLazyResources(): + | Record Promise<{ messages: Messages }>> + | undefined; +} + +/** @alpha */ +export type TranslationOptions< + Messages extends Record = Record, +> = Messages; diff --git a/packages/core-plugin-api/src/translation/useTranslationRef.test.ts b/packages/core-plugin-api/src/translation/useTranslationRef.test.ts new file mode 100644 index 0000000000..372d72eb23 --- /dev/null +++ b/packages/core-plugin-api/src/translation/useTranslationRef.test.ts @@ -0,0 +1,68 @@ +/* + * 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({ + addResourcesByRef: 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.ts b/packages/core-plugin-api/src/translation/useTranslationRef.ts new file mode 100644 index 0000000000..083bcd1f0e --- /dev/null +++ b/packages/core-plugin-api/src/translation/useTranslationRef.ts @@ -0,0 +1,41 @@ +/* + * 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 { useTranslation } from 'react-i18next'; + +import { TranslationOptions, TranslationRef } from './types'; +import { useApi } from '../apis'; +import { appTranslationApiRef } from '../apis/alpha'; + +/** @alpha */ +export const useTranslationRef = < + Messages extends Record, +>( + translationRef: TranslationRef, +) => { + const appTranslationApi = useApi(appTranslationApiRef); + + appTranslationApi.addResourcesByRef(translationRef); + + const { t } = useTranslation(translationRef.getId()); + + const defaulteMessage = translationRef.getDefaultMessages(); + + return ( + key: Tkey, + options?: TranslationOptions, + ): Messages[Tkey] => t(key as string, defaulteMessage[key], options); +}; diff --git a/plugins/user-settings/alpha-api-report.md b/plugins/user-settings/alpha-api-report.md new file mode 100644 index 0000000000..925d387882 --- /dev/null +++ b/plugins/user-settings/alpha-api-report.md @@ -0,0 +1,26 @@ +## API Report File for "@backstage/plugin-user-settings" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { TranslationRef } from '@backstage/core-plugin-api/alpha'; + +// @alpha (undocumented) +export const userSettingsTranslationRef: TranslationRef<{ + language: string; + change_the_language: string; + theme: string; + theme_light: string; + theme_dark: string; + theme_auto: string; + change_the_theme_mode: string; + select_theme_light: string; + select_theme_dark: string; + select_theme_auto: string; + select_theme_custom: string; + lng: string; + select_lng: string; +}>; + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/user-settings/api-report.md b/plugins/user-settings/api-report.md index e19935557c..cdb576c45c 100644 --- a/plugins/user-settings/api-report.md +++ b/plugins/user-settings/api-report.md @@ -93,6 +93,9 @@ export const UserSettingsGeneral: () => React_2.JSX.Element; // @public (undocumented) export const UserSettingsIdentityCard: () => React_2.JSX.Element; +// @public (undocumented) +export const UserSettingsLanguageToggle: () => React_2.JSX.Element | null; + // @public (undocumented) export const UserSettingsMenu: () => React_2.JSX.Element; diff --git a/plugins/user-settings/package.json b/plugins/user-settings/package.json index 4012c72081..6a8e6bda14 100644 --- a/plugins/user-settings/package.json +++ b/plugins/user-settings/package.json @@ -5,10 +5,23 @@ "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", + "exports": { + ".": "./src/index.ts", + "./alpha": "./src/alpha.ts", + "./package.json": "./package.json" + }, + "typesVersions": { + "*": { + "alpha": [ + "src/alpha.ts" + ], + "package.json": [ + "package.json" + ] + } + }, "publishConfig": { - "access": "public", - "main": "dist/index.esm.js", - "types": "dist/index.d.ts" + "access": "public" }, "backstage": { "role": "frontend-plugin" @@ -43,6 +56,7 @@ "@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/alpha.ts b/plugins/user-settings/src/alpha.ts new file mode 100644 index 0000000000..e1f7678bae --- /dev/null +++ b/plugins/user-settings/src/alpha.ts @@ -0,0 +1,16 @@ +/* + * 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 * from './translation'; diff --git a/plugins/user-settings/src/components/General/UserSettingsAppearanceCard.tsx b/plugins/user-settings/src/components/General/UserSettingsAppearanceCard.tsx index 6b61edb83b..8a32c0bd9b 100644 --- a/plugins/user-settings/src/components/General/UserSettingsAppearanceCard.tsx +++ b/plugins/user-settings/src/components/General/UserSettingsAppearanceCard.tsx @@ -19,6 +19,7 @@ import { List } from '@material-ui/core'; import React from 'react'; import { UserSettingsPinToggle } from './UserSettingsPinToggle'; import { UserSettingsThemeToggle } from './UserSettingsThemeToggle'; +import { UserSettingsLanguageToggle } from './UserSettingsLanguageToggle'; /** @public */ export const UserSettingsAppearanceCard = () => { @@ -28,6 +29,7 @@ export const UserSettingsAppearanceCard = () => { + {!isMobile && } diff --git a/plugins/user-settings/src/components/General/UserSettingsLanguageToggle.test.tsx b/plugins/user-settings/src/components/General/UserSettingsLanguageToggle.test.tsx new file mode 100644 index 0000000000..bb0e778993 --- /dev/null +++ b/plugins/user-settings/src/components/General/UserSettingsLanguageToggle.test.tsx @@ -0,0 +1,125 @@ +/* + * 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 { render, screen, fireEvent } from '@testing-library/react'; +import { useTranslationRef } from '@backstage/core-plugin-api/alpha'; +import { UserSettingsLanguageToggle } from './UserSettingsLanguageToggle'; +import { wrapInTestApp } 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(), +})); + +describe('UserSettingsLanguageToggle', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render correctly with multiple supported languages', () => { + const messages: Record = { + en: 'English', + fr: 'French', + de: 'German', + language: 'language', + change_the_language: 'Change the language', + }; + + 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', + ); + + render(wrapInTestApp()); + + 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); + }); + + it('should not render when only one supported language', () => { + const tMock = jest.fn().mockReturnValue('translatedValue'); + const i18nMock = { + language: 'en', + options: { + supportedLngs: ['en'], + }, + changeLanguage: jest.fn(), + }; + + (useTranslationRef as jest.Mock).mockReturnValue(tMock); + + (useTranslation as jest.Mock).mockReturnValue({ + i18n: i18nMock, + }); + + render(wrapInTestApp()); + + expect(screen.queryByText('translatedValue')).toBeNull(); + expect(screen.queryByText('English')).toBeNull(); + }); + + it('should handle language change', () => { + const messages: Record = { + en: 'English', + fr: 'French', + language: 'language', + change_the_language: 'Change the language', + }; + + 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', + ); + + (useTranslation as jest.Mock).mockReturnValue({ + i18n: i18nMock, + }); + + render(wrapInTestApp()); + + fireEvent.click(screen.getByText('French')); + + expect(i18nMock.changeLanguage).toHaveBeenCalledWith('fr'); + }); +}); diff --git a/plugins/user-settings/src/components/General/UserSettingsLanguageToggle.tsx b/plugins/user-settings/src/components/General/UserSettingsLanguageToggle.tsx new file mode 100644 index 0000000000..3c5772ab16 --- /dev/null +++ b/plugins/user-settings/src/components/General/UserSettingsLanguageToggle.tsx @@ -0,0 +1,149 @@ +/* + * 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 React, { useMemo } from 'react'; +import { useTranslationRef } from '@backstage/core-plugin-api/alpha'; +import ToggleButton from '@material-ui/lab/ToggleButton'; +import ToggleButtonGroup from '@material-ui/lab/ToggleButtonGroup'; +import { + ListItem, + ListItemText, + ListItemSecondaryAction, + Tooltip, + makeStyles, +} from '@material-ui/core'; +import { userSettingsTranslationRef } from '../../translation'; +import { useTranslation } from 'react-i18next'; + +type TooltipToggleButtonProps = { + children: JSX.Element; + title: string; + value: string; +}; + +const useStyles = makeStyles(theme => ({ + container: { + display: 'flex', + flexWrap: 'wrap', + width: '100%', + justifyContent: 'space-between', + alignItems: 'center', + paddingBottom: 8, + paddingRight: 16, + }, + list: { + width: 'initial', + [theme.breakpoints.down('xs')]: { + width: '100%', + padding: `0 0 12px`, + }, + }, + listItemText: { + paddingRight: 0, + paddingLeft: 0, + }, + listItemSecondaryAction: { + position: 'relative', + transform: 'unset', + top: 'auto', + right: 'auto', + paddingLeft: 16, + [theme.breakpoints.down('xs')]: { + paddingLeft: 0, + }, + }, +})); + +// ToggleButtonGroup uses React.children.map instead of context +// so wrapping with Tooltip breaks ToggleButton functionality. +const TooltipToggleButton = ({ + children, + title, + value, + ...props +}: TooltipToggleButtonProps) => ( + + + {children} + + +); + +/** @public */ +export const UserSettingsLanguageToggle = () => { + const classes = useStyles(); + const { i18n } = useTranslation(); + const t = useTranslationRef(userSettingsTranslationRef); + + const supportedLngs = useMemo( + () => (i18n.options.supportedLngs || []).filter(lng => lng !== 'cimode'), + [i18n], + ); + + if (supportedLngs.length <= 1) { + return null; + } + + const handleSetLanguage = ( + _event: React.MouseEvent, + newLanguage: string | undefined, + ) => { + if (supportedLngs.some(it => it === newLanguage)) { + i18n.changeLanguage(newLanguage); + } else { + i18n.changeLanguage(undefined); + } + }; + + return ( + + + + + {supportedLngs.map(lng => { + return ( + + <> + {t('lng', { + language: lng, + })} + + + ); + })} + + + + ); +}; diff --git a/plugins/user-settings/src/components/General/UserSettingsThemeToggle.test.tsx b/plugins/user-settings/src/components/General/UserSettingsThemeToggle.test.tsx index 09b27cb559..48d16a9fde 100644 --- a/plugins/user-settings/src/components/General/UserSettingsThemeToggle.test.tsx +++ b/plugins/user-settings/src/components/General/UserSettingsThemeToggle.test.tsx @@ -15,6 +15,7 @@ */ import { AppTheme, appThemeApiRef } from '@backstage/core-plugin-api'; +import { useTranslationRef } from '@backstage/core-plugin-api/alpha'; import { renderWithEffects, TestApiRegistry, @@ -27,6 +28,7 @@ import { fireEvent } from '@testing-library/react'; import React from 'react'; import { UserSettingsThemeToggle } from './UserSettingsThemeToggle'; import { ApiProvider, AppThemeSelector } from '@backstage/core-app-api'; +import { userSettingsTranslationRef } from '../../translation'; const mockTheme: AppTheme = { id: 'light-theme', @@ -39,6 +41,11 @@ const mockTheme: AppTheme = { ), }; +jest.mock('@backstage/core-plugin-api/alpha', () => ({ + ...jest.requireActual('@backstage/core-plugin-api/alpha'), + useTranslationRef: jest.fn(), +})); + const apiRegistry = TestApiRegistry.from([ appThemeApiRef, AppThemeSelector.createWithStorage([mockTheme]), @@ -47,6 +54,16 @@ const apiRegistry = TestApiRegistry.from([ describe('', () => { it('toggles the theme select button', async () => { const themeApi = apiRegistry.get(appThemeApiRef); + // todo: general test provider + const messages: Record = + userSettingsTranslationRef.getDefaultMessages(); + + const useTranslationRefMock = jest + .fn() + .mockReturnValue((key: string) => messages[key]); + + (useTranslationRef as jest.Mock).mockImplementation(useTranslationRefMock); + const rendered = await renderWithEffects( wrapInTestApp( @@ -57,7 +74,7 @@ describe('', () => { expect(rendered.getByText('Theme')).toBeInTheDocument(); - const themeButton = rendered.getByTitle('Select Mock Theme'); + const themeButton = rendered.getByText('Mock Theme'); expect(themeApi?.getActiveThemeId()).toBe(undefined); fireEvent.click(themeButton); expect(themeApi?.getActiveThemeId()).toBe('light-theme'); diff --git a/plugins/user-settings/src/components/General/UserSettingsThemeToggle.tsx b/plugins/user-settings/src/components/General/UserSettingsThemeToggle.tsx index 564cce205d..42490ec118 100644 --- a/plugins/user-settings/src/components/General/UserSettingsThemeToggle.tsx +++ b/plugins/user-settings/src/components/General/UserSettingsThemeToggle.tsx @@ -27,6 +27,8 @@ import { makeStyles, } from '@material-ui/core'; import { appThemeApiRef, useApi } from '@backstage/core-plugin-api'; +import { useTranslationRef } from '@backstage/core-plugin-api/alpha'; +import { userSettingsTranslationRef } from '../../translation'; type ThemeIconProps = { id: string; @@ -101,18 +103,20 @@ const TooltipToggleButton = ({ export const UserSettingsThemeToggle = () => { const classes = useStyles(); const appThemeApi = useApi(appThemeApiRef); - const themeId = useObservable( + const activeThemeId = useObservable( appThemeApi.activeThemeId$(), appThemeApi.getActiveThemeId(), ); const themeIds = appThemeApi.getInstalledThemes(); + const t = useTranslationRef(userSettingsTranslationRef); + const handleSetTheme = ( _event: React.MouseEvent, newThemeId: string | undefined, ) => { - if (themeIds.some(t => t.id === newThemeId)) { + if (themeIds.some(it => it.id === newThemeId)) { appThemeApi.setActiveThemeId(newThemeId); } else { appThemeApi.setActiveThemeId(undefined); @@ -126,26 +130,31 @@ export const UserSettingsThemeToggle = () => { > {themeIds.map(theme => { - const themeIcon = themeIds.find(t => t.id === theme.id)?.icon; + const themeIcon = themeIds.find(it => it.id === theme.id)?.icon; + const themeId = theme.id as 'light' | 'dark'; return ( <> - {theme.title}  + {theme.title || t(`theme_${themeId}`)}  { ); })} - - - Auto  - + + + {t('theme_auto')}  + diff --git a/plugins/user-settings/src/components/General/index.ts b/plugins/user-settings/src/components/General/index.ts index ff4d1210c4..d21355a970 100644 --- a/plugins/user-settings/src/components/General/index.ts +++ b/plugins/user-settings/src/components/General/index.ts @@ -22,3 +22,4 @@ export { UserSettingsAppearanceCard } from './UserSettingsAppearanceCard'; export { UserSettingsThemeToggle } from './UserSettingsThemeToggle'; export { UserSettingsPinToggle } from './UserSettingsPinToggle'; export { UserSettingsIdentityCard } from './UserSettingsIdentityCard'; +export { UserSettingsLanguageToggle } from './UserSettingsLanguageToggle'; diff --git a/plugins/user-settings/src/translation.ts b/plugins/user-settings/src/translation.ts new file mode 100644 index 0000000000..920f499082 --- /dev/null +++ b/plugins/user-settings/src/translation.ts @@ -0,0 +1,37 @@ +/* + * 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 { createTranslationRef } from '@backstage/core-plugin-api/alpha'; + +/** @alpha */ +export const userSettingsTranslationRef = createTranslationRef({ + id: 'user-settings', + messages: { + language: 'Language', + change_the_language: 'Change the language', + theme: 'Theme', + theme_light: 'Light', + theme_dark: 'Dark', + theme_auto: 'Auto', + change_the_theme_mode: 'Change the theme mode', + select_theme_light: 'Select light', + select_theme_dark: 'Select dark', + select_theme_auto: 'Select Auto Theme', + select_theme_custom: 'Select {{custom}}', + lng: '{{language}}', + select_lng: 'Select language {{language}}', + }, +}); diff --git a/yarn.lock b/yarn.lock index 5beb427570..5e3a19ba2c 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.2.0, @babel/runtime@npm:^7.20.1, @babel/runtime@npm:^7.20.13, @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.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": version: 7.22.11 resolution: "@babel/runtime@npm:7.22.11" dependencies: @@ -3987,8 +3987,11 @@ __metadata: "@types/react": ^16.13.1 || ^17.0.0 "@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" @@ -4217,8 +4220,10 @@ __metadata: "@types/react": ^16.13.1 || ^17.0.0 "@types/zen-observable": ^0.8.0 history: ^5.0.0 + 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 @@ -9801,6 +9806,7 @@ __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: @@ -28084,6 +28090,15 @@ __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" @@ -28329,6 +28344,24 @@ __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" + dependencies: + "@babel/runtime": ^7.20.6 + checksum: 175f8ab7fac2abcee147b00cc2d8e7d4fa9b05cdc227f02cac841fc2fd9545ed4a6d88774f594f8ad12dc944e4d34cc8e88aa00c8b9947baef9e859d93abd305 + languageName: node + linkType: hard + "iconv-lite@npm:0.4.24, iconv-lite@npm:^0.4.24": version: 0.4.24 resolution: "iconv-lite@npm:0.4.24" @@ -37098,6 +37131,24 @@ __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" @@ -42434,6 +42485,13 @@ __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"