feat: Introduced experimental support for internationalization.
Signed-off-by: rui ma <ruima@alauda.io>
This commit is contained in:
@@ -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}
|
||||
{theme.title || t(`theme_${themeId}`)}
|
||||
<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
|
||||
<AutoIcon color={themeId === undefined ? 'primary' : undefined} />
|
||||
<Tooltip placement="top" arrow title={t('select_theme_auto')}>
|
||||
<ToggleButton value="auto" selected={activeThemeId === undefined}>
|
||||
{t('theme_auto')}
|
||||
<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';
|
||||
|
||||
@@ -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}}',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user