@@ -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 (
+
+ );
+}
+
+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}
-
-
- {resolvedLinks.length > 0 &&
- resolvedLinks.map((resolvedLink, index) => {
- return (
- -
-
- {resolvedLink.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) => (