diff --git a/.changeset/strange-snakes-agree.md b/.changeset/strange-snakes-agree.md new file mode 100644 index 0000000000..b3f2c220a7 --- /dev/null +++ b/.changeset/strange-snakes-agree.md @@ -0,0 +1,6 @@ +--- +'@backstage/core-plugin-api': patch +'@backstage/dev-utils': patch +--- + +Allow using translations in `createDevApp` diff --git a/packages/dev-utils/api-report.md b/packages/dev-utils/api-report.md index 0a5b3d10e8..1a7b765fab 100644 --- a/packages/dev-utils/api-report.md +++ b/packages/dev-utils/api-report.md @@ -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>; registerApi< Api, @@ -37,6 +39,8 @@ export class DevAppBuilder { >(factory: ApiFactory): 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; diff --git a/packages/dev-utils/src/components/SidebarLanguageSwitcher/SidebarLanguageSwitcher.test.tsx b/packages/dev-utils/src/components/SidebarLanguageSwitcher/SidebarLanguageSwitcher.test.tsx new file mode 100644 index 0000000000..fc620b846b --- /dev/null +++ b/packages/dev-utils/src/components/SidebarLanguageSwitcher/SidebarLanguageSwitcher.test.tsx @@ -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; + + 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( + + + , + ); + + 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( + + + , + ); + + 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'); + }); +}); diff --git a/packages/dev-utils/src/components/SidebarLanguageSwitcher/SidebarLanguageSwitcher.tsx b/packages/dev-utils/src/components/SidebarLanguageSwitcher/SidebarLanguageSwitcher.tsx new file mode 100644 index 0000000000..71fd9a6c94 --- /dev/null +++ b/packages/dev-utils/src/components/SidebarLanguageSwitcher/SidebarLanguageSwitcher.tsx @@ -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(); + + 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 ( + <> + + + Choose language + {languages.map(lang => { + const active = currentLanguage === lang; + return ( + handleSetLanguage(lang)} + > + {getLanguageDisplayName(lang)} + + ); + })} + + + ); +}; diff --git a/packages/dev-utils/src/components/SidebarLanguageSwitcher/index.ts b/packages/dev-utils/src/components/SidebarLanguageSwitcher/index.ts new file mode 100644 index 0000000000..6726e0bb9d --- /dev/null +++ b/packages/dev-utils/src/components/SidebarLanguageSwitcher/index.ts @@ -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'; diff --git a/packages/dev-utils/src/components/index.ts b/packages/dev-utils/src/components/index.ts index 8959b6e7b1..7a27a97e73 100644 --- a/packages/dev-utils/src/components/index.ts +++ b/packages/dev-utils/src/components/index.ts @@ -16,3 +16,4 @@ export * from './EntityGridItem'; export * from './SidebarSignOutButton'; +export * from './SidebarLanguageSwitcher'; diff --git a/packages/dev-utils/src/devApp/render.tsx b/packages/dev-utils/src/devApp/render.tsx index 3b3c993a6b..a22c4ffb71 100644 --- a/packages/dev-utils/src/devApp/render.tsx +++ b/packages/dev-utils/src/devApp/render.tsx @@ -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(); private readonly sidebarItems = new Array(); private readonly signInProviders = new Array(); + private readonly translationResources = new Array(); private defaultPage?: string; private themes?: Array; + 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 { +