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
+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}}',
},
});