Themes: Prevent duplicated API call in drawer (#105602)

refactor to separate component, stop propagating radio button event + unit test
This commit is contained in:
Ashley Harrison
2025-05-19 12:42:36 +01:00
committed by GitHub
parent 3fe31e19b2
commit 808b371225
3 changed files with 122 additions and 72 deletions
@@ -0,0 +1,35 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { createTheme, ThemeRegistryItem } from '@grafana/data';
import { ThemeCard } from './ThemeCard';
describe('ThemeCard', () => {
let user: ReturnType<typeof userEvent.setup>;
beforeEach(() => {
user = userEvent.setup();
});
const mockTheme: ThemeRegistryItem = {
id: 'dark',
name: 'Dark',
build: createTheme,
};
it('should only call onSelect once when clicking the radio button dot', async () => {
const onSelectMock = jest.fn();
render(<ThemeCard themeOption={mockTheme} onSelect={onSelectMock} isSelected={false} />);
// Find the radio button input element
const radioButtonInput = screen.getByRole('radio');
// Click the radio button
await user.click(radioButtonInput);
// Check that onSelect was called only once
expect(onSelectMock).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,84 @@
import { css } from '@emotion/css';
import { FeatureState, GrafanaTheme2, ThemeRegistryItem } from '@grafana/data';
import { useTranslate } from '@grafana/i18n';
import { TFunction } from '@grafana/i18n/internal';
import { FeatureBadge, RadioButtonDot, useStyles2 } from '@grafana/ui';
import { ThemePreview } from '../Theme/ThemePreview';
interface ThemeCardProps {
themeOption: ThemeRegistryItem;
isExperimental?: boolean;
isSelected?: boolean;
onSelect: () => void;
}
export function ThemeCard({ themeOption, isExperimental, isSelected, onSelect }: ThemeCardProps) {
const { t } = useTranslate();
const theme = themeOption.build();
const label = getTranslatedThemeName(themeOption, t);
const styles = useStyles2(getStyles);
return (
// this is a convenience for mouse users. keyboard/screen reader users will use the radio button
// eslint-disable-next-line jsx-a11y/no-static-element-interactions,jsx-a11y/click-events-have-key-events
<div className={styles.card} onClick={onSelect}>
<div className={styles.header}>
<RadioButtonDot
id={`theme-${theme.name}`}
name={'theme'}
label={label}
onClick={(event) => {
// prevent propagation so that onSelect is only called once when clicking the radio button
event.stopPropagation();
}}
onChange={onSelect}
checked={isSelected}
/>
{isExperimental && <FeatureBadge featureState={FeatureState.experimental} />}
</div>
<ThemePreview theme={theme} />
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
card: css({
border: `1px solid ${theme.colors.border.weak}`,
borderRadius: theme.shape.radius.default,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
cursor: 'pointer',
'&:hover': {
border: `1px solid ${theme.colors.border.medium}`,
},
}),
header: css({
alignItems: 'center',
borderBottom: `1px solid ${theme.colors.border.weak}`,
display: 'flex',
justifyContent: 'space-between',
padding: theme.spacing(1),
// The RadioButtonDot is not correctly implemented at the moment, missing cursor (And click ability for the label and input)
'> label': {
cursor: 'pointer',
},
}),
};
};
function getTranslatedThemeName(theme: ThemeRegistryItem, t: TFunction) {
switch (theme.id) {
case 'dark':
return t('shared.preferences.theme.dark-label', 'Dark');
case 'light':
return t('shared.preferences.theme.light-label', 'Light');
case 'system':
return t('shared.preferences.theme.system-label', 'System preference');
default:
return theme.name;
}
}
@@ -1,14 +1,12 @@
import { css } from '@emotion/css';
import { FeatureState, GrafanaTheme2, ThemeRegistryItem } from '@grafana/data';
import { GrafanaTheme2, ThemeRegistryItem } from '@grafana/data';
import { Trans, useTranslate } from '@grafana/i18n';
import { TFunction } from '@grafana/i18n/internal';
import { config, reportInteraction } from '@grafana/runtime';
import { Drawer, FeatureBadge, RadioButtonDot, TextLink, useStyles2, useTheme2 } from '@grafana/ui';
import { Drawer, TextLink, useStyles2, useTheme2 } from '@grafana/ui';
import { changeTheme } from 'app/core/services/theme';
import { ThemePreview } from '../Theme/ThemePreview';
import { ThemeCard } from './ThemeCard';
import { getSelectableThemes } from './getSelectableThemes';
interface Props {
@@ -63,38 +61,6 @@ export function ThemeSelectorDrawer({ onClose }: Props) {
);
}
interface ThemeCardProps {
themeOption: ThemeRegistryItem;
isExperimental?: boolean;
isSelected?: boolean;
onSelect: () => void;
}
function ThemeCard({ themeOption, isExperimental, isSelected, onSelect }: ThemeCardProps) {
const { t } = useTranslate();
const theme = themeOption.build();
const label = getTranslatedThemeName(themeOption, t);
const styles = useStyles2(getStyles);
return (
// this is a convenience for mouse users. keyboard/screen reader users will use the radio button
// eslint-disable-next-line jsx-a11y/no-static-element-interactions,jsx-a11y/click-events-have-key-events
<div className={styles.card} onClick={onSelect}>
<div className={styles.header}>
<RadioButtonDot
id={`theme-${theme.name}`}
name={'theme'}
label={label}
onChange={onSelect}
checked={isSelected}
/>
{isExperimental && <FeatureBadge featureState={FeatureState.experimental} />}
</div>
<ThemePreview theme={theme} />
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
grid: css({
@@ -103,40 +69,5 @@ const getStyles = (theme: GrafanaTheme2) => {
gridAutoRows: `250px`,
gap: theme.spacing(2),
}),
card: css({
border: `1px solid ${theme.colors.border.weak}`,
borderRadius: theme.shape.radius.default,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
cursor: 'pointer',
'&:hover': {
border: `1px solid ${theme.colors.border.medium}`,
},
}),
header: css({
alignItems: 'center',
borderBottom: `1px solid ${theme.colors.border.weak}`,
display: 'flex',
justifyContent: 'space-between',
padding: theme.spacing(1),
// The RadioButtonDot is not correctly implemented at the moment, missing cursor (And click ability for the label and input)
'> label': {
cursor: 'pointer',
},
}),
};
};
function getTranslatedThemeName(theme: ThemeRegistryItem, t: TFunction) {
switch (theme.id) {
case 'dark':
return t('shared.preferences.theme.dark-label', 'Dark');
case 'light':
return t('shared.preferences.theme.light-label', 'Light');
case 'system':
return t('shared.preferences.theme.system-label', 'System preference');
default:
return theme.name;
}
}