feat: allow using languages in createDevApp

This allows plugin developers to verify their translations on the fly
in dev app.

Relates also to #26127

Closes #26161

Signed-off-by: Heikki Hellgren <heikki.hellgren@op.fi>
This commit is contained in:
Heikki Hellgren
2024-08-26 15:16:32 +03:00
parent 0a2ccf8458
commit 10b1452d92
7 changed files with 259 additions and 1 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/core-plugin-api': patch
'@backstage/dev-utils': patch
---
Allow using translations in `createDevApp`
+7
View File
@@ -16,6 +16,7 @@ import { PropsWithChildren } from 'react';
import { default as React_2 } from 'react';
import { ReactNode } from 'react';
import { SignInProviderConfig } from '@backstage/core-components';
import { TranslationResource } from '@backstage/core-plugin-api/alpha';
// @public
export function createDevApp(): DevAppBuilder;
@@ -27,6 +28,7 @@ export class DevAppBuilder {
addSidebarItem(sidebarItem: JSX.Element): DevAppBuilder;
addSignInProvider(provider: SignInProviderConfig): this;
addThemes(themes: AppTheme[]): this;
addTranslationResource(resource: TranslationResource): this;
build(): ComponentType<PropsWithChildren<{}>>;
registerApi<
Api,
@@ -37,6 +39,8 @@ export class DevAppBuilder {
>(factory: ApiFactory<Api, Impl, Deps>): DevAppBuilder;
registerPlugin(...plugins: BackstagePlugin[]): DevAppBuilder;
render(): void;
setAvailableLanguages(languages: string[]): this;
setDefaultLanguage(language: string): this;
}
// @public (undocumented)
@@ -55,6 +59,9 @@ export const EntityGridItem: (
},
) => JSX.Element;
// @public (undocumented)
export const SidebarLanguageSwitcher: () => React_2.JSX.Element | null;
// @public
export const SidebarSignOutButton: (props: {
icon?: IconComponent;
@@ -0,0 +1,85 @@
/*
* Copyright 2021 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 { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
import userEvent from '@testing-library/user-event';
import React from 'react';
import ObservableImpl from 'zen-observable';
import {
AppLanguageApi,
appLanguageApiRef,
} from '@backstage/core-plugin-api/alpha';
import { SidebarLanguageSwitcher } from './SidebarLanguageSwitcher';
describe('SidebarLanguageSwitcher', () => {
let languageApi: jest.Mocked<AppLanguageApi>;
beforeEach(() => {
languageApi = {
getAvailableLanguages: jest.fn(),
getLanguage: jest.fn(),
language$: jest.fn(),
setLanguage: jest.fn(),
};
languageApi.language$.mockReturnValue(
ObservableImpl.of<{ language?: string }>({ language: 'en' }),
);
languageApi.getLanguage.mockReturnValue({ language: 'en' });
languageApi.getAvailableLanguages.mockReturnValue({
languages: ['en', 'fi'],
});
});
it('should display current language', async () => {
const { getByLabelText, getByRole, getByText } = await renderInTestApp(
<TestApiProvider apis={[[appLanguageApiRef, languageApi]]}>
<SidebarLanguageSwitcher />
</TestApiProvider>,
);
const button = getByLabelText('Language');
expect(button).toBeInTheDocument();
await userEvent.click(button);
expect(getByRole('listbox')).toBeInTheDocument();
expect(getByText('English')).toBeInTheDocument();
expect(getByText('English').parentElement?.parentElement).toHaveAttribute(
'aria-selected',
'true',
);
});
it('should select different language', async () => {
const { getByLabelText, getByRole, getByText } = await renderInTestApp(
<TestApiProvider apis={[[appLanguageApiRef, languageApi]]}>
<SidebarLanguageSwitcher />
</TestApiProvider>,
);
const button = getByLabelText('Language');
expect(button).toBeInTheDocument();
await userEvent.click(button);
expect(getByRole('listbox')).toBeInTheDocument();
await userEvent.click(getByText('suomi'));
expect(languageApi.setLanguage).toHaveBeenCalledWith('fi');
});
});
@@ -0,0 +1,109 @@
/*
* 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, { useState } from 'react';
import { appLanguageApiRef } from '@backstage/core-plugin-api/alpha';
import TranslateIcon from '@material-ui/icons/Translate';
import ListItemText from '@material-ui/core/ListItemText';
import { useApi } from '@backstage/core-plugin-api';
import useObservable from 'react-use/esm/useObservable';
import { SidebarItem } from '@backstage/core-components';
import Menu from '@material-ui/core/Menu';
import MenuItem from '@material-ui/core/MenuItem';
/** @public */
export const SidebarLanguageSwitcher = () => {
const languageApi = useApi(appLanguageApiRef);
const [languageObservable] = useState(() => languageApi.language$());
const { language: currentLanguage } = useObservable(
languageObservable,
languageApi.getLanguage(),
);
const [anchorEl, setAnchorEl] = useState<Element | undefined>();
const { languages } = languageApi.getAvailableLanguages();
if (languages.length <= 1) {
return null;
}
const open = Boolean(anchorEl);
const handleClose = () => {
setAnchorEl(undefined);
};
const handleOpen = (event: React.MouseEvent) => {
setAnchorEl(event.currentTarget);
};
const handleSetLanguage = (newLanguage: string | undefined) => {
languageApi.setLanguage(newLanguage);
setAnchorEl(undefined);
};
const getLanguageDisplayName = (language: string) => {
try {
const names = new Intl.DisplayNames([language], {
type: 'language',
});
return names.of(language) || language;
} catch (err) {
return language;
}
};
return (
<>
<SidebarItem
icon={TranslateIcon}
text="Language"
id="language-button"
aria-haspopup="listbox"
aria-controls="language-menu"
aria-label="switch language"
aria-expanded={open ? 'true' : undefined}
onClick={handleOpen}
/>
<Menu
id="language-menu"
anchorEl={anchorEl}
open={open}
onClose={handleClose}
MenuListProps={{
'aria-labelledby': 'language-button',
role: 'listbox',
}}
>
<MenuItem disabled>Choose language</MenuItem>
{languages.map(lang => {
const active = currentLanguage === lang;
return (
<MenuItem
key={lang}
selected={active}
aria-selected={active}
onClick={() => handleSetLanguage(lang)}
>
<ListItemText>{getLanguageDisplayName(lang)}</ListItemText>
</MenuItem>
);
})}
</Menu>
</>
);
};
@@ -0,0 +1,16 @@
/*
* Copyright 2024 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 { SidebarLanguageSwitcher } from './SidebarLanguageSwitcher';
@@ -16,3 +16,4 @@
export * from './EntityGridItem';
export * from './SidebarSignOutButton';
export * from './SidebarLanguageSwitcher';
+35 -1
View File
@@ -40,6 +40,7 @@ import {
IconComponent,
RouteRef,
} from '@backstage/core-plugin-api';
import { TranslationResource } from '@backstage/core-plugin-api/alpha';
import {
ScmIntegrationsApi,
scmIntegrationsApiRef,
@@ -50,7 +51,7 @@ import React, { ComponentType, PropsWithChildren, ReactNode } from 'react';
import { createRoutesFromChildren, Route } from 'react-router-dom';
import { SidebarThemeSwitcher } from './SidebarThemeSwitcher';
import 'react-dom';
import { SidebarSignOutButton } from '../components';
import { SidebarLanguageSwitcher, SidebarSignOutButton } from '../components';
let ReactDOMPromise: Promise<
typeof import('react-dom') | typeof import('react-dom/client')
@@ -98,9 +99,12 @@ export class DevAppBuilder {
private readonly routes = new Array<JSX.Element>();
private readonly sidebarItems = new Array<JSX.Element>();
private readonly signInProviders = new Array<SignInProviderConfig>();
private readonly translationResources = new Array<TranslationResource>();
private defaultPage?: string;
private themes?: Array<AppTheme>;
private languages?: string[];
private defaultLanguage?: string;
/**
* Register one or more plugins to render in the dev app
@@ -193,6 +197,30 @@ export class DevAppBuilder {
return this;
}
/**
* Set available languages to be shown in the dev app
*/
setAvailableLanguages(languages: string[]) {
this.languages = languages;
return this;
}
/**
* Add translation resource to the dev app
*/
addTranslationResource(resource: TranslationResource) {
this.translationResources.push(resource);
return this;
}
/**
* Set default language for the dev app
*/
setDefaultLanguage(language: string) {
this.defaultLanguage = language;
return this;
}
/**
* Build a DevApp component using the resources registered so far
*/
@@ -237,6 +265,11 @@ export class DevAppBuilder {
bind(plugin.externalRoutes, targets);
}
},
__experimentalTranslations: {
defaultLanguage: this.defaultLanguage,
availableLanguages: this.languages,
resources: this.translationResources,
},
});
const DevApp = (
@@ -252,6 +285,7 @@ export class DevAppBuilder {
<SidebarSpace />
<SidebarDivider />
<SidebarThemeSwitcher />
<SidebarLanguageSwitcher />
<SidebarSignOutButton />
</Sidebar>
<FlatRoutes>