Dashboard Controls: Add UI for displaying under menu (#113517)

* feat: add options to render under the controls menu

* fix: filter out hidden annotation layers from the dashboard-controls menu

* fix: adjust spacing between annotation controls in the dashboard-controls menu

* fix: e2e test for variables

* feat: move the menu button next to the variables

* fix: remove duplicate link controls

* fix: show dashboard controls when the dashboard is not saved
This commit is contained in:
Levente Balogh
2025-12-04 16:22:41 +01:00
committed by GitHub
parent 4c5d9cb95f
commit 32a58c56ed
21 changed files with 980 additions and 382 deletions
@@ -54,7 +54,13 @@ test.describe(
await expect(descriptionInput).toHaveAttribute('placeholder', 'Descriptive text');
await expect(descriptionInput).toHaveValue('');
await expect(page.locator('label').filter({ hasText: 'Hide' })).toBeVisible();
// Display
await expect(page.locator('label', { hasText: /^Display$/ })).toBeVisible();
const displaySelect = dashboardPage.getByGrafanaSelector(
selectors.pages.Dashboard.Settings.Variables.Edit.General.generalDisplaySelect
);
await expect(displaySelect).toBeVisible();
await expect(displaySelect).toHaveValue('Above dashboard');
// Check datasource selector
const datasourceSelect = dashboardPage.getByGrafanaSelector(
-15
View File
@@ -1978,16 +1978,6 @@
"count": 2
}
},
"public/app/features/dashboard-scene/settings/annotations/AnnotationSettingsEdit.tsx": {
"no-restricted-syntax": {
"count": 7
}
},
"public/app/features/dashboard-scene/settings/links/DashboardLinkForm.tsx": {
"no-restricted-syntax": {
"count": 10
}
},
"public/app/features/dashboard-scene/settings/variables/VariableEditableElement.tsx": {
"react-hooks/rules-of-hooks": {
"count": 4
@@ -2008,11 +1998,6 @@
"count": 1
}
},
"public/app/features/dashboard-scene/settings/variables/components/VariableHideSelect.tsx": {
"no-restricted-syntax": {
"count": 1
}
},
"public/app/features/dashboard-scene/settings/variables/components/VariableSelectField.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
@@ -352,6 +352,9 @@ export const versionedPages = {
showInLabel: {
'11.1.0': 'data-testid show-in-label',
},
annotationControlsDisplay: {
'12.4.0': 'data-testid annotation-controls-display-label',
},
previewInDashboard: {
'10.0.0': 'data-testid annotations-preview',
},
@@ -445,6 +448,9 @@ export const versionedPages = {
generalHideSelectV2: {
[MIN_GRAFANA_VERSION]: 'data-testid Variable editor Form Hide select',
},
generalDisplaySelect: {
'12.4.0': 'data-testid Variable editor Display select',
},
selectionOptionsAllowCustomValueSwitch: {
[MIN_GRAFANA_VERSION]: 'data-testid Variable editor Form Allow Custom Value switch',
},
@@ -23,11 +23,12 @@ import { playlistSrv } from 'app/features/playlist/PlaylistSrv';
import { PanelEditControls } from '../panel-edit/PanelEditControls';
import { getDashboardSceneFor } from '../utils/utils';
import { DashboardControlsButton } from './DashboardControlsMenu';
import { DashboardDataLayerControls } from './DashboardDataLayerControls';
import { DashboardLinksControls } from './DashboardLinksControls';
import { DashboardScene } from './DashboardScene';
import { VariableControls } from './VariableControls';
import { DashboardControlsButton } from './dashboard-controls-menu/DashboardControlsMenuButton';
import { hasDashboardControls, useHasDashboardControls } from './dashboard-controls-menu/utils';
import { EditDashboardSwitch } from './new-toolbar/actions/EditDashboardSwitch';
import { SaveDashboard } from './new-toolbar/actions/SaveDashboard';
import { ShareDashboardButton } from './new-toolbar/actions/ShareDashboardButton';
@@ -117,19 +118,8 @@ export class DashboardControls extends SceneObjectBase<DashboardControlsState> {
}
}
// Dashboard controls is a separate dropdown menu at the top-right of the controls
public hasDashboardControls(): boolean {
const dashboard = getDashboardSceneFor(this);
const { links } = dashboard.state;
const hasControlMenuVariables = sceneGraph
.getVariables(dashboard)
?.state.variables.some((v) => v.state.hide === VariableHide.inControlsMenu);
const hasControlMenuLinks = links.some((link) => link.placement === 'inControlsMenu');
return hasControlMenuVariables || hasControlMenuLinks;
}
public hasControls(): boolean {
const dashboard = getDashboardSceneFor(this);
const hasVariables = sceneGraph
.getVariables(this)
?.state.variables.some((v) => v.state.hide !== VariableHide.hideVariable);
@@ -138,7 +128,7 @@ export class DashboardControls extends SceneObjectBase<DashboardControlsState> {
const hideLinks = this.state.hideLinksControls || !hasLinks;
const hideVariables = this.state.hideVariableControls || (!hasAnnotations && !hasVariables);
const hideTimePicker = this.state.hideTimeControls;
const hideDashboardControls = this.state.hideDashboardControls || !this.hasDashboardControls();
const hideDashboardControls = this.state.hideDashboardControls || !hasDashboardControls(dashboard);
return !(hideVariables && hideLinks && hideTimePicker && hideDashboardControls);
}
@@ -157,10 +147,10 @@ function DashboardControlsRenderer({ model }: SceneComponentProps<DashboardContr
const { links, editPanel } = dashboard.useState();
const styles = useStyles2(getStyles);
const showDebugger = window.location.search.includes('scene-debugger');
const hasDashboardControls = useHasDashboardControls(dashboard);
if (!model.hasControls()) {
// To still have spacing when no controls are rendered
return <Box padding={1}>{renderHiddenVariables(dashboard)}</Box>;
}
@@ -176,11 +166,6 @@ function DashboardControlsRenderer({ model }: SceneComponentProps<DashboardContr
<refreshPicker.Component model={refreshPicker} />
</div>
)}
{!hideDashboardControls && model.hasDashboardControls() && (
<div className={styles.dashboardControlsButton}>
<DashboardControlsButton dashboard={dashboard} />
</div>
)}
{config.featureToggles.dashboardNewLayouts && (
<div className={styles.fixedControls}>
<DashboardControlActions dashboard={dashboard} />
@@ -194,6 +179,7 @@ function DashboardControlsRenderer({ model }: SceneComponentProps<DashboardContr
<DashboardDataLayerControls dashboard={dashboard} />
</>
)}
{!hideDashboardControls && hasDashboardControls && <DashboardControlsButton dashboard={dashboard} />}
{editPanel && <PanelEditControls panelEditor={editPanel} />}
{showDebugger && <SceneDebugger scene={model} key={'scene-debugger'} />}
</div>
@@ -1,137 +0,0 @@
import { css, cx } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
import { SceneDataLayerProvider, sceneGraph, SceneVariable } from '@grafana/scenes';
import { DashboardLink, VariableHide } from '@grafana/schema';
import { Box, Dropdown, Menu, ToolbarButton, useStyles2 } from '@grafana/ui';
import { isDashboardDataLayerSetState } from './DashboardDataLayerSet';
import { DashboardLinkRenderer } from './DashboardLinkRenderer';
import { DashboardScene } from './DashboardScene';
import { DataLayerControl } from './DataLayerControl';
import { VariableValueSelectWrapper } from './VariableControls';
export const DASHBOARD_CONTROLS_MENU_ARIA_LABEL = 'Dashboard controls menu';
export const DASHBOARD_CONTROLS_MENU_TITLE = 'Dashboard controls';
export function DashboardControlsButton({ dashboard }: { dashboard: DashboardScene }) {
const { links, uid } = dashboard.useState();
// Dashboard links are not supported at the moment.
// Reason: nesting <Dropdown> components causes issues since the inner dropdown is rendered in a portal,
// so clicking it closes the parent dropdown (the parent sees it as an overlay click, and the event cannot easily be intercepted,
// as it is in different HTML subtree).
const filteredLinks = links.filter((link) => link.placement === 'inControlsMenu' && link.type !== 'dashboards');
const variables = sceneGraph
.getVariables(dashboard)!
.useState()
.variables.filter((v) => v.state.hide === VariableHide.inControlsMenu);
const dataState = sceneGraph.getData(dashboard).useState();
const annotationLayers = isDashboardDataLayerSetState(dataState) ? dataState.annotationLayers : [];
const filteredAnnotationLayers = annotationLayers.filter((layer) => layer.state.placement === 'inControlsMenu');
if ((variables.length === 0 && filteredLinks.length === 0 && filteredAnnotationLayers.length === 0) || !uid) {
return null;
}
return (
<Dropdown
placement="bottom-end"
overlay={
<DashboardControlsMenu
variables={variables}
links={filteredLinks}
annotationLayers={filteredAnnotationLayers}
dashboardUID={uid}
/>
}
>
<ToolbarButton
aria-label={t('dashboard.controls.menu.aria-label', DASHBOARD_CONTROLS_MENU_ARIA_LABEL)}
title={t('dashboard.controls.menu.title', DASHBOARD_CONTROLS_MENU_TITLE)}
icon="ellipsis-v"
iconSize="md"
narrow
variant="canvas"
/>
</Dropdown>
);
}
interface DashboardControlsMenuProps {
variables: SceneVariable[];
links: DashboardLink[];
annotationLayers: SceneDataLayerProvider[];
dashboardUID: string;
}
function DashboardControlsMenu({ variables, links, annotationLayers, dashboardUID }: DashboardControlsMenuProps) {
const styles = useStyles2(getStyles);
return (
<Box
minWidth={32}
borderColor={'weak'}
borderStyle={'solid'}
boxShadow={'z3'}
display={'flex'}
direction={'column'}
borderRadius={'default'}
backgroundColor={'primary'}
padding={1.5}
gap={0.5}
onClick={(e) => {
// Normally, clicking the overlay closes the dropdown.
// We stop event propagation here to keep it open while users interact with variable controls.
e.stopPropagation();
}}
>
{/* Variables */}
{variables.map((variable, index) => (
<div className={cx({ [styles.menuItem]: index > 0 })} key={variable.state.key}>
<VariableValueSelectWrapper variable={variable} inMenu />
</div>
))}
{/* Annotation layers */}
{annotationLayers.length > 0 &&
annotationLayers.map((layer, index) => (
<div className={cx({ [styles.menuItem]: variables.length > 0 || index > 0 })} key={layer.state.key}>
<DataLayerControl layer={layer} inMenu />
</div>
))}
{/* Links */}
{links.length > 0 && (
<>
{(variables.length > 0 || annotationLayers.length > 0) && <MenuDivider />}
{links.map((link, index) => (
<div key={`${link.title}-${index}`}>
<DashboardLinkRenderer link={link} dashboardUID={dashboardUID} inMenu />
</div>
))}
</>
)}
</Box>
);
}
function MenuDivider() {
const styles = useStyles2(getStyles);
return (
<div className={styles.divider}>
<Menu.Divider />
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
divider: css({
marginTop: theme.spacing(2),
padding: theme.spacing(0, 0.5),
}),
menuItem: css({
marginTop: theme.spacing(2),
}),
});
@@ -66,6 +66,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
gap: theme.spacing(1),
}),
controlWrapper: css({
height: theme.spacing(2),
'& > div': {
border: 'none',
background: 'transparent',
@@ -0,0 +1,88 @@
import { css, cx } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { SceneDataLayerProvider, SceneVariable } from '@grafana/scenes';
import { DashboardLink } from '@grafana/schema';
import { Box, Menu, useStyles2 } from '@grafana/ui';
import { DashboardLinkRenderer } from '../DashboardLinkRenderer';
import { DataLayerControl } from '../DataLayerControl';
import { VariableValueSelectWrapper } from '../VariableControls';
interface DashboardControlsMenuProps {
variables: SceneVariable[];
links: DashboardLink[];
annotations: SceneDataLayerProvider[];
dashboardUID?: string;
}
export function DashboardControlsMenu({ variables, links, annotations, dashboardUID }: DashboardControlsMenuProps) {
const styles = useStyles2(getStyles);
return (
<Box
minWidth={32}
borderColor={'weak'}
borderStyle={'solid'}
boxShadow={'z3'}
display={'flex'}
direction={'column'}
borderRadius={'default'}
backgroundColor={'primary'}
padding={1.5}
gap={0.5}
onClick={(e) => {
// Normally, clicking the overlay closes the dropdown.
// We stop event propagation here to keep it open while users interact with variable controls.
e.stopPropagation();
}}
>
{/* Variables */}
{variables.map((variable, index) => (
<div className={cx({ [styles.menuItem]: index > 0 })} key={variable.state.key}>
<VariableValueSelectWrapper variable={variable} inMenu />
</div>
))}
{/* Annotation layers */}
{annotations.length > 0 &&
annotations.map((layer, index) => (
<div className={cx({ [styles.menuItem]: variables.length > 0 || index > 0 })} key={layer.state.key}>
<DataLayerControl layer={layer} inMenu />
</div>
))}
{/* Links */}
{links.length > 0 && dashboardUID && (
<>
{(variables.length > 0 || annotations.length > 0) && <MenuDivider />}
{links.map((link, index) => (
<div key={`${link.title}-${index}`}>
<DashboardLinkRenderer link={link} dashboardUID={dashboardUID} inMenu />
</div>
))}
</>
)}
</Box>
);
}
function MenuDivider() {
const styles = useStyles2(getStyles);
return (
<div className={styles.divider}>
<Menu.Divider />
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
divider: css({
marginTop: theme.spacing(2),
padding: theme.spacing(0, 0.5),
}),
menuItem: css({
marginTop: theme.spacing(2),
}),
});
@@ -4,12 +4,13 @@ import userEvent from '@testing-library/user-event';
import { VariableHide } from '@grafana/data';
import { SceneVariableSet, TextBoxVariable, QueryVariable, CustomVariable, SceneVariable } from '@grafana/scenes';
import { DashboardScene } from '../DashboardScene';
import {
DASHBOARD_CONTROLS_MENU_ARIA_LABEL,
DASHBOARD_CONTROLS_MENU_TITLE,
DashboardControlsButton,
} from './DashboardControlsMenu';
import { DashboardScene } from './DashboardScene';
} from './DashboardControlsMenuButton';
describe('DashboardControlsMenu', () => {
it('should return null and not render anything when there are no variables', () => {
@@ -0,0 +1,51 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
import { Dropdown, ToolbarButton, useStyles2 } from '@grafana/ui';
import { DashboardScene } from '../DashboardScene';
import { DashboardControlsMenu } from './DashboardControlsMenu';
import { useDashboardControls } from './utils';
export const DASHBOARD_CONTROLS_MENU_ARIA_LABEL = 'Dashboard controls menu';
export const DASHBOARD_CONTROLS_MENU_TITLE = 'Dashboard controls';
export function DashboardControlsButton({ dashboard }: { dashboard: DashboardScene }) {
const styles = useStyles2(getStyles);
const { uid } = dashboard.useState();
const { variables, links, annotations } = useDashboardControls(dashboard);
const dashboardControlsCount = variables.length + links.length + annotations.length;
const hasDashboardControls = dashboardControlsCount > 0;
if (!hasDashboardControls) {
return null;
}
return (
<Dropdown
placement="bottom-start"
overlay={
<DashboardControlsMenu variables={variables} links={links} annotations={annotations} dashboardUID={uid} />
}
>
<ToolbarButton
aria-label={t('dashboard.controls.menu.aria-label', DASHBOARD_CONTROLS_MENU_ARIA_LABEL)}
title={t('dashboard.controls.menu.title', DASHBOARD_CONTROLS_MENU_TITLE)}
icon="sliders-v-alt"
iconSize="md"
variant="canvas"
className={styles.dropdownButton}
>
+ {dashboardControlsCount}
</ToolbarButton>
</Dropdown>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
dropdownButton: css({
display: 'inline-flex',
}),
});
@@ -0,0 +1,62 @@
import { SceneDataState, sceneGraph, SceneVariable } from '@grafana/scenes';
import { DashboardLink, VariableHide } from '@grafana/schema';
import { isDashboardDataLayerSetState } from '../DashboardDataLayerSet';
import { DashboardScene } from '../DashboardScene';
export function getDashboardControlsLinks(links: DashboardLink[]) {
// Dashboard links are not supported at the moment.
// Reason: nesting <Dropdown> components causes issues since the inner dropdown is rendered in a portal,
// so clicking it closes the parent dropdown (the parent sees it as an overlay click, and the event cannot easily be intercepted,
// as it is in different HTML subtree).
return links.filter((link) => link.placement === 'inControlsMenu' && link.type !== 'dashboards');
}
export function getDashboardControlsVariables(variables: SceneVariable[]) {
return variables.filter((v) => v.state.hide === VariableHide.inControlsMenu);
}
export function getDashboardControlsAnnotations(dataState: SceneDataState) {
return (isDashboardDataLayerSetState(dataState) ? dataState.annotationLayers : []).filter(
(layer) => layer.state.placement === 'inControlsMenu' && !layer.state.isHidden
);
}
export function getDashboardControls(dashboard: DashboardScene) {
const variables = getDashboardControlsVariables(sceneGraph.getVariables(dashboard)?.state.variables);
const links = getDashboardControlsLinks(dashboard.state.links);
const annotations = getDashboardControlsAnnotations(sceneGraph.getData(dashboard).state);
return {
variables,
links,
annotations,
};
}
export function useDashboardControls(dashboard: DashboardScene) {
const dashboardState = dashboard.useState();
const variablesState = sceneGraph.getVariables(dashboard).useState();
const dataState = sceneGraph.getData(dashboard).useState();
const links = getDashboardControlsLinks(dashboardState.links);
const variables = getDashboardControlsVariables(variablesState.variables);
const annotations = getDashboardControlsAnnotations(dataState);
return {
variables,
links,
annotations,
};
}
export function useHasDashboardControls(dashboard: DashboardScene) {
const { variables, links, annotations } = useDashboardControls(dashboard);
return variables.length > 0 || links.length > 0 || annotations.length > 0;
}
export function hasDashboardControls(dashboard: DashboardScene) {
const { variables, links, annotations } = getDashboardControls(dashboard);
return variables.length > 0 || links.length > 0 || annotations.length > 0;
}
@@ -119,6 +119,7 @@ export class AnnotationsEditView extends SceneObjectBase<AnnotationsEditViewStat
name: annotation.name,
isEnabled: Boolean(annotation.enable),
isHidden: Boolean(annotation.hide),
placement: annotation.placement,
query: annotation,
});
@@ -1,4 +1,4 @@
import { act, render } from '@testing-library/react';
import { act, render, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { of } from 'rxjs';
@@ -88,6 +88,22 @@ describe('AnnotationSettingsEdit', () => {
};
}
// For testing combobox
beforeAll(() => {
const mockGetBoundingClientRect = jest.fn(() => ({
width: 120,
height: 120,
top: 0,
left: 0,
bottom: 0,
right: 0,
}));
Object.defineProperty(Element.prototype, 'getBoundingClientRect', {
value: mockGetBoundingClientRect,
});
});
afterEach(() => {
jest.clearAllMocks();
});
@@ -100,7 +116,9 @@ describe('AnnotationSettingsEdit', () => {
const nameInput = getByTestId(selectors.pages.Dashboard.Settings.Annotations.Settings.name);
const dataSourceSelect = getByTestId(selectors.components.DataSourcePicker.container);
const enableToggle = getByTestId(selectors.pages.Dashboard.Settings.Annotations.NewAnnotation.enable);
const hideToggle = getByTestId(selectors.pages.Dashboard.Settings.Annotations.NewAnnotation.hide);
const annotationControlsDisplaySelect = getByTestId(
selectors.pages.Dashboard.Settings.Annotations.NewAnnotation.annotationControlsDisplay
);
const iconColorToggle = getByTestId(selectors.components.ColorSwatch.name);
const panelSelect = getByTestId(selectors.pages.Dashboard.Settings.Annotations.NewAnnotation.showInLabel);
const deleteAnno = getByTestId(selectors.pages.Dashboard.Settings.Annotations.NewAnnotation.delete);
@@ -109,7 +127,7 @@ describe('AnnotationSettingsEdit', () => {
expect(nameInput).toBeInTheDocument();
expect(dataSourceSelect).toBeInTheDocument();
expect(enableToggle).toBeInTheDocument();
expect(hideToggle).toBeInTheDocument();
expect(annotationControlsDisplaySelect).toBeInTheDocument();
expect(iconColorToggle).toBeInTheDocument();
expect(panelSelect).toBeInTheDocument();
expect(deleteAnno).toBeInTheDocument();
@@ -147,26 +165,6 @@ describe('AnnotationSettingsEdit', () => {
expect(mockOnUpdate).toHaveBeenCalledWith(annoArg, 1);
});
it('should toggle annotation hide on change', async () => {
const {
renderer: { getByTestId },
user,
anno,
} = await setup();
const annoArg = {
...anno,
hide: !anno.hide,
};
const hideToggle = getByTestId(selectors.pages.Dashboard.Settings.Annotations.NewAnnotation.hide);
await user.click(hideToggle);
expect(mockOnUpdate).toHaveBeenCalledTimes(1);
expect(mockOnUpdate).toHaveBeenCalledWith(annoArg, 1);
});
it('should set annotation filter', async () => {
const {
renderer: { getByTestId },
@@ -207,4 +205,104 @@ describe('AnnotationSettingsEdit', () => {
expect(mockGoBackToList).toHaveBeenCalledTimes(1);
});
it('should render the annotation controls display combobox', async () => {
const {
renderer: { getByTestId },
} = await setup();
const field = getByTestId(selectors.pages.Dashboard.Settings.Annotations.NewAnnotation.annotationControlsDisplay);
const combobox = within(field).getByRole('combobox');
expect(combobox).toBeInTheDocument();
expect(combobox).toHaveValue('Above dashboard');
});
it('should set placement to undefined when selecting "Above dashboard" instead of "Controls menu"', async () => {
const annotationQuery: AnnotationQuery = {
name: 'test',
datasource: defaultDatasource,
enable: true,
hide: false,
iconColor: 'blue',
placement: 'inControlsMenu',
};
const props = {
annotation: annotationQuery,
onUpdate: mockOnUpdate,
editIndex: 1,
panels: [],
onBackToList: mockGoBackToList,
onDelete: mockOnDelete,
};
const {
user,
renderer: { getByTestId, findByText },
} = {
user: userEvent.setup(),
renderer: await act(async () => render(<AnnotationSettingsEdit {...props} />)),
};
const field = getByTestId(selectors.pages.Dashboard.Settings.Annotations.NewAnnotation.annotationControlsDisplay);
const combobox = within(field).getByRole('combobox');
await user.click(combobox);
const aboveDashboardOption = await findByText('Above dashboard');
await user.click(aboveDashboardOption);
expect(mockOnUpdate).toHaveBeenCalledTimes(1);
expect(mockOnUpdate).toHaveBeenCalledWith(
{
...annotationQuery,
placement: undefined,
},
1
);
});
it('should set `hide: true` and `placement: undefined` when selecting "Hidden"', async () => {
const annotationQuery: AnnotationQuery = {
name: 'test',
datasource: defaultDatasource,
enable: true,
hide: false,
iconColor: 'blue',
placement: 'inControlsMenu',
};
const props = {
annotation: annotationQuery,
onUpdate: mockOnUpdate,
editIndex: 1,
panels: [],
onBackToList: mockGoBackToList,
onDelete: mockOnDelete,
};
const {
user,
renderer: { getByTestId, findByText },
} = {
user: userEvent.setup(),
renderer: await act(async () => render(<AnnotationSettingsEdit {...props} />)),
};
const field = getByTestId(selectors.pages.Dashboard.Settings.Annotations.NewAnnotation.annotationControlsDisplay);
const combobox = within(field).getByRole('combobox');
await user.click(combobox);
const hiddenOption = await findByText('Hidden');
await user.click(hiddenOption);
expect(mockOnUpdate).toHaveBeenCalledTimes(1);
expect(mockOnUpdate).toHaveBeenCalledWith(
{
...annotationQuery,
hide: true,
placement: undefined,
},
1
);
});
});
@@ -15,7 +15,20 @@ import { Trans, t } from '@grafana/i18n';
import { config, getDataSourceSrv } from '@grafana/runtime';
import { VizPanel } from '@grafana/scenes';
import { AnnotationPanelFilter } from '@grafana/schema/src/raw/dashboard/x/dashboard_types.gen';
import { Button, Checkbox, Field, FieldSet, Input, MultiSelect, Select, useStyles2, Stack, Alert } from '@grafana/ui';
import {
Button,
Checkbox,
Field,
FieldSet,
Input,
MultiSelect,
Select,
useStyles2,
Stack,
Alert,
ComboboxOption,
Combobox,
} from '@grafana/ui';
import { ColorValueEditor } from 'app/core/components/OptionsUI/color';
import StandardAnnotationQueryEditor from 'app/features/annotations/components/StandardAnnotationQueryEditor';
import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker';
@@ -31,8 +44,15 @@ type Props = {
onDelete: (index: number) => void;
};
const collator = Intl.Collator();
export const newAnnotationName = 'New annotation';
enum AnnotationControlsDisplay {
Hidden,
AboveDashboard,
InControlsMenu,
}
export const AnnotationSettingsEdit = ({ annotation, editIndex, panels, onUpdate, onBackToList, onDelete }: Props) => {
const styles = useStyles2(getStyles);
@@ -49,6 +69,63 @@ export const AnnotationSettingsEdit = ({ annotation, editIndex, panels, onUpdate
const dsi = getDataSourceSrv().getInstanceSettings(annotation.datasource);
const AnnotationControlsDisplayOptions = useMemo(
() => [
{
value: AnnotationControlsDisplay.AboveDashboard,
label: t(
'dashboard-scene.annotation-settings-edit.control-display-options.above-dashboard.label',
'Above dashboard'
),
},
{
value: AnnotationControlsDisplay.InControlsMenu,
label: t(
'dashboard-scene.annotation-settings-edit.control-display-options.controls-menu.label',
'Controls menu'
),
description: t(
'dashboard-scene.annotation-settings-edit.control-display-options.controls-menu.description',
'Can be accessed when the controls menu is open'
),
},
{
value: AnnotationControlsDisplay.Hidden,
label: t('dashboard-scene.annotation-settings-edit.control-display-options.hidden.label', 'Hidden'),
description: t(
'dashboard-scene.annotation-settings-edit.control-display-options.hidden.description',
'Hides the toggle for turning this annotation on or off'
),
},
],
[]
);
// The UI is using a single select input for where to display the annotation controls, however under the hood
// it is computed from different fields of the annotation.
const annotationControlsDisplayValue = useMemo(() => {
if (annotation.hide) {
return AnnotationControlsDisplay.Hidden;
}
if (annotation.placement === 'inControlsMenu') {
return AnnotationControlsDisplay.InControlsMenu;
}
return AnnotationControlsDisplay.AboveDashboard;
}, [annotation]);
const onAnnotationControlDisplayChange = (option: ComboboxOption<AnnotationControlsDisplay>) => {
onUpdate(
{
...annotation,
placement: option.value === AnnotationControlsDisplay.InControlsMenu ? 'inControlsMenu' : undefined,
hide: option.value === AnnotationControlsDisplay.Hidden ? true : false,
},
editIndex
);
};
const onNameChange = (ev: React.FocusEvent<HTMLInputElement>) => {
onUpdate(
{
@@ -143,7 +220,7 @@ export const AnnotationSettingsEdit = ({ annotation, editIndex, panels, onUpdate
const sortFn = (a: SelectableValue<number>, b: SelectableValue<number>) => {
if (a.label && b.label) {
return a.label.toLowerCase().localeCompare(b.label.toLowerCase());
return collator.compare(a.label, b.label);
}
return -1;
@@ -169,102 +246,121 @@ export const AnnotationSettingsEdit = ({ annotation, editIndex, panels, onUpdate
return (
<div>
<FieldSet className={styles.settingsForm}>
<Field label={t('dashboard-scene.annotation-settings-edit.label-name', 'Name')}>
<Input
data-testid={selectors.pages.Dashboard.Settings.Annotations.Settings.name}
name="name"
id="name"
autoFocus={isNewAnnotation}
value={annotation.name}
onChange={onNameChange}
/>
</Field>
<Field
label={t('dashboard-scene.annotation-settings-edit.label-data-source', 'Data source')}
htmlFor="data-source-picker"
>
<DataSourcePicker annotations variables current={annotation.datasource} onChange={onDataSourceChange} />
</Field>
{!ds?.meta.annotations && (
<Alert
title={t(
'dashboard-scene.annotation-settings-edit.title-annotation-support-source',
'No annotation support for this data source'
)}
severity="error"
>
<Trans i18nKey="errors.dashboard-settings.annotations.datasource">
The selected data source does not support annotations. Please select a different data source.
</Trans>
</Alert>
)}
<Field
label={t('dashboard-scene.annotation-settings-edit.label-enabled', 'Enabled')}
description={t(
'dashboard-scene.annotation-settings-edit.description-enabled-annotation-query-issued-every-dashboard',
'When enabled the annotation query is issued every dashboard refresh'
)}
>
<Checkbox
name="enable"
id="enable"
value={annotation.enable}
onChange={onChange}
data-testid={selectors.pages.Dashboard.Settings.Annotations.NewAnnotation.enable}
/>
</Field>
<Field
label={t('dashboard-scene.annotation-settings-edit.label-hidden', 'Hidden')}
description={t(
'dashboard-scene.annotation-settings-edit.description-hidden',
'Annotation queries can be toggled on or off at the top of the dashboard. With this option checked this toggle will be hidden.'
)}
>
<Checkbox
name="hide"
id="hide"
value={annotation.hide}
onChange={onChange}
data-testid={selectors.pages.Dashboard.Settings.Annotations.NewAnnotation.hide}
/>
</Field>
<Field
label={t('dashboard-scene.annotation-settings-edit.label-color', 'Color')}
description={t(
'dashboard-scene.annotation-settings-edit.description-color-annotation-event-markers',
'Color to use for the annotation event markers'
)}
>
<Stack>
<ColorValueEditor value={annotation?.iconColor} onChange={onColorChange} />
</Stack>
</Field>
<Field
label={t('dashboard-scene.annotation-settings-edit.label-show-in', 'Show in')}
data-testid={selectors.pages.Dashboard.Settings.Annotations.NewAnnotation.showInLabel}
>
<>
<Select
options={getPanelFilters()}
value={panelFilter}
onChange={onFilterTypeChange}
data-testid={selectors.components.Annotations.annotationsTypeInput}
<Stack direction="column" gap={2}>
{/* Name */}
<Field noMargin label={t('dashboard-scene.annotation-settings-edit.label-name', 'Name')}>
<Input
data-testid={selectors.pages.Dashboard.Settings.Annotations.Settings.name}
name="name"
id="name"
autoFocus={isNewAnnotation}
value={annotation.name}
onChange={onNameChange}
/>
{panelFilter !== PanelFilterType.AllPanels && (
<MultiSelect
options={selectablePanels}
value={selectablePanels.filter((panel) => annotation.filter?.ids.includes(panel.value!))}
onChange={onAddFilterPanelID}
isClearable={true}
placeholder={t('dashboard-scene.annotation-settings-edit.placeholder-choose-panels', 'Choose panels')}
width={100}
closeMenuOnSelect={false}
className={styles.select}
data-testid={selectors.components.Annotations.annotationsChoosePanelInput}
/>
</Field>
{/* Data source */}
<Field
noMargin
label={t('dashboard-scene.annotation-settings-edit.label-data-source', 'Data source')}
htmlFor="data-source-picker"
>
<DataSourcePicker annotations variables current={annotation.datasource} onChange={onDataSourceChange} />
</Field>
{!ds?.meta.annotations && (
<Alert
title={t(
'dashboard-scene.annotation-settings-edit.title-annotation-support-source',
'No annotation support for this data source'
)}
severity="error"
>
<Trans i18nKey="errors.dashboard-settings.annotations.datasource">
The selected data source does not support annotations. Please select a different data source.
</Trans>
</Alert>
)}
{/* Enabled */}
<Field
noMargin
label={t('dashboard-scene.annotation-settings-edit.label-enabled', 'Enabled')}
description={t(
'dashboard-scene.annotation-settings-edit.description-enabled-annotation-query-issued-every-dashboard',
'When enabled the annotation query is issued every dashboard refresh'
)}
</>
</Field>
>
<Checkbox
name="enable"
id="enable"
value={annotation.enable}
onChange={onChange}
data-testid={selectors.pages.Dashboard.Settings.Annotations.NewAnnotation.enable}
/>
</Field>
{/* Color */}
<Field
noMargin
label={t('dashboard-scene.annotation-settings-edit.label-color', 'Color')}
description={t(
'dashboard-scene.annotation-settings-edit.description-color-annotation-event-markers',
'Color to use for the annotation event markers'
)}
>
<Stack>
<ColorValueEditor value={annotation?.iconColor} onChange={onColorChange} />
</Stack>
</Field>
{/* Annotation controls display */}
<Field
noMargin
label={t(
'dashboard-scene.annotation-settings-edit.label-annotation-controls-display',
'Show annotation controls in'
)}
data-testid={selectors.pages.Dashboard.Settings.Annotations.NewAnnotation.annotationControlsDisplay}
>
<Combobox
id="annotationControlsDisplay"
options={AnnotationControlsDisplayOptions}
value={annotationControlsDisplayValue}
onChange={onAnnotationControlDisplayChange}
width="auto"
minWidth={100}
/>
</Field>
{/* Show in */}
<Field
noMargin
label={t('dashboard-scene.annotation-settings-edit.label-show-in', 'Show in')}
data-testid={selectors.pages.Dashboard.Settings.Annotations.NewAnnotation.showInLabel}
>
<>
<Select
options={getPanelFilters()}
value={panelFilter}
onChange={onFilterTypeChange}
data-testid={selectors.components.Annotations.annotationsTypeInput}
/>
{panelFilter !== PanelFilterType.AllPanels && (
<MultiSelect
options={selectablePanels}
value={selectablePanels.filter((panel) => annotation.filter?.ids.includes(panel.value!))}
onChange={onAddFilterPanelID}
isClearable={true}
placeholder={t('dashboard-scene.annotation-settings-edit.placeholder-choose-panels', 'Choose panels')}
width={100}
closeMenuOnSelect={false}
className={styles.select}
data-testid={selectors.components.Annotations.annotationsChoosePanelInput}
/>
)}
</>
</Field>
</Stack>
</FieldSet>
<FieldSet>
<h3 className="page-heading">
@@ -0,0 +1,118 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { DashboardLink } from '@grafana/schema';
import { DashboardLinkForm } from './DashboardLinkForm';
import { NEW_LINK } from './utils';
describe('DashboardLinkForm', () => {
const defaultLink: DashboardLink = {
title: 'Test Link',
type: 'link',
url: 'https://example.com',
icon: 'external link',
tags: [],
asDropdown: false,
targetBlank: false,
includeVars: false,
keepTime: false,
tooltip: '',
};
it('should render the "Show in controls menu" checkbox', () => {
const onUpdate = jest.fn();
const onGoBack = jest.fn();
render(<DashboardLinkForm link={defaultLink} onUpdate={onUpdate} onGoBack={onGoBack} />);
expect(screen.getByText('Show in controls menu')).toBeInTheDocument();
});
it('should call onUpdate with placement="inControlsMenu" when checkbox is checked', async () => {
const onUpdate = jest.fn();
const onGoBack = jest.fn();
const user = userEvent.setup();
render(<DashboardLinkForm link={defaultLink} onUpdate={onUpdate} onGoBack={onGoBack} />);
const checkbox = screen.getByRole('checkbox', { name: 'Show in controls menu' });
await user.click(checkbox);
expect(onUpdate).toHaveBeenCalledWith({
...defaultLink,
placement: 'inControlsMenu',
});
});
it('should call onUpdate with placement=undefined when checkbox is unchecked', async () => {
const onUpdate = jest.fn();
const onGoBack = jest.fn();
const user = userEvent.setup();
const linkWithPlacement: DashboardLink = {
...defaultLink,
placement: 'inControlsMenu',
};
render(<DashboardLinkForm link={linkWithPlacement} onUpdate={onUpdate} onGoBack={onGoBack} />);
const checkbox = screen.getByRole('checkbox', { name: 'Show in controls menu' });
await user.click(checkbox);
expect(onUpdate).toHaveBeenCalledWith({
...linkWithPlacement,
placement: undefined,
});
});
it('should have checkbox checked when placement is "inControlsMenu"', () => {
const onUpdate = jest.fn();
const onGoBack = jest.fn();
const linkWithPlacement: DashboardLink = {
...defaultLink,
placement: 'inControlsMenu',
};
render(<DashboardLinkForm link={linkWithPlacement} onUpdate={onUpdate} onGoBack={onGoBack} />);
const checkbox = screen.getByRole('checkbox', { name: 'Show in controls menu' });
expect(checkbox).toBeChecked();
});
it('should have checkbox unchecked when placement is undefined', () => {
const onUpdate = jest.fn();
const onGoBack = jest.fn();
render(<DashboardLinkForm link={defaultLink} onUpdate={onUpdate} onGoBack={onGoBack} />);
const checkbox = screen.getByRole('checkbox', { name: 'Show in controls menu' });
expect(checkbox).not.toBeChecked();
});
it('should render checkbox for dashboards type links', () => {
const onUpdate = jest.fn();
const onGoBack = jest.fn();
const dashboardsLink: DashboardLink = {
...defaultLink,
type: 'dashboards',
url: '',
};
render(<DashboardLinkForm link={dashboardsLink} onUpdate={onUpdate} onGoBack={onGoBack} />);
expect(screen.getByText('Show in controls menu')).toBeInTheDocument();
});
it('should autofocus title input for new links', () => {
const onUpdate = jest.fn();
const onGoBack = jest.fn();
render(<DashboardLinkForm link={NEW_LINK} onUpdate={onUpdate} onGoBack={onGoBack} />);
const titleInput = screen.getByRole('textbox', { name: 'Title' });
expect(titleInput).toHaveFocus();
});
});
@@ -1,9 +1,10 @@
import { css } from '@emotion/css';
import * as React from 'react';
import { SelectableValue } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { DashboardLink } from '@grafana/schema';
import { CollapsableSection, TagsInput, Select, Field, Input, Checkbox, Button } from '@grafana/ui';
import { CollapsableSection, TagsInput, Select, Field, Input, Checkbox, Button, Stack, useStyles2 } from '@grafana/ui';
import { LINK_ICON_MAP, NEW_LINK } from './utils';
@@ -16,6 +17,7 @@ interface DashboardLinkFormProps {
}
export function DashboardLinkForm({ link, onUpdate, onGoBack }: DashboardLinkFormProps) {
const styles = useStyles2(getStyles);
const linkTypeOptions = [
{
value: 'dashboards',
@@ -53,86 +55,138 @@ export function DashboardLinkForm({ link, onUpdate, onGoBack }: DashboardLinkFor
});
};
const onPlacementChange = (ev: React.FocusEvent<HTMLInputElement>) => {
const isChecked = ev.currentTarget.checked;
onUpdate({
...link,
placement: isChecked ? 'inControlsMenu' : undefined,
});
};
const isNew = link.title === NEW_LINK.title;
return (
<div style={{ maxWidth: '600px' }}>
<Field label={t('dashboard-scene.dashboard-link-form.label-title', 'Title')}>
<Input name="title" id="title" value={link.title} onChange={onChange} autoFocus={isNew} />
</Field>
<Field label={t('dashboard-scene.dashboard-link-form.label-type', 'Type')}>
<Select inputId="link-type-input" value={link.type} options={linkTypeOptions} onChange={onTypeChange} />
</Field>
{link.type === 'dashboards' && (
<>
<Field label={t('dashboard-scene.dashboard-link-form.label-with-tags', 'With tags')}>
<Stack direction="column" gap={2}>
{/* Title */}
<Field noMargin label={t('dashboard-scene.dashboard-link-form.label-title', 'Title')}>
<Input name="title" id="title" value={link.title} onChange={onChange} autoFocus={isNew} />
</Field>
{/* Type */}
<Field noMargin label={t('dashboard-scene.dashboard-link-form.label-type', 'Type')}>
<Select inputId="link-type-input" value={link.type} options={linkTypeOptions} onChange={onTypeChange} />
</Field>
{/* Tags */}
{link.type === 'dashboards' && (
<Field noMargin label={t('dashboard-scene.dashboard-link-form.label-with-tags', 'With tags')}>
<TagsInput tags={link.tags} onChange={onTagsChange} />
</Field>
</>
)}
{link.type === 'link' && (
<>
<Field label={t('dashboard-scene.dashboard-link-form.label-url', 'URL')}>
<Input name="url" value={link.url} onChange={onChange} />
</Field>
<Field label={t('dashboard-scene.dashboard-link-form.label-tooltip', 'Tooltip')}>
<Input
name="tooltip"
value={link.tooltip}
onChange={onChange}
placeholder={t('dashboard-scene.dashboard-link-form.placeholder-open-dashboard', 'Open dashboard')}
/>
</Field>
<Field label={t('dashboard-scene.dashboard-link-form.label-icon', 'Icon')}>
<Select value={link.icon} options={linkIconOptions} onChange={onIconChange} />
</Field>
</>
)}
<CollapsableSection label={t('dashboard-scene.dashboard-link-form.label-options', 'Options')} isOpen={true}>
{link.type === 'dashboards' && (
<Field>
<Checkbox
label={t('dashboard-scene.dashboard-link-form.label-show-as-dropdown', 'Show as dropdown')}
name="asDropdown"
value={link.asDropdown}
onChange={onChange}
/>
</Field>
)}
<Field>
<Checkbox
label={t(
'dashboard-scene.dashboard-link-form.label-include-current-time-range',
'Include current time range'
{link.type === 'link' && (
<Stack direction="column" gap={2}>
{/* URL */}
<Field noMargin label={t('dashboard-scene.dashboard-link-form.label-url', 'URL')}>
<Input name="url" value={link.url} onChange={onChange} />
</Field>
{/* Tooltip */}
<Field noMargin label={t('dashboard-scene.dashboard-link-form.label-tooltip', 'Tooltip')}>
<Input
name="tooltip"
value={link.tooltip}
onChange={onChange}
placeholder={t('dashboard-scene.dashboard-link-form.placeholder-open-dashboard', 'Open dashboard')}
/>
</Field>
{/* Icon */}
<Field noMargin label={t('dashboard-scene.dashboard-link-form.label-icon', 'Icon')}>
<Select value={link.icon} options={linkIconOptions} onChange={onIconChange} />
</Field>
</Stack>
)}
<CollapsableSection
label={t('dashboard-scene.dashboard-link-form.label-options', 'Options')}
isOpen={true}
contentClassName={styles.collapsableSection}
>
<Stack direction="column" gap={2}>
{/* Show as dropdown */}
{link.type === 'dashboards' && (
<Field noMargin>
<Checkbox
label={t('dashboard-scene.dashboard-link-form.label-show-as-dropdown', 'Show as dropdown')}
name="asDropdown"
value={link.asDropdown}
onChange={onChange}
/>
</Field>
)}
name="keepTime"
value={link.keepTime}
onChange={onChange}
/>
</Field>
<Field>
<Checkbox
label={t(
'dashboard-scene.dashboard-link-form.label-include-current-template-variable-values',
'Include current template variable values'
)}
name="includeVars"
value={link.includeVars}
onChange={onChange}
/>
</Field>
<Field>
<Checkbox
label={t('dashboard-scene.dashboard-link-form.label-open-link-in-new-tab', 'Open link in new tab')}
name="targetBlank"
value={link.targetBlank}
onChange={onChange}
/>
</Field>
</CollapsableSection>
<Button variant="secondary" onClick={onGoBack}>
<Trans i18nKey="dashboard-scene.dashboard-link-form.back-to-list">Back to list</Trans>
</Button>
{/* Include time range */}
<Field noMargin>
<Checkbox
label={t(
'dashboard-scene.dashboard-link-form.label-include-current-time-range',
'Include current time range'
)}
name="keepTime"
value={link.keepTime}
onChange={onChange}
/>
</Field>
{/* Include variables */}
<Field noMargin>
<Checkbox
label={t(
'dashboard-scene.dashboard-link-form.label-include-current-template-variable-values',
'Include current template variable values'
)}
name="includeVars"
value={link.includeVars}
onChange={onChange}
/>
</Field>
{/* Open in new tab */}
<Field noMargin>
<Checkbox
label={t('dashboard-scene.dashboard-link-form.label-open-link-in-new-tab', 'Open link in new tab')}
name="targetBlank"
value={link.targetBlank}
onChange={onChange}
/>
</Field>
{/* Show in controls */}
<Field noMargin>
<Checkbox
label={t('dashboard-scene.dashboard-link-form.label-show-in-controls-menu', 'Show in controls menu')}
name="placement"
value={link.placement === 'inControlsMenu'}
onChange={onPlacementChange}
/>
</Field>
</Stack>
</CollapsableSection>
<div>
<Button variant="secondary" onClick={onGoBack}>
<Trans i18nKey="dashboard-scene.dashboard-link-form.back-to-list">Back to list</Trans>
</Button>
</div>
</Stack>
</div>
);
}
const getStyles = () => ({
collapsableSection: css({
padding: 0,
}),
});
@@ -13,7 +13,7 @@ import { dashboardEditActions } from '../../edit-pane/shared';
import { useEditPaneInputAutoFocus } from '../../scene/layouts-shared/utils';
import { BulkActionElement } from '../../scene/types/BulkActionElement';
import { EditableDashboardElement, EditableDashboardElementInfo } from '../../scene/types/EditableDashboardElement';
import { VariableHideSelect } from '../../settings/variables/components/VariableHideSelect';
import { VariableDisplaySelect } from '../../settings/variables/components/VariableDisplaySelect';
import { getEditableVariableDefinition, validateVariableName } from '../../settings/variables/utils';
import { useVariableSelectionOptionsCategory } from './useVariableSelectionOptionsCategory';
@@ -25,7 +25,7 @@ function useEditPaneOptions(this: VariableEditableElement, isNewElement: boolean
const variableNameId = useId();
const labelId = useId();
const descriptionId = useId();
const variableHideId = useId();
const variableDisplayId = useId();
if (variable instanceof LocalValueVariable) {
return useLocalVariableOptions(variable);
@@ -59,12 +59,12 @@ function useEditPaneOptions(this: VariableEditableElement, isNewElement: boolean
.addItem(
new OptionsPaneItemDescriptor({
title: '',
id: variableHideId,
id: variableDisplayId,
skipField: true,
render: () => <VariableHideInput variable={variable} />,
render: () => <VariableDisplayInput variable={variable} />,
})
);
}, [variableOptionsCategoryId, variableNameId, labelId, descriptionId, variableHideId, variable, isNewElement]);
}, [variableOptionsCategoryId, variableNameId, labelId, descriptionId, variableDisplayId, variable, isNewElement]);
const categories = [basicOptions];
const typeCategory = useVariableTypeCategory(variable);
@@ -248,18 +248,18 @@ function VariableDescriptionTextArea({ variable, id }: VariableInputProps) {
);
}
function VariableHideInput({ variable }: VariableInputProps) {
const { hide = VariableHide.dontHide } = variable.useState();
function VariableDisplayInput({ variable }: VariableInputProps) {
const { hide: display = VariableHide.dontHide } = variable.useState();
const onChange = (option: VariableHide) => {
dashboardEditActions.changeVariableHideValue({
source: variable,
oldValue: hide,
oldValue: display,
newValue: option,
});
};
return <VariableHideSelect hide={hide} type={variable.state.type} onChange={onChange} />;
return <VariableDisplaySelect display={display} type={variable.state.type} onChange={onChange} />;
}
function useVariableTypeCategory(variable: SceneVariable) {
@@ -10,7 +10,7 @@ import { reportInteraction } from '@grafana/runtime';
import { SceneVariable } from '@grafana/scenes';
import { VariableHide, defaultVariableModel } from '@grafana/schema';
import { Button, LoadingPlaceholder, ConfirmModal, ModalsController, Stack, useStyles2 } from '@grafana/ui';
import { VariableHideSelect } from 'app/features/dashboard-scene/settings/variables/components/VariableHideSelect';
import { VariableDisplaySelect } from 'app/features/dashboard-scene/settings/variables/components/VariableDisplaySelect';
import { VariableLegend } from 'app/features/dashboard-scene/settings/variables/components/VariableLegend';
import { VariableTextAreaField } from 'app/features/dashboard-scene/settings/variables/components/VariableTextAreaField';
import { VariableTextField } from 'app/features/dashboard-scene/settings/variables/components/VariableTextField';
@@ -35,7 +35,7 @@ interface VariableEditorFormProps {
export function VariableEditorForm({ variable, onTypeChange, onGoBack, onDelete }: VariableEditorFormProps) {
const styles = useStyles2(getStyles);
const [nameError, setNameError] = useState<string>();
const { name, type, label, description, hide } = variable.useState();
const { name, type, label, description, hide: display } = variable.useState();
const EditorToRender = isEditableVariableType(type) ? getVariableEditor(type) : undefined;
const [runQueryState, onRunQuery] = useAsyncFn(async () => {
await lastValueFrom(variable.validateAndUpdate!());
@@ -65,7 +65,7 @@ export function VariableEditorForm({ variable, onTypeChange, onGoBack, onDelete
const onLabelBlur = (e: FormEvent<HTMLInputElement>) => variable.setState({ label: e.currentTarget.value });
const onDescriptionBlur = (e: FormEvent<HTMLTextAreaElement>) =>
variable.setState({ description: e.currentTarget.value });
const onHideChange = (hide: VariableHide) => variable.setState({ hide });
const onDisplayChange = (display: VariableHide) => variable.setState({ hide: display });
const isHasVariableOptions = hasVariableOptions(variable);
@@ -119,7 +119,7 @@ export function VariableEditorForm({ variable, onTypeChange, onGoBack, onDelete
width={52}
/>
<VariableHideSelect onChange={onHideChange} hide={hide || defaultVariableModel.hide!} type={type} />
<VariableDisplaySelect onChange={onDisplayChange} display={display || defaultVariableModel.hide!} type={type} />
{EditorToRender && <EditorToRender variable={variable} onRunQuery={onRunQuery} />}
@@ -0,0 +1,84 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { VariableHide } from '@grafana/data';
import { VariableDisplaySelect } from './VariableDisplaySelect';
// For testing combobox
beforeAll(() => {
const mockGetBoundingClientRect = jest.fn(() => ({
width: 120,
height: 120,
top: 0,
left: 0,
bottom: 0,
right: 0,
}));
Object.defineProperty(Element.prototype, 'getBoundingClientRect', {
value: mockGetBoundingClientRect,
});
});
describe('VariableDisplaySelect', () => {
it('should render all options when opening the combobox', async () => {
const onChange = jest.fn();
const user = userEvent.setup();
render(<VariableDisplaySelect onChange={onChange} display={VariableHide.dontHide} type="query" />);
const combobox = screen.getByRole('combobox');
await user.click(combobox);
expect(await screen.findByText('Above dashboard')).toBeInTheDocument();
expect(screen.getByText('Above dashboard, label hidden')).toBeInTheDocument();
expect(screen.getByText('Controls menu')).toBeInTheDocument();
expect(screen.getByText('Hidden')).toBeInTheDocument();
});
it('should call onChange() with the selected value', async () => {
const onChange = jest.fn();
const user = userEvent.setup();
render(<VariableDisplaySelect onChange={onChange} display={VariableHide.dontHide} type="query" />);
const combobox = screen.getByRole('combobox');
await user.click(combobox);
const controlsMenuOption = await screen.findByText('Controls menu');
await user.click(controlsMenuOption);
expect(onChange).toHaveBeenCalledWith(VariableHide.inControlsMenu);
});
it('should have "Controls menu" selected when `hide` is set to "inControlsMenu"', () => {
const onChange = jest.fn();
render(<VariableDisplaySelect onChange={onChange} display={VariableHide.inControlsMenu} type="query" />);
expect(screen.getByDisplayValue('Controls menu')).toBeInTheDocument();
});
it('should not render anything for constant type variables', () => {
const onChange = jest.fn();
const { container } = render(
<VariableDisplaySelect onChange={onChange} display={VariableHide.dontHide} type="constant" />
);
expect(container).toBeEmptyDOMElement();
});
it('should call onChange() when switching from "Controls menu" to "Above dashboard"', async () => {
const onChange = jest.fn();
const user = userEvent.setup();
render(<VariableDisplaySelect onChange={onChange} display={VariableHide.inControlsMenu} type="query" />);
const combobox = screen.getByRole('combobox');
await user.click(combobox);
const aboveDashboardOption = await screen.findByText('Above dashboard');
await user.click(aboveDashboardOption);
expect(onChange).toHaveBeenCalledWith(VariableHide.dontHide);
});
});
@@ -0,0 +1,65 @@
import { PropsWithChildren, useMemo } from 'react';
import { VariableType, VariableHide } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { t } from '@grafana/i18n';
import { Combobox, Field } from '@grafana/ui';
interface Props {
onChange: (option: VariableHide) => void;
display: VariableHide;
type: VariableType;
minWidth?: number;
}
export function VariableDisplaySelect({ onChange, display, type, minWidth = 52 }: PropsWithChildren<Props>) {
const OPTIONS = useMemo(
() => [
{
value: VariableHide.dontHide,
label: t('dashboard-scene.variable-display-select.options.above-dashboard.label', 'Above dashboard'),
},
{
value: VariableHide.hideLabel,
label: t('dashboard-scene.variable-display-select.options.hidden-label.label', 'Above dashboard, label hidden'),
description: t(
'dashboard-scene.variable-display-select.options.hidden-label.description',
'Above the dashboard, but without showing the name of variable'
),
},
{
value: VariableHide.inControlsMenu,
label: t('dashboard-scene.variable-display-select.options.controls-menu.label', 'Controls menu'),
description: t(
'dashboard-scene.variable-display-select.options.controls-menu.description',
'Visible when the controls menu is open'
),
},
{
value: VariableHide.hideVariable,
label: t('dashboard-scene.variable-display-select.options.hidden.label', 'Hidden'),
},
],
[]
);
const value = useMemo(() => OPTIONS.find((o) => o.value === display)?.value ?? OPTIONS[0].value, [display, OPTIONS]);
// Constant variables don't support display options
if (type === 'constant') {
return null;
}
return (
// eslint-disable-next-line no-restricted-syntax
<Field label={t('dashboard-scene.variable-display-select.label', 'Display')}>
<Combobox
data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.General.generalDisplaySelect}
options={OPTIONS}
onChange={(option) => option && onChange(option.value)}
value={value}
width="auto"
minWidth={minWidth}
/>
</Field>
);
}
@@ -38,6 +38,7 @@ export function VariableHideSelect({ onChange, hide, type }: PropsWithChildren<P
}
return (
// eslint-disable-next-line no-restricted-syntax
<Field label={t('dashboard-scene.variable-hide-select.label', 'Hide')}>
<RadioButtonGroup options={HIDE_OPTIONS} onChange={onChange} value={value} />
</Field>
+34 -2
View File
@@ -5824,14 +5824,26 @@
},
"annotation-settings-edit": {
"back-to-list": "Back to list",
"control-display-options": {
"above-dashboard": {
"label": "Above dashboard"
},
"controls-menu": {
"description": "Can be accessed when the controls menu is open",
"label": "Controls menu"
},
"hidden": {
"description": "Hides the toggle for turning this annotation on or off",
"label": "Hidden"
}
},
"delete": "Delete",
"description-color-annotation-event-markers": "Color to use for the annotation event markers",
"description-enabled-annotation-query-issued-every-dashboard": "When enabled the annotation query is issued every dashboard refresh",
"description-hidden": "Annotation queries can be toggled on or off at the top of the dashboard. With this option checked this toggle will be hidden.",
"label-annotation-controls-display": "Show annotation controls in",
"label-color": "Color",
"label-data-source": "Data source",
"label-enabled": "Enabled",
"label-hidden": "Hidden",
"label-name": "Name",
"label-show-in": "Show in",
"placeholder-choose-panels": "Choose panels",
@@ -5887,6 +5899,7 @@
"label-open-link-in-new-tab": "Open link in new tab",
"label-options": "Options",
"label-show-as-dropdown": "Show as dropdown",
"label-show-in-controls-menu": "Show in controls menu",
"label-title": "Title",
"label-tooltip": "Tooltip",
"label-type": "Type",
@@ -6448,6 +6461,25 @@
"variable-controls": {
"add-variable": "Add variable"
},
"variable-display-select": {
"label": "Display",
"options": {
"above-dashboard": {
"label": "Above dashboard"
},
"controls-menu": {
"description": "Visible when the controls menu is open",
"label": "Controls menu"
},
"hidden": {
"label": "Hidden"
},
"hidden-label": {
"description": "Above the dashboard, but without showing the name of variable",
"label": "Above dashboard, label hidden"
}
}
},
"variable-editor-form": {
"aria-label-variable-editor-form": "Variable editor form",
"back-to-list": "Back to list",