feat: Introduced experimental support for internationalization.

Signed-off-by: rui ma <ruima@alauda.io>
This commit is contained in:
rui ma
2023-09-04 23:51:08 +08:00
parent 155901d486
commit 6e30769cc6
38 changed files with 1698 additions and 37 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/core-plugin-api': minor
'@backstage/core-app-api': minor
'@backstage/plugin-user-settings': patch
---
Introduced experimental support for internationalization.
+61
View File
@@ -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 (
<ListItemText
className={classes.listItemText}
primary={t('language')}
secondary={t('change_the_language')}
/>
);
```
## 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',
+ },
+ },
+ }),
+ ],
+ },
...
})
```
+82
View File
@@ -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<Messages extends Record<string, string>>(
translationRef: TranslationRef<Messages>,
initResources?: Record<
string,
() => Promise<{
messages: TranslationMessages<TranslationRef>;
}>
>,
): void;
// (undocumented)
addResources<Messages extends Record<string, string>>(
translationRef: TranslationRef<Messages>,
initResources?: TranslationMessages<TranslationRef<Messages>>,
): void;
// (undocumented)
addResourcesByRef<Messages extends Record<string, string>>(
translationRef: TranslationRef<Messages>,
): void;
// (undocumented)
static create(options?: ExperimentalI18n): AppTranslationApiImpl;
// (undocumented)
getI18n(): i18n;
// (undocumented)
initMessages(options?: ExperimentalI18n): void;
}
// @alpha (undocumented)
export function createTranslationResource<T extends TranslationRef>(options: {
ref: T;
messages?: TranslationMessages<T>;
lazyMessages: Record<
string,
() => Promise<{
messages: TranslationMessages<T>;
}>
>;
}): {
ref: T;
messages?: TranslationMessages<T> | undefined;
lazyMessages: Record<
string,
() => Promise<{
messages: TranslationMessages<T>;
}>
>;
};
// @alpha (undocumented)
export type ExperimentalI18n = {
supportedLanguages: string[];
fallbackLanguage?: string | string[];
messages?: Array<{
ref: TranslationRef;
messages?: TranslationMessages<TranslationRef>;
lazyMessages: Record<
string,
() => Promise<{
messages: TranslationMessages<TranslationRef>;
}>
>;
}>;
};
// @alpha (undocumented)
export type TranslationMessages<T> = T extends TranslationRef<infer R>
? Record<string, Partial<R>>
: never;
// (No @packageDocumentation comment for this package)
```
+15
View File
@@ -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<AppTheme> & Omit<AppTheme, 'theme'>)[];
configLoader?: AppConfigLoader;
bindRoutes?(context: { bind: AppRouteBinder }): void;
__experimentalI18n?: {
supportedLanguages: string[];
fallbackLanguage?: string | string[];
messages?: Array<{
ref: TranslationRef;
messages?: TranslationMessages<TranslationRef>;
lazyMessages: Record<
string,
() => Promise<{
messages: TranslationMessages<TranslationRef>;
}>
>;
}>;
};
};
// @public
+19 -3
View File
@@ -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"
+17
View File
@@ -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';
@@ -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,
);
});
});
});
@@ -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<TranslationRef>;
lazyMessages: Record<
string,
() => Promise<{ messages: TranslationMessages<TranslationRef> }>
>;
}>;
};
/** @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<TranslationRef>();
private readonly lazyCache = new WeakMap<TranslationRef, Set<string>>();
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<Messages extends Record<string, string>>(
translationRef: TranslationRef<Messages>,
): void {
this.addResources(translationRef);
this.addLazyResources(translationRef);
}
addResources<Messages extends Record<string, string>>(
translationRef: TranslationRef<Messages>,
initResources?: TranslationMessages<TranslationRef<Messages>>,
) {
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<Messages extends Record<string, string>>(
translationRef: TranslationRef<Messages>,
initResources?: Record<
string,
() => Promise<{ messages: TranslationMessages<TranslationRef> }>
>,
) {
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<void> | 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);
}
}
@@ -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';
@@ -260,6 +260,46 @@ describe('Integration Test', () => {
expect(screen.getByText('extLink4: <none>')).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(
<Provider>
<Router>
<Routes>
<Route path="/" element={<ExposedComponent />} />
<Route path="/foo" element={<HiddenComponent />} />
</Routes>
</Router>
</Provider>,
);
expect(screen.getByText('extLink1: /')).toBeInTheDocument();
expect(screen.getByText('extLink2: /foo')).toBeInTheDocument();
expect(screen.getByText('extLink3: <none>')).toBeInTheDocument();
expect(screen.getByText('extLink4: <none>')).toBeInTheDocument();
});
it('should wait for the config to load before calling feature flags', async () => {
const storageFlags = new LocalStorageFeatureFlags();
jest.spyOn(storageFlags, 'registerFlag');
+34 -17
View File
@@ -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<AnyApiFactory>;
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 (
<ApiProvider apis={this.getApiHolder()}>
<AppContextProvider appContext={appContext}>
<ThemeProvider>
<RoutingProvider
routePaths={routing.paths}
routeParents={routing.parents}
routeObjects={routing.objects}
routeBindings={routeBindings}
basePath={getBasePath(loadedConfig.api)}
>
<InternalAppContext.Provider
value={{
routeObjects: routing.objects,
appIdentityProxy: this.appIdentityProxy,
}}
<AppTranslationProvider>
<ThemeProvider>
<RoutingProvider
routePaths={routing.paths}
routeParents={routing.parents}
routeObjects={routing.objects}
routeBindings={routeBindings}
basePath={getBasePath(loadedConfig.api)}
>
{children}
</InternalAppContext.Provider>
</RoutingProvider>
</ThemeProvider>
<InternalAppContext.Provider
value={{
routeObjects: routing.objects,
appIdentityProxy: this.appIdentityProxy,
}}
>
{children}
</InternalAppContext.Provider>
</RoutingProvider>
</ThemeProvider>
</AppTranslationProvider>
</AppContextProvider>
</ApiProvider>
);
@@ -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.
@@ -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 <I18nextProvider i18n={i18n}>{children}</I18nextProvider>;
}
@@ -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> = T extends TranslationRef<infer R>
? Record<string, Partial<R>>
: never;
/** @alpha */
export function createTranslationResource<T extends TranslationRef>(options: {
ref: T;
messages?: TranslationMessages<T>;
lazyMessages: Record<
string,
() => Promise<{ messages: TranslationMessages<T> }>
>;
}) {
return options;
}
+28
View File
@@ -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> = T extends TranslationRef<infer R>
? Record<string, Partial<R>>
: 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<TranslationRef>;
lazyMessages: Record<
string,
() => Promise<{ messages: TranslationMessages<TranslationRef> }>
>;
}>;
};
};
/**
@@ -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<Messages extends Record<string, string>>(
translationRef: TranslationRef<Messages>,
): void;
};
// @alpha (undocumented)
export const appTranslationApiRef: ApiRef<AppTranslationApi>;
// @alpha (undocumented)
export const createTranslationRef: <
Messages extends Record<keyof Messages, string> = {},
>(
config: TranslationRefConfig<Messages>,
) => TranslationRef<Messages>;
// @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<keyof Messages, string> = Record<string, string>,
> = Messages;
// @alpha (undocumented)
export interface TranslationRef<
Messages extends Record<keyof Messages, string> = Record<string, string>,
> {
// (undocumented)
getDefaultMessages(): Messages;
// (undocumented)
getId(): string;
// (undocumented)
getLazyResources():
| Record<
string,
() => Promise<{
messages: Messages;
}>
>
| undefined;
// (undocumented)
getResources(): Record<string, Messages> | undefined;
}
// @alpha (undocumented)
export interface TranslationRefConfig<
Messages extends Record<keyof Messages, string>,
> {
// (undocumented)
id: string;
// (undocumented)
lazyResources?: Record<
string,
() => Promise<{
messages: Messages;
}>
>;
// (undocumented)
messages: Messages;
// (undocumented)
resources?: Record<string, Messages>;
}
// @alpha (undocumented)
export class TranslationRefImpl<Messages extends Record<keyof Messages, string>>
implements TranslationRef<Messages>
{
// (undocumented)
static create<Messages extends Record<keyof Messages, string>>(
config: TranslationRefConfig<Messages>,
): TranslationRefImpl<Messages>;
// (undocumented)
getDefaultMessages(): Messages;
// (undocumented)
getId(): string;
// (undocumented)
getLazyResources():
| Record<
string,
() => Promise<{
messages: Messages;
}>
>
| undefined;
// (undocumented)
getResources(): Record<string, Messages> | undefined;
// (undocumented)
toString(): string;
}
// @alpha
export function usePluginOptions<
TPluginOptions extends {} = {},
>(): TPluginOptions;
// @alpha (undocumented)
export const useTranslationRef: <
Messages extends Record<keyof Messages, string>,
>(
translationRef: TranslationRef<Messages>,
) => <Tkey extends keyof Messages>(
key: Tkey,
options?: TranslationOptions,
) => Messages[Tkey];
// (No @packageDocumentation comment for this package)
```
+2
View File
@@ -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": {
+2
View File
@@ -15,3 +15,5 @@
*/
export * from './plugin-options';
export * from './translation';
export * from './apis/alpha';
@@ -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';
@@ -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<Messages extends Record<string, string>>(
translationRef: TranslationRef<Messages>,
): void;
};
/**
* @alpha
*/
export const appTranslationApiRef: ApiRef<AppTranslationApi> = createApiRef({
id: 'core.apptranslation',
});
@@ -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';
@@ -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' } });
});
});
@@ -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<Messages extends Record<keyof Messages, string>>
implements TranslationRef<Messages>
{
static create<Messages extends Record<keyof Messages, string>>(
config: TranslationRefConfig<Messages>,
) {
return new TranslationRefImpl(config);
}
getId() {
return this.config.id;
}
getDefaultMessages(): Messages {
return this.config.messages;
}
getLazyResources():
| Record<string, () => Promise<{ messages: Messages }>>
| undefined {
return this.config.lazyResources;
}
getResources(): Record<string, Messages> | undefined {
return this.config.resources;
}
toString() {
return `TranslationRef(${this.getId()})`;
}
private constructor(
private readonly config: TranslationRefConfig<Messages>,
) {}
}
/** @alpha */
export const createTranslationRef = <
Messages extends Record<keyof Messages, string> = {},
>(
config: TranslationRefConfig<Messages>,
): TranslationRef<Messages> => TranslationRefImpl.create(config);
@@ -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';
@@ -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<keyof Messages, string>,
> {
id: string;
messages: Messages;
lazyResources?: Record<string, () => Promise<{ messages: Messages }>>;
resources?: Record<string, Messages>;
}
/** @alpha */
export interface TranslationRef<
Messages extends Record<keyof Messages, string> = Record<string, string>,
> {
getId(): string;
getDefaultMessages(): Messages;
getResources(): Record<string, Messages> | undefined;
getLazyResources():
| Record<string, () => Promise<{ messages: Messages }>>
| undefined;
}
/** @alpha */
export type TranslationOptions<
Messages extends Record<keyof Messages, string> = Record<string, string>,
> = Messages;
@@ -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);
});
});
@@ -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<keyof Messages, string>,
>(
translationRef: TranslationRef<Messages>,
) => {
const appTranslationApi = useApi(appTranslationApiRef);
appTranslationApi.addResourcesByRef(translationRef);
const { t } = useTranslation(translationRef.getId());
const defaulteMessage = translationRef.getDefaultMessages();
return <Tkey extends keyof Messages>(
key: Tkey,
options?: TranslationOptions,
): Messages[Tkey] => t(key as string, defaulteMessage[key], options);
};
+26
View File
@@ -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)
```
+3
View File
@@ -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;
+17 -3
View File
@@ -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"
},
+16
View File
@@ -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';
@@ -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 = () => {
<InfoCard title="Appearance" variant="gridItem">
<List dense>
<UserSettingsThemeToggle />
<UserSettingsLanguageToggle />
{!isMobile && <UserSettingsPinToggle />}
</List>
</InfoCard>
@@ -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<string, string> = {
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(<UserSettingsLanguageToggle />));
expect(screen.getAllByText('Change the language')).toHaveLength(1);
expect(screen.getAllByText('English')).toHaveLength(1);
expect(screen.getAllByText('French')).toHaveLength(1);
expect(screen.getAllByText('German')).toHaveLength(1);
});
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(<UserSettingsLanguageToggle />));
expect(screen.queryByText('translatedValue')).toBeNull();
expect(screen.queryByText('English')).toBeNull();
});
it('should handle language change', () => {
const messages: Record<string, string> = {
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(<UserSettingsLanguageToggle />));
fireEvent.click(screen.getByText('French'));
expect(i18nMock.changeLanguage).toHaveBeenCalledWith('fr');
});
});
@@ -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) => (
<Tooltip placement="top" arrow title={title}>
<ToggleButton value={value} {...props}>
{children}
</ToggleButton>
</Tooltip>
);
/** @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<HTMLElement>,
newLanguage: string | undefined,
) => {
if (supportedLngs.some(it => it === newLanguage)) {
i18n.changeLanguage(newLanguage);
} else {
i18n.changeLanguage(undefined);
}
};
return (
<ListItem
className={classes.list}
classes={{ container: classes.container }}
>
<ListItemText
className={classes.listItemText}
primary={t('language')}
secondary={t('change_the_language')}
/>
<ListItemSecondaryAction className={classes.listItemSecondaryAction}>
<ToggleButtonGroup
exclusive
size="small"
value={i18n.language}
onChange={handleSetLanguage}
>
{supportedLngs.map(lng => {
return (
<TooltipToggleButton
key={lng}
title={t('select_lng', {
language: lng,
})}
value={lng}
>
<>
{t('lng', {
language: lng,
})}
</>
</TooltipToggleButton>
);
})}
</ToggleButtonGroup>
</ListItemSecondaryAction>
</ListItem>
);
};
@@ -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('<UserSettingsThemeToggle />', () => {
it('toggles the theme select button', async () => {
const themeApi = apiRegistry.get(appThemeApiRef);
// todo: general test provider
const messages: Record<string, string> =
userSettingsTranslationRef.getDefaultMessages();
const useTranslationRefMock = jest
.fn()
.mockReturnValue((key: string) => messages[key]);
(useTranslationRef as jest.Mock).mockImplementation(useTranslationRefMock);
const rendered = await renderWithEffects(
wrapInTestApp(
<ApiProvider apis={apiRegistry}>
@@ -57,7 +74,7 @@ describe('<UserSettingsThemeToggle />', () => {
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');
@@ -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<HTMLElement>,
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 = () => {
>
<ListItemText
className={classes.listItemText}
primary="Theme"
secondary="Change the theme mode"
primary={t('theme')}
secondary={t('change_the_theme_mode')}
/>
<ListItemSecondaryAction className={classes.listItemSecondaryAction}>
<ToggleButtonGroup
exclusive
size="small"
value={themeId ?? 'auto'}
value={activeThemeId ?? 'auto'}
onChange={handleSetTheme}
>
{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 (
<TooltipToggleButton
key={theme.id}
title={`Select ${theme.title}`}
title={
theme.title
? t('select_theme_custom', { custom: theme.title })
: t(`select_theme_${themeId}`)
}
value={theme.id}
>
<>
{theme.title}&nbsp;
{theme.title || t(`theme_${themeId}`)}&nbsp;
<ThemeIcon
id={theme.id}
icon={themeIcon}
@@ -155,10 +164,12 @@ export const UserSettingsThemeToggle = () => {
</TooltipToggleButton>
);
})}
<Tooltip placement="top" arrow title="Select Auto Theme">
<ToggleButton value="auto" selected={themeId === undefined}>
Auto&nbsp;
<AutoIcon color={themeId === undefined ? 'primary' : undefined} />
<Tooltip placement="top" arrow title={t('select_theme_auto')}>
<ToggleButton value="auto" selected={activeThemeId === undefined}>
{t('theme_auto')}&nbsp;
<AutoIcon
color={activeThemeId === undefined ? 'primary' : undefined}
/>
</ToggleButton>
</Tooltip>
</ToggleButtonGroup>
@@ -22,3 +22,4 @@ export { UserSettingsAppearanceCard } from './UserSettingsAppearanceCard';
export { UserSettingsThemeToggle } from './UserSettingsThemeToggle';
export { UserSettingsPinToggle } from './UserSettingsPinToggle';
export { UserSettingsIdentityCard } from './UserSettingsIdentityCard';
export { UserSettingsLanguageToggle } from './UserSettingsLanguageToggle';
+37
View File
@@ -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}}',
},
});
+59 -1
View File
@@ -3305,7 +3305,7 @@ __metadata:
languageName: node
linkType: hard
"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.14.6, @babel/runtime@npm:^7.15.4, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.18.6, @babel/runtime@npm:^7.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"