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"