From 32a58c56ed9e1a9b0959c2fca66157aeb2a03ff6 Mon Sep 17 00:00:00 2001 From: Levente Balogh Date: Thu, 4 Dec 2025 16:22:41 +0100 Subject: [PATCH] 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 --- .../new-query-variable.spec.ts | 8 +- eslint-suppressions.json | 15 - .../src/selectors/pages.ts | 6 + .../scene/DashboardControls.tsx | 26 +- .../scene/DashboardControlsMenu.tsx | 137 --------- .../scene/DataLayerControl.tsx | 1 + .../DashboardControlsMenu.tsx | 88 ++++++ .../DashboardControlsMenuButton.test.tsx} | 5 +- .../DashboardControlsMenuButton.tsx | 51 ++++ .../scene/dashboard-controls-menu/utils.tsx | 62 ++++ .../settings/AnnotationsEditView.tsx | 1 + .../AnnotationSettingsEdit.test.tsx | 144 +++++++-- .../annotations/AnnotationSettingsEdit.tsx | 288 ++++++++++++------ .../settings/links/DashboardLinkForm.test.tsx | 118 +++++++ .../settings/links/DashboardLinkForm.tsx | 200 +++++++----- .../variables/VariableEditableElement.tsx | 18 +- .../settings/variables/VariableEditorForm.tsx | 8 +- .../components/VariableDisplaySelect.test.tsx | 84 +++++ .../components/VariableDisplaySelect.tsx | 65 ++++ .../components/VariableHideSelect.tsx | 1 + public/locales/en-US/grafana.json | 36 ++- 21 files changed, 980 insertions(+), 382 deletions(-) delete mode 100644 public/app/features/dashboard-scene/scene/DashboardControlsMenu.tsx create mode 100644 public/app/features/dashboard-scene/scene/dashboard-controls-menu/DashboardControlsMenu.tsx rename public/app/features/dashboard-scene/scene/{DashboardControlsMenu.test.tsx => dashboard-controls-menu/DashboardControlsMenuButton.test.tsx} (97%) create mode 100644 public/app/features/dashboard-scene/scene/dashboard-controls-menu/DashboardControlsMenuButton.tsx create mode 100644 public/app/features/dashboard-scene/scene/dashboard-controls-menu/utils.tsx create mode 100644 public/app/features/dashboard-scene/settings/links/DashboardLinkForm.test.tsx create mode 100644 public/app/features/dashboard-scene/settings/variables/components/VariableDisplaySelect.test.tsx create mode 100644 public/app/features/dashboard-scene/settings/variables/components/VariableDisplaySelect.tsx diff --git a/e2e-playwright/dashboards-suite/new-query-variable.spec.ts b/e2e-playwright/dashboards-suite/new-query-variable.spec.ts index 088f4bd9b12..ed92c79ee36 100644 --- a/e2e-playwright/dashboards-suite/new-query-variable.spec.ts +++ b/e2e-playwright/dashboards-suite/new-query-variable.spec.ts @@ -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( diff --git a/eslint-suppressions.json b/eslint-suppressions.json index d358be67002..5c863244c20 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -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 diff --git a/packages/grafana-e2e-selectors/src/selectors/pages.ts b/packages/grafana-e2e-selectors/src/selectors/pages.ts index 79422ee8db9..47a5573b00d 100644 --- a/packages/grafana-e2e-selectors/src/selectors/pages.ts +++ b/packages/grafana-e2e-selectors/src/selectors/pages.ts @@ -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', }, diff --git a/public/app/features/dashboard-scene/scene/DashboardControls.tsx b/public/app/features/dashboard-scene/scene/DashboardControls.tsx index 653829fcb4f..f21a9ea01aa 100644 --- a/public/app/features/dashboard-scene/scene/DashboardControls.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardControls.tsx @@ -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 { } } - // 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 { 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{renderHiddenVariables(dashboard)}; } @@ -176,11 +166,6 @@ function DashboardControlsRenderer({ model }: SceneComponentProps )} - {!hideDashboardControls && model.hasDashboardControls() && ( -
- -
- )} {config.featureToggles.dashboardNewLayouts && (
@@ -194,6 +179,7 @@ function DashboardControlsRenderer({ model }: SceneComponentProps )} + {!hideDashboardControls && hasDashboardControls && } {editPanel && } {showDebugger && }
diff --git a/public/app/features/dashboard-scene/scene/DashboardControlsMenu.tsx b/public/app/features/dashboard-scene/scene/DashboardControlsMenu.tsx deleted file mode 100644 index 03ac5f0ea88..00000000000 --- a/public/app/features/dashboard-scene/scene/DashboardControlsMenu.tsx +++ /dev/null @@ -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 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 ( - - } - > - - - ); -} - -interface DashboardControlsMenuProps { - variables: SceneVariable[]; - links: DashboardLink[]; - annotationLayers: SceneDataLayerProvider[]; - dashboardUID: string; -} - -function DashboardControlsMenu({ variables, links, annotationLayers, dashboardUID }: DashboardControlsMenuProps) { - const styles = useStyles2(getStyles); - - return ( - { - // 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) => ( -
0 })} key={variable.state.key}> - -
- ))} - - {/* Annotation layers */} - {annotationLayers.length > 0 && - annotationLayers.map((layer, index) => ( -
0 || index > 0 })} key={layer.state.key}> - -
- ))} - - {/* Links */} - {links.length > 0 && ( - <> - {(variables.length > 0 || annotationLayers.length > 0) && } - {links.map((link, index) => ( -
- -
- ))} - - )} -
- ); -} - -function MenuDivider() { - const styles = useStyles2(getStyles); - - return ( -
- -
- ); -} - -const getStyles = (theme: GrafanaTheme2) => ({ - divider: css({ - marginTop: theme.spacing(2), - padding: theme.spacing(0, 0.5), - }), - menuItem: css({ - marginTop: theme.spacing(2), - }), -}); diff --git a/public/app/features/dashboard-scene/scene/DataLayerControl.tsx b/public/app/features/dashboard-scene/scene/DataLayerControl.tsx index 6318fa248a6..3fc19bb2a3d 100644 --- a/public/app/features/dashboard-scene/scene/DataLayerControl.tsx +++ b/public/app/features/dashboard-scene/scene/DataLayerControl.tsx @@ -66,6 +66,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ gap: theme.spacing(1), }), controlWrapper: css({ + height: theme.spacing(2), '& > div': { border: 'none', background: 'transparent', diff --git a/public/app/features/dashboard-scene/scene/dashboard-controls-menu/DashboardControlsMenu.tsx b/public/app/features/dashboard-scene/scene/dashboard-controls-menu/DashboardControlsMenu.tsx new file mode 100644 index 00000000000..9d27ee01f0f --- /dev/null +++ b/public/app/features/dashboard-scene/scene/dashboard-controls-menu/DashboardControlsMenu.tsx @@ -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 ( + { + // 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) => ( +
0 })} key={variable.state.key}> + +
+ ))} + + {/* Annotation layers */} + {annotations.length > 0 && + annotations.map((layer, index) => ( +
0 || index > 0 })} key={layer.state.key}> + +
+ ))} + + {/* Links */} + {links.length > 0 && dashboardUID && ( + <> + {(variables.length > 0 || annotations.length > 0) && } + {links.map((link, index) => ( +
+ +
+ ))} + + )} +
+ ); +} + +function MenuDivider() { + const styles = useStyles2(getStyles); + + return ( +
+ +
+ ); +} + +const getStyles = (theme: GrafanaTheme2) => ({ + divider: css({ + marginTop: theme.spacing(2), + padding: theme.spacing(0, 0.5), + }), + menuItem: css({ + marginTop: theme.spacing(2), + }), +}); diff --git a/public/app/features/dashboard-scene/scene/DashboardControlsMenu.test.tsx b/public/app/features/dashboard-scene/scene/dashboard-controls-menu/DashboardControlsMenuButton.test.tsx similarity index 97% rename from public/app/features/dashboard-scene/scene/DashboardControlsMenu.test.tsx rename to public/app/features/dashboard-scene/scene/dashboard-controls-menu/DashboardControlsMenuButton.test.tsx index 101bd69ec97..867513ffc8c 100644 --- a/public/app/features/dashboard-scene/scene/DashboardControlsMenu.test.tsx +++ b/public/app/features/dashboard-scene/scene/dashboard-controls-menu/DashboardControlsMenuButton.test.tsx @@ -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', () => { diff --git a/public/app/features/dashboard-scene/scene/dashboard-controls-menu/DashboardControlsMenuButton.tsx b/public/app/features/dashboard-scene/scene/dashboard-controls-menu/DashboardControlsMenuButton.tsx new file mode 100644 index 00000000000..898eb471373 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/dashboard-controls-menu/DashboardControlsMenuButton.tsx @@ -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 ( + + } + > + + + {dashboardControlsCount} + + + ); +} + +const getStyles = (theme: GrafanaTheme2) => ({ + dropdownButton: css({ + display: 'inline-flex', + }), +}); diff --git a/public/app/features/dashboard-scene/scene/dashboard-controls-menu/utils.tsx b/public/app/features/dashboard-scene/scene/dashboard-controls-menu/utils.tsx new file mode 100644 index 00000000000..6514ea34ed9 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/dashboard-controls-menu/utils.tsx @@ -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 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; +} diff --git a/public/app/features/dashboard-scene/settings/AnnotationsEditView.tsx b/public/app/features/dashboard-scene/settings/AnnotationsEditView.tsx index bbc49a8fc30..31520a0d168 100644 --- a/public/app/features/dashboard-scene/settings/AnnotationsEditView.tsx +++ b/public/app/features/dashboard-scene/settings/AnnotationsEditView.tsx @@ -119,6 +119,7 @@ export class AnnotationsEditView extends SceneObjectBase { }; } + // 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()), + }; + + 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()), + }; + + 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 + ); + }); }); diff --git a/public/app/features/dashboard-scene/settings/annotations/AnnotationSettingsEdit.tsx b/public/app/features/dashboard-scene/settings/annotations/AnnotationSettingsEdit.tsx index 269d7bc4056..03ed262c210 100644 --- a/public/app/features/dashboard-scene/settings/annotations/AnnotationSettingsEdit.tsx +++ b/public/app/features/dashboard-scene/settings/annotations/AnnotationSettingsEdit.tsx @@ -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) => { + onUpdate( + { + ...annotation, + placement: option.value === AnnotationControlsDisplay.InControlsMenu ? 'inControlsMenu' : undefined, + hide: option.value === AnnotationControlsDisplay.Hidden ? true : false, + }, + editIndex + ); + }; + const onNameChange = (ev: React.FocusEvent) => { onUpdate( { @@ -143,7 +220,7 @@ export const AnnotationSettingsEdit = ({ annotation, editIndex, panels, onUpdate const sortFn = (a: SelectableValue, b: SelectableValue) => { 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 (
- - - - - - - {!ds?.meta.annotations && ( - - - The selected data source does not support annotations. Please select a different data source. - - - )} - - - - - - - - - - - - - <> - - {panelFilter !== PanelFilterType.AllPanels && ( - 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} - /> + + + {/* Data source */} + + + + {!ds?.meta.annotations && ( + + + The selected data source does not support annotations. Please select a different data source. + + + )} + + {/* Enabled */} + - + > + + + + {/* Color */} + + + + + + + {/* Annotation controls display */} + + + + + {/* Show in */} + + <> + - - - + + + {/* Type */} + + - - - - - - + + + {/* Tooltip */} + + + + + {/* Icon */} + +