From 0122f7ccadaccde41d41668a97d873dba3ca0591 Mon Sep 17 00:00:00 2001 From: Dominik Prokop Date: Wed, 15 Nov 2023 16:49:51 +0100 Subject: [PATCH] DashboardScene: Support dashboard links (#77855) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * MenuItem: Allow react node as label * LinkButton: Expose ButtonLinkProps * Typecheck fix * DashboardLinks: Refactor and use LinkButton and menu * DashbaordLinks scene object * Use flex layout for dashboard controls * Update public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.tsx Co-authored-by: Alex Khomenko * fix keepTime and includeVars * Add ellipsis to menu item label and description * Use DashboardLink type from grafana/schema * Update dashboard scene controls layout * Fix e2e * Test fix * Bring back keyboard navigation * Remove unused code * One more fix --------- Co-authored-by: Alex Khomenko Co-authored-by: Torkel Ödegaard --- .betterer.results | 13 +- .../kinds/core/dashboard/schema-reference.md | 2 +- kinds/dashboard/dashboard_kind.cue | 2 +- .../raw/dashboard/x/dashboard_types.gen.ts | 2 +- .../src/components/Button/Button.tsx | 4 +- .../DataLinks/DataLinksContextMenu.tsx | 8 +- .../src/components/Menu/MenuItem.tsx | 9 +- pkg/kinds/dashboard/dashboard_spec_gen.go | 2 +- .../scene/DashboardControls.tsx | 42 ++++ .../scene/DashboardLinksControls.tsx | 58 +++++ .../transformSaveModelToScene.test.ts | 20 +- .../transformSaveModelToScene.ts | 39 ++-- .../transformSceneToSaveModel.ts | 6 +- .../DashboardSettings/LinksSettings.test.tsx | 4 +- .../LinksSettings/LinkSettingsEdit.tsx | 3 +- .../LinksSettings/LinkSettingsList.tsx | 3 +- .../components/SubMenu/DashboardLinks.tsx | 15 +- .../SubMenu/DashboardLinksDashboard.test.tsx | 2 +- .../SubMenu/DashboardLinksDashboard.tsx | 200 ++++++++---------- .../dashboard/components/SubMenu/SubMenu.tsx | 2 +- .../dashboard/state/DashboardModel.ts | 16 +- .../app/features/panel/panellinks/link_srv.ts | 21 +- .../panel/panellinks/specs/link_srv.test.ts | 50 ++++- .../timeseries/plugins/ContextMenuPlugin.tsx | 2 +- 24 files changed, 324 insertions(+), 201 deletions(-) create mode 100644 public/app/features/dashboard-scene/scene/DashboardControls.tsx create mode 100644 public/app/features/dashboard-scene/scene/DashboardLinksControls.tsx diff --git a/.betterer.results b/.betterer.results index be89734c7d6..fcc34c4f18e 100644 --- a/.betterer.results +++ b/.betterer.results @@ -3190,6 +3190,10 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"] ], + "public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.tsx:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"], + [0, 0, 0, "Unexpected any. Specify a different type.", "1"] + ], "public/app/features/dashboard/components/SubMenu/SubMenu.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"] @@ -3376,10 +3380,8 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "21"], [0, 0, 0, "Unexpected any. Specify a different type.", "22"], [0, 0, 0, "Unexpected any. Specify a different type.", "23"], - [0, 0, 0, "Unexpected any. Specify a different type.", "24"], - [0, 0, 0, "Unexpected any. Specify a different type.", "25"], - [0, 0, 0, "Do not use any type assertions.", "26"], - [0, 0, 0, "Unexpected any. Specify a different type.", "27"] + [0, 0, 0, "Do not use any type assertions.", "24"], + [0, 0, 0, "Unexpected any. Specify a different type.", "25"] ], "public/app/features/dashboard/state/PanelModel.test.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], @@ -4500,8 +4502,7 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"] + [0, 0, 0, "Unexpected any. Specify a different type.", "3"] ], "public/app/features/panel/state/actions.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] diff --git a/docs/sources/developers/kinds/core/dashboard/schema-reference.md b/docs/sources/developers/kinds/core/dashboard/schema-reference.md index 42b36f9f486..4b6f67f6c34 100644 --- a/docs/sources/developers/kinds/core/dashboard/schema-reference.md +++ b/docs/sources/developers/kinds/core/dashboard/schema-reference.md @@ -166,7 +166,7 @@ Links with references to other dashboards or external resources | `title` | string | **Yes** | | Title to display with the link | | `tooltip` | string | **Yes** | | Tooltip to display when the user hovers their mouse over it | | `type` | string | **Yes** | | Dashboard Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource)
Possible values are: `link`, `dashboards`. | -| `url` | string | **Yes** | | Link URL. Only required/valid if the type is link | +| `url` | string | No | | Link URL. Only required/valid if the type is link | ### Snapshot diff --git a/kinds/dashboard/dashboard_kind.cue b/kinds/dashboard/dashboard_kind.cue index 1fb83373166..5fb6347ee3d 100644 --- a/kinds/dashboard/dashboard_kind.cue +++ b/kinds/dashboard/dashboard_kind.cue @@ -265,7 +265,7 @@ lineage: schemas: [{ // Tooltip to display when the user hovers their mouse over it tooltip: string // Link URL. Only required/valid if the type is link - url: string + url?: string // List of tags to limit the linked dashboards. If empty, all dashboards will be displayed. Only valid if the type is dashboards tags: [...string] // If true, all dashboards links will be displayed in a dropdown. If false, all dashboards links will be displayed side by side. Only valid if the type is dashboards diff --git a/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts b/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts index d16491351f7..666357753f5 100644 --- a/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts +++ b/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts @@ -301,7 +301,7 @@ export interface DashboardLink { /** * Link URL. Only required/valid if the type is link */ - url: string; + url?: string; } export const defaultDashboardLink: Partial = { diff --git a/packages/grafana-ui/src/components/Button/Button.tsx b/packages/grafana-ui/src/components/Button/Button.tsx index 8e4f00b1c87..b2f8bf3bf32 100644 --- a/packages/grafana-ui/src/components/Button/Button.tsx +++ b/packages/grafana-ui/src/components/Button/Button.tsx @@ -83,7 +83,9 @@ export const Button = React.forwardRef( Button.displayName = 'Button'; -type ButtonLinkProps = CommonProps & ButtonHTMLAttributes & AnchorHTMLAttributes; +export type ButtonLinkProps = CommonProps & + ButtonHTMLAttributes & + AnchorHTMLAttributes; export const LinkButton = React.forwardRef( ( diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinksContextMenu.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinksContextMenu.tsx index 56c17d7e59a..23dbd13c95f 100644 --- a/packages/grafana-ui/src/components/DataLinks/DataLinksContextMenu.tsx +++ b/packages/grafana-ui/src/components/DataLinks/DataLinksContextMenu.tsx @@ -24,11 +24,11 @@ export const DataLinksContextMenu = ({ children, links, style }: DataLinksContex const itemsGroup: MenuItemsGroup[] = [{ items: linkModelToContextMenuItems(links), label: 'Data links' }]; const linksCounter = itemsGroup[0].items.length; const renderMenuGroupItems = () => { - return itemsGroup.map((group, index) => ( - - {(group.items || []).map((item) => ( + return itemsGroup.map((group, groupIdx) => ( + + {(group.items || []).map((item, itemIdx) => ( {icon && } - {label} + {label}
{hasShortcut && (
@@ -188,7 +188,7 @@ export const MenuItem = React.memo( {description && (
@@ -283,5 +283,10 @@ const getStyles = (theme: GrafanaTheme2) => { descriptionWithIcon: css({ marginLeft: theme.spacing(3), }), + ellipsis: css({ + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }), }; }; diff --git a/pkg/kinds/dashboard/dashboard_spec_gen.go b/pkg/kinds/dashboard/dashboard_spec_gen.go index e5be145cffa..7e2a86472dd 100644 --- a/pkg/kinds/dashboard/dashboard_spec_gen.go +++ b/pkg/kinds/dashboard/dashboard_spec_gen.go @@ -270,7 +270,7 @@ type Link struct { Type LinkType `json:"type"` // Link URL. Only required/valid if the type is link - Url string `json:"url"` + Url *string `json:"url,omitempty"` } // Dashboard Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource) diff --git a/public/app/features/dashboard-scene/scene/DashboardControls.tsx b/public/app/features/dashboard-scene/scene/DashboardControls.tsx new file mode 100644 index 00000000000..e7c79b0ae2f --- /dev/null +++ b/public/app/features/dashboard-scene/scene/DashboardControls.tsx @@ -0,0 +1,42 @@ +import React from 'react'; + +import { SceneObjectState, SceneObject, SceneObjectBase, SceneComponentProps } from '@grafana/scenes'; +import { Box, Stack } from '@grafana/ui'; + +import { DashboardLinksControls } from './DashboardLinksControls'; + +interface DashboardControlsState extends SceneObjectState { + variableControls: SceneObject[]; + timeControls: SceneObject[]; + linkControls: DashboardLinksControls; +} +export class DashboardControls extends SceneObjectBase { + static Component = DashboardControlsRenderer; +} + +function DashboardControlsRenderer({ model }: SceneComponentProps) { + const { variableControls, linkControls, timeControls } = model.useState(); + + return ( + + + {variableControls.map((c) => ( + + ))} + + + + + {timeControls.map((c) => ( + + ))} + + + ); +} diff --git a/public/app/features/dashboard-scene/scene/DashboardLinksControls.tsx b/public/app/features/dashboard-scene/scene/DashboardLinksControls.tsx new file mode 100644 index 00000000000..7b7411cbe01 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/DashboardLinksControls.tsx @@ -0,0 +1,58 @@ +import React from 'react'; + +import { sanitizeUrl } from '@grafana/data/src/text/sanitize'; +import { selectors } from '@grafana/e2e-selectors'; +import { SceneComponentProps, SceneObjectBase, SceneObjectState } from '@grafana/scenes'; +import { DashboardLink } from '@grafana/schema'; +import { Tooltip } from '@grafana/ui'; +import { linkIconMap } from 'app/features/dashboard/components/LinksSettings/LinkSettingsEdit'; +import { + DashboardLinkButton, + DashboardLinksDashboard, +} from 'app/features/dashboard/components/SubMenu/DashboardLinksDashboard'; +import { getLinkSrv } from 'app/features/panel/panellinks/link_srv'; + +interface DashboardLinksControlsState extends SceneObjectState { + links: DashboardLink[]; + dashboardUID: string; +} + +export class DashboardLinksControls extends SceneObjectBase { + static Component = DashboardLinksControlsRenderer; +} + +function DashboardLinksControlsRenderer({ model }: SceneComponentProps) { + const { links, dashboardUID } = model.useState(); + return ( + <> + {links.map((link: DashboardLink, index: number) => { + const linkInfo = getLinkSrv().getAnchorInfo(link); + const key = `${link.title}-$${index}`; + + if (link.type === 'dashboards') { + return ; + } + + const icon = linkIconMap[link.icon]; + + const linkElement = ( + + {linkInfo.title} + + ); + + return ( +
+ {link.tooltip ? {linkElement} : linkElement} +
+ ); + })} + + ); +} diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.test.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.test.ts index 7ac19530a5f..c6eac008668 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.test.ts @@ -30,6 +30,7 @@ import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard'; import { DASHBOARD_DATASOURCE_PLUGIN_ID } from 'app/plugins/datasource/dashboard/types'; import { DashboardDataDTO } from 'app/types'; +import { DashboardControls } from '../scene/DashboardControls'; import { PanelRepeaterGridItem } from '../scene/PanelRepeaterGridItem'; import { PanelTimeRange } from '../scene/PanelTimeRange'; import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior'; @@ -100,8 +101,11 @@ describe('transformSaveModelToScene', () => { expect(scene.state?.$timeRange?.state.weekStart).toEqual('saturday'); expect(scene.state?.$variables?.state.variables).toHaveLength(1); expect(scene.state.controls).toBeDefined(); - expect(scene.state.controls![1]).toBeInstanceOf(AdHocFilterSet); - expect((scene.state.controls![1] as AdHocFilterSet).state.name).toBe('CoolFilters'); + expect(scene.state.controls![0]).toBeInstanceOf(DashboardControls); + expect((scene.state.controls![0] as DashboardControls).state.variableControls[1]).toBeInstanceOf(AdHocFilterSet); + expect( + ((scene.state.controls![0] as DashboardControls).state.variableControls[1] as AdHocFilterSet).state.name + ).toBe('CoolFilters'); }); it('should apply cursor sync behavior', () => { @@ -723,7 +727,9 @@ describe('transformSaveModelToScene', () => { const scene = transformSaveModelToScene({ dashboard: dashboard_to_load1 as any, meta: {} }); expect(scene.state.$data).toBeInstanceOf(SceneDataLayers); - expect(scene.state.controls![2]).toBeInstanceOf(SceneDataLayerControls); + expect((scene.state.controls![0] as DashboardControls)!.state.variableControls[2]).toBeInstanceOf( + SceneDataLayerControls + ); const dataLayers = scene.state.$data as SceneDataLayers; expect(dataLayers.state.layers).toHaveLength(4); @@ -751,7 +757,9 @@ describe('transformSaveModelToScene', () => { const scene = transformSaveModelToScene({ dashboard: dashboard_to_load1 as any, meta: {} }); expect(scene.state.$data).toBeInstanceOf(SceneDataLayers); - expect(scene.state.controls![2]).toBeInstanceOf(SceneDataLayerControls); + expect((scene.state.controls![0] as DashboardControls)!.state.variableControls[2]).toBeInstanceOf( + SceneDataLayerControls + ); const dataLayers = scene.state.$data as SceneDataLayers; expect(dataLayers.state.layers).toHaveLength(5); @@ -765,7 +773,9 @@ describe('transformSaveModelToScene', () => { const scene = transformSaveModelToScene({ dashboard: dashboard_to_load1 as any, meta: {} }); expect(scene.state.$data).toBeInstanceOf(SceneDataLayers); - expect(scene.state.controls![2]).toBeInstanceOf(SceneDataLayerControls); + expect((scene.state.controls![0] as DashboardControls)!.state.variableControls[2]).toBeInstanceOf( + SceneDataLayerControls + ); const dataLayers = scene.state.$data as SceneDataLayers; expect(dataLayers.state.layers).toHaveLength(5); diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts index 4433de0e3f7..263902d17cb 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts @@ -17,7 +17,6 @@ import { SceneRefreshPicker, SceneGridItem, SceneObject, - SceneControlsSpacer, VizPanelMenu, behaviors, VizPanelState, @@ -32,6 +31,8 @@ import { DashboardDTO } from 'app/types'; import { AlertStatesDataLayer } from '../scene/AlertStatesDataLayer'; import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer'; +import { DashboardControls } from '../scene/DashboardControls'; +import { DashboardLinksControls } from '../scene/DashboardLinksControls'; import { registerDashboardMacro } from '../scene/DashboardMacro'; import { DashboardScene } from '../scene/DashboardScene'; import { LibraryVizPanel } from '../scene/LibraryVizPanel'; @@ -215,23 +216,6 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel) ); } - let controls: SceneObject[] = [ - new VariableValueSelectors({}), - ...filtersSets, - new SceneDataLayerControls(), - new SceneControlsSpacer(), - ]; - - if (!Boolean(oldModel.timepicker.hidden)) { - controls = controls.concat([ - new SceneTimePicker({}), - new SceneRefreshPicker({ - refresh: oldModel.refresh, - intervals: oldModel.timepicker.refresh_intervals, - }), - ]); - } - return new DashboardScene({ title: oldModel.title, uid: oldModel.uid, @@ -261,7 +245,24 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel) layers, }) : undefined, - controls: controls, + controls: [ + new DashboardControls({ + variableControls: [new VariableValueSelectors({}), ...filtersSets, new SceneDataLayerControls()], + timeControls: Boolean(oldModel.timepicker.hidden) + ? [] + : [ + new SceneTimePicker({}), + new SceneRefreshPicker({ + refresh: oldModel.refresh, + intervals: oldModel.timepicker.refresh_intervals, + }), + ], + linkControls: new DashboardLinksControls({ + links: oldModel.links, + dashboardUID: oldModel.uid, + }), + }), + ], }); } diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts index 26d9cd660cd..66d8fdb01f7 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts @@ -28,6 +28,7 @@ import { getPanelDataFrames } from 'app/features/dashboard/components/HelpWizard import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard'; import { GrafanaQueryType } from 'app/plugins/datasource/grafana/types'; +import { DashboardControls } from '../scene/DashboardControls'; import { DashboardScene } from '../scene/DashboardScene'; import { LibraryVizPanel } from '../scene/LibraryVizPanel'; import { PanelRepeaterGridItem } from '../scene/PanelRepeaterGridItem'; @@ -81,8 +82,9 @@ export function transformSceneToSaveModel(scene: DashboardScene, isSnapshot = fa variables = sceneVariablesSetToVariables(variablesSet); } - if (state.controls) { - for (const control of state.controls) { + if (state.controls && state.controls[0] instanceof DashboardControls) { + const variableControls = state.controls[0].state.variableControls; + for (const control of variableControls) { if (control instanceof AdHocFilterSet) { variables.push({ name: control.state.name!, diff --git a/public/app/features/dashboard/components/DashboardSettings/LinksSettings.test.tsx b/public/app/features/dashboard/components/DashboardSettings/LinksSettings.test.tsx index 4973e38beb2..7c049c86ebc 100644 --- a/public/app/features/dashboard/components/DashboardSettings/LinksSettings.test.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/LinksSettings.test.tsx @@ -125,14 +125,14 @@ describe('LinksSettings', () => { // Checking the original order assertRowHasText(0, links[0].title); assertRowHasText(1, links[1].title); - assertRowHasText(2, links[2].url); + assertRowHasText(2, links[2].url!); await userEvent.click(within(getTableBody()).getAllByRole('button', { name: 'Move link down' })[0]); await userEvent.click(within(getTableBody()).getAllByRole('button', { name: 'Move link down' })[1]); await userEvent.click(within(getTableBody()).getAllByRole('button', { name: 'Move link up' })[0]); // Checking if it has changed the sorting accordingly - assertRowHasText(0, links[2].url); + assertRowHasText(0, links[2].url!); assertRowHasText(1, links[1].title); assertRowHasText(2, links[0].title); }); diff --git a/public/app/features/dashboard/components/LinksSettings/LinkSettingsEdit.tsx b/public/app/features/dashboard/components/LinksSettings/LinkSettingsEdit.tsx index b8a2c8374a2..182c2be49ae 100644 --- a/public/app/features/dashboard/components/LinksSettings/LinkSettingsEdit.tsx +++ b/public/app/features/dashboard/components/LinksSettings/LinkSettingsEdit.tsx @@ -1,9 +1,10 @@ import React, { useState } from 'react'; import { SelectableValue } from '@grafana/data'; +import { DashboardLink } from '@grafana/schema'; import { CollapsableSection, TagsInput, Select, Field, Input, Checkbox, Button, IconName } from '@grafana/ui'; -import { DashboardLink, DashboardModel } from '../../state/DashboardModel'; +import { DashboardModel } from '../../state/DashboardModel'; export const newLink: DashboardLink = { icon: 'external link', diff --git a/public/app/features/dashboard/components/LinksSettings/LinkSettingsList.tsx b/public/app/features/dashboard/components/LinksSettings/LinkSettingsList.tsx index 39aa6a88f96..f2726abf06d 100644 --- a/public/app/features/dashboard/components/LinksSettings/LinkSettingsList.tsx +++ b/public/app/features/dashboard/components/LinksSettings/LinkSettingsList.tsx @@ -2,10 +2,11 @@ import { css } from '@emotion/css'; import React, { useState } from 'react'; import { arrayUtils } from '@grafana/data'; +import { DashboardLink } from '@grafana/schema'; import { DeleteButton, HorizontalGroup, Icon, IconButton, TagList, useStyles2 } from '@grafana/ui'; import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; -import { DashboardModel, DashboardLink } from '../../state/DashboardModel'; +import { DashboardModel } from '../../state/DashboardModel'; import { ListNewButton } from '../DashboardSettings/ListNewButton'; type LinkSettingsListProps = { diff --git a/public/app/features/dashboard/components/SubMenu/DashboardLinks.tsx b/public/app/features/dashboard/components/SubMenu/DashboardLinks.tsx index 115641d181b..dcd270521c0 100644 --- a/public/app/features/dashboard/components/SubMenu/DashboardLinks.tsx +++ b/public/app/features/dashboard/components/SubMenu/DashboardLinks.tsx @@ -4,14 +4,14 @@ import { useEffectOnce } from 'react-use'; import { sanitizeUrl } from '@grafana/data/src/text/sanitize'; import { selectors } from '@grafana/e2e-selectors'; import { TimeRangeUpdatedEvent } from '@grafana/runtime'; -import { Icon, Tooltip, useForceUpdate } from '@grafana/ui'; +import { DashboardLink } from '@grafana/schema'; +import { Tooltip, useForceUpdate } from '@grafana/ui'; import { getLinkSrv } from '../../../panel/panellinks/link_srv'; import { DashboardModel } from '../../state'; -import { DashboardLink } from '../../state/DashboardModel'; import { linkIconMap } from '../LinksSettings/LinkSettingsEdit'; -import { DashboardLinksDashboard } from './DashboardLinksDashboard'; +import { DashboardLinkButton, DashboardLinksDashboard } from './DashboardLinksDashboard'; export interface Props { dashboard: DashboardModel; @@ -43,16 +43,15 @@ export const DashboardLinks = ({ dashboard, links }: Props) => { const icon = linkIconMap[link.icon]; const linkElement = ( - - {icon && } - {linkInfo.title} - + {linkInfo.title} + ); return ( diff --git a/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.test.tsx b/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.test.tsx index c05ce36eac1..8759dcef8f9 100644 --- a/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.test.tsx +++ b/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.test.tsx @@ -1,8 +1,8 @@ +import { DashboardLink } from '@grafana/schema'; import { backendSrv } from 'app/core/services/__mocks__/backend_srv'; import { LinkSrv } from 'app/features/panel/panellinks/link_srv'; import { DashboardSearchItem, DashboardSearchItemType } from '../../../search/types'; -import { DashboardLink } from '../../state/DashboardModel'; import { resolveLinks, searchForTags } from './DashboardLinksDashboard'; diff --git a/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.tsx b/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.tsx index 964fc3869df..fe77b4870e4 100644 --- a/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.tsx +++ b/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.tsx @@ -1,16 +1,17 @@ import { css, cx } from '@emotion/css'; -import React, { useRef, useState, useLayoutEffect } from 'react'; +import React from 'react'; import { useAsync } from 'react-use'; import { GrafanaTheme2 } from '@grafana/data'; import { sanitize, sanitizeUrl } from '@grafana/data/src/text/sanitize'; import { selectors } from '@grafana/e2e-selectors'; -import { Icon, ToolbarButton, Tooltip, useStyles2 } from '@grafana/ui'; +import { DashboardLink } from '@grafana/schema'; +import { CustomScrollbar, Dropdown, Icon, Button, Menu, useStyles2 } from '@grafana/ui'; +import { ButtonLinkProps, LinkButton } from '@grafana/ui/src/components/Button'; import { getBackendSrv } from 'app/core/services/backend_srv'; import { DashboardSearchItem } from 'app/features/search/types'; import { getLinkSrv } from '../../../panel/panellinks/link_srv'; -import { DashboardLink } from '../../state/DashboardModel'; interface Props { link: DashboardLink; @@ -18,60 +19,62 @@ interface Props { dashboardUID: string; } -export const DashboardLinksDashboard = (props: Props) => { - const { link, linkInfo } = props; - const listRef = useRef(null); - const [dropdownCssClass, setDropdownCssClass] = useState('invisible'); - const [opened, setOpened] = useState(0); - const resolvedLinks = useResolvedLinks(props, opened); - const styles = useStyles2(getStyles); +interface DashboardLinksMenuProps { + link: DashboardLink; + dashboardUID: string; +} - useLayoutEffect(() => { - setDropdownCssClass(getDropdownLocationCssClass(listRef.current)); - }, [resolvedLinks]); +function DashboardLinksMenu({ dashboardUID, link }: DashboardLinksMenuProps) { + const styles = useStyles2(getStyles); + const resolvedLinks = useResolvedLinks({ dashboardUID, link }); + + if (!resolvedLinks || resolveLinks.length === 0) { + return null; + } + + return ( + +
+ + {resolvedLinks.map((resolvedLink, index) => { + return ( + + ); + })} + +
+
+ ); +} + +export const DashboardLinksDashboard = (props: Props) => { + const { link, linkInfo, dashboardUID } = props; + const resolvedLinks = useResolvedLinks(props); + const styles = useStyles2(getStyles); if (link.asDropdown) { return ( - - <> - setOpened(Date.now())} - className={cx('gf-form-label gf-form-label--dashlink', styles.button)} - data-placement="bottom" - data-toggle="dropdown" - aria-expanded={!!opened} - aria-controls="dropdown-list" - aria-haspopup="menu" - > - - {linkInfo.title} - - - - + }> + + + {linkInfo.title} + + ); } @@ -80,49 +83,27 @@ export const DashboardLinksDashboard = (props: Props) => { {resolvedLinks.length > 0 && resolvedLinks.map((resolvedLink, index) => { return ( - - - - {resolvedLink.title} - - + {resolvedLink.title} + ); })} ); }; -interface LinkElementProps { - link: DashboardLink; - key: string; - children: JSX.Element; -} - -const LinkElement = (props: LinkElementProps) => { - const { link, children, ...rest } = props; - - return ( -
- {link.tooltip && {children}} - {!link.tooltip && <>{children}} -
- ); -}; - -const useResolvedLinks = ({ link, dashboardUID }: Props, opened: number): ResolvedLinkDTO[] => { +const useResolvedLinks = ({ link, dashboardUID }: Pick): ResolvedLinkDTO[] => { const { tags } = link; - const result = useAsync(() => searchForTags(tags), [tags, opened]); + const result = useAsync(() => searchForTags(tags), [tags]); if (!result.value) { return []; } @@ -167,25 +148,6 @@ export function resolveLinks( }); } -function getDropdownLocationCssClass(element: HTMLElement | null) { - if (!element) { - return 'invisible'; - } - - const wrapperPos = element.parentElement!.getBoundingClientRect(); - const pos = element.getBoundingClientRect(); - - if (pos.width === 0) { - return 'invisible'; - } - - if (wrapperPos.left + pos.width + 10 > window.innerWidth) { - return 'pull-left'; - } else { - return 'pull-right'; - } -} - function getStyles(theme: GrafanaTheme2) { return { iconMargin: css({ @@ -195,14 +157,30 @@ function getStyles(theme: GrafanaTheme2) { maxWidth: 'max(30vw, 300px)', maxHeight: '70vh', overflowY: 'auto', - a: { - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - }, }), button: css({ color: theme.colors.text.primary, }), + dashButton: css({ + fontSize: theme.typography.bodySmall.fontSize, + paddingLeft: theme.spacing(1), + paddingRight: theme.spacing(1), + }), }; } + +export const DashboardLinkButton = React.forwardRef(({ className, ...otherProps }, ref) => { + const styles = useStyles2(getStyles); + const Component = otherProps.href ? LinkButton : Button; + return ( + + ); +}); + +DashboardLinkButton.displayName = 'DashboardLinkButton'; diff --git a/public/app/features/dashboard/components/SubMenu/SubMenu.tsx b/public/app/features/dashboard/components/SubMenu/SubMenu.tsx index f5e10bc9c78..16a5742e9d4 100644 --- a/public/app/features/dashboard/components/SubMenu/SubMenu.tsx +++ b/public/app/features/dashboard/components/SubMenu/SubMenu.tsx @@ -3,12 +3,12 @@ import React, { PureComponent } from 'react'; import { connect, MapStateToProps } from 'react-redux'; import { AnnotationQuery, DataQuery, TypedVariableModel, GrafanaTheme2 } from '@grafana/data'; +import { DashboardLink } from '@grafana/schema'; import { stylesFactory, Themeable2, withTheme2 } from '@grafana/ui'; import { StoreState } from '../../../../types'; import { getSubMenuVariables, getVariablesState } from '../../../variables/state/selectors'; import { DashboardModel } from '../../state'; -import { DashboardLink } from '../../state/DashboardModel'; import { Annotations } from './Annotations'; import { DashboardLinks } from './DashboardLinks'; diff --git a/public/app/features/dashboard/state/DashboardModel.ts b/public/app/features/dashboard/state/DashboardModel.ts index 7a878397df6..7ce8db93d0e 100644 --- a/public/app/features/dashboard/state/DashboardModel.ts +++ b/public/app/features/dashboard/state/DashboardModel.ts @@ -18,7 +18,7 @@ import { UrlQueryValue, } from '@grafana/data'; import { RefreshEvent, TimeRangeUpdatedEvent, config } from '@grafana/runtime'; -import { Dashboard } from '@grafana/schema'; +import { Dashboard, DashboardLink } from '@grafana/schema'; import { DEFAULT_ANNOTATION_COLOR } from '@grafana/ui'; import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT, REPEAT_DIR_VERTICAL } from 'app/core/constants'; import { contextSrv } from 'app/core/services/context_srv'; @@ -56,20 +56,6 @@ export interface CloneOptions { export type DashboardLinkType = 'link' | 'dashboards'; -export interface DashboardLink { - icon: string; - title: string; - tooltip: string; - type: DashboardLinkType; - url: string; - asDropdown: boolean; - tags: any[]; - searchHits?: any[]; - targetBlank: boolean; - keepTime: boolean; - includeVars: boolean; -} - export class DashboardModel implements TimeModel { /** @deprecated use UID */ id: any; diff --git a/public/app/features/panel/panellinks/link_srv.ts b/public/app/features/panel/panellinks/link_srv.ts index e8d47aaa884..83f8cf82acb 100644 --- a/public/app/features/panel/panellinks/link_srv.ts +++ b/public/app/features/panel/panellinks/link_srv.ts @@ -20,11 +20,8 @@ import { VariableSuggestionsScope, } from '@grafana/data'; import { getTemplateSrv } from '@grafana/runtime'; -import { VariableFormatID } from '@grafana/schema'; +import { DashboardLink, VariableFormatID } from '@grafana/schema'; import { getConfig } from 'app/core/config'; -import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv'; - -import { getVariablesUrlParams } from '../../variables/getAllVariableValuesForUrl'; const timeRangeVars = [ { @@ -254,24 +251,20 @@ export interface LinkService { } export class LinkSrv implements LinkService { - getLinkUrl(link: any) { - let url = locationUtil.assureBaseUrl(getTemplateSrv().replace(link.url || '')); + getLinkUrl(link: DashboardLink) { let params: { [key: string]: any } = {}; if (link.keepTime) { - const range = getTimeSrv().timeRangeForUrl(); - params['from'] = range.from; - params['to'] = range.to; + params[`\$${DataLinkBuiltInVars.keepTime}`] = true; } if (link.includeVars) { - params = { - ...params, - ...getVariablesUrlParams(), - }; + params[`\$${DataLinkBuiltInVars.includeVars}`] = true; } - url = urlUtil.appendQueryToUrl(url, urlUtil.toUrlParams(params)); + let url = locationUtil.assureBaseUrl(urlUtil.appendQueryToUrl(link.url || '', urlUtil.toUrlParams(params))); + url = getTemplateSrv().replace(url); + return getConfig().disableSanitizeHtml ? url : textUtil.sanitizeUrl(url); } diff --git a/public/app/features/panel/panellinks/specs/link_srv.test.ts b/public/app/features/panel/panellinks/specs/link_srv.test.ts index 580f79b659c..7075fe98f99 100644 --- a/public/app/features/panel/panellinks/specs/link_srv.test.ts +++ b/public/app/features/panel/panellinks/specs/link_srv.test.ts @@ -1,5 +1,6 @@ import { FieldType, GrafanaConfig, locationUtil, toDataFrame, VariableOrigin } from '@grafana/data'; import { setTemplateSrv } from '@grafana/runtime'; +import { DashboardLink } from '@grafana/schema'; import { ContextSrv } from 'app/core/services/context_srv'; import { getTimeSrv, setTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv'; import { TimeModel } from 'app/features/dashboard/state/TimeModel'; @@ -176,9 +177,27 @@ describe('linkSrv', () => { it('converts link urls', () => { const linkUrl = linkSrv.getLinkUrl({ url: '/graph', + asDropdown: false, + icon: 'external link', + targetBlank: false, + includeVars: false, + keepTime: false, + tags: [], + title: 'Visit home', + tooltip: 'Visit home', + type: 'link', }); const linkUrlWithVar = linkSrv.getLinkUrl({ url: '/graph?home=$home', + asDropdown: false, + icon: 'external link', + targetBlank: false, + includeVars: false, + keepTime: false, + tags: [], + title: 'Visit home', + tooltip: 'Visit home', + type: 'link', }); expect(linkUrl).toBe('/graph'); @@ -187,8 +206,16 @@ describe('linkSrv', () => { it('appends current dashboard time range if keepTime is true', () => { const anchorInfoKeepTime = linkSrv.getLinkUrl({ - keepTime: true, url: '/graph', + asDropdown: false, + icon: 'external link', + targetBlank: false, + includeVars: false, + keepTime: true, + tags: [], + title: 'Visit home', + tooltip: 'Visit home', + type: 'link', }); expect(anchorInfoKeepTime).toBe('/graph?from=now-1h&to=now'); @@ -196,16 +223,33 @@ describe('linkSrv', () => { it('adds all variables to the url if includeVars is true', () => { const anchorInfoIncludeVars = linkSrv.getLinkUrl({ - includeVars: true, url: '/graph', + asDropdown: false, + icon: 'external link', + targetBlank: false, + includeVars: true, + keepTime: false, + tags: [], + title: 'Visit home', + tooltip: 'Visit home', + type: 'link', }); expect(anchorInfoIncludeVars).toBe('/graph?var-home=127.0.0.1&var-server1=192.168.0.100'); }); it('respects config disableSanitizeHtml', () => { - const anchorInfo = { + const anchorInfo: DashboardLink = { url: 'javascript:alert(document.domain)', + asDropdown: false, + icon: 'external link', + targetBlank: false, + includeVars: false, + keepTime: false, + tags: [], + title: 'Visit home', + tooltip: 'Visit home', + type: 'link', }; expect(linkSrv.getLinkUrl(anchorInfo)).toBe('about:blank'); diff --git a/public/app/plugins/panel/timeseries/plugins/ContextMenuPlugin.tsx b/public/app/plugins/panel/timeseries/plugins/ContextMenuPlugin.tsx index d3e06eaf2d9..59c6ad9dceb 100644 --- a/public/app/plugins/panel/timeseries/plugins/ContextMenuPlugin.tsx +++ b/public/app/plugins/panel/timeseries/plugins/ContextMenuPlugin.tsx @@ -205,7 +205,7 @@ export const ContextMenuView = ({ {(group.items || []).map((item) => (