DashboardScene: Support dashboard links (#77855)

* 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 <Clarity-89@users.noreply.github.com>

* 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 <Clarity-89@users.noreply.github.com>
Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
This commit is contained in:
Dominik Prokop
2023-11-15 16:49:51 +01:00
committed by GitHub
parent dfa506857a
commit 0122f7ccad
24 changed files with 324 additions and 201 deletions

View File

@ -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"]

View File

@ -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)<br/>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

View File

@ -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

View File

@ -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<DashboardLink> = {

View File

@ -83,7 +83,9 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
Button.displayName = 'Button';
type ButtonLinkProps = CommonProps & ButtonHTMLAttributes<HTMLButtonElement> & AnchorHTMLAttributes<HTMLAnchorElement>;
export type ButtonLinkProps = CommonProps &
ButtonHTMLAttributes<HTMLButtonElement> &
AnchorHTMLAttributes<HTMLAnchorElement>;
export const LinkButton = React.forwardRef<HTMLAnchorElement, ButtonLinkProps>(
(

View File

@ -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) => (
<MenuGroup key={`${group.label}${index}`} label={group.label}>
{(group.items || []).map((item) => (
return itemsGroup.map((group, groupIdx) => (
<MenuGroup key={`${group.label}${groupIdx}`} label={group.label}>
{(group.items || []).map((item, itemIdx) => (
<MenuItem
key={item.label}
key={`${group.label}-${groupIdx}-${itemIdx}}`}
url={item.url}
label={item.label}
target={item.target}

View File

@ -166,7 +166,7 @@ export const MenuItem = React.memo(
>
<Stack direction="row" justifyContent="flex-start" alignItems="center">
{icon && <Icon name={icon} className={styles.icon} aria-hidden />}
{label}
<span className={styles.ellipsis}>{label}</span>
<div className={cx(styles.rightWrapper, { [styles.withShortcut]: hasShortcut })}>
{hasShortcut && (
<div className={styles.shortcut}>
@ -188,7 +188,7 @@ export const MenuItem = React.memo(
</Stack>
{description && (
<div
className={cx(styles.description, {
className={cx(styles.description, styles.ellipsis, {
[styles.descriptionWithIcon]: icon !== undefined,
})}
>
@ -283,5 +283,10 @@ const getStyles = (theme: GrafanaTheme2) => {
descriptionWithIcon: css({
marginLeft: theme.spacing(3),
}),
ellipsis: css({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}),
};
};

View File

@ -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)

View File

@ -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<DashboardControlsState> {
static Component = DashboardControlsRenderer;
}
function DashboardControlsRenderer({ model }: SceneComponentProps<DashboardControls>) {
const { variableControls, linkControls, timeControls } = model.useState();
return (
<Stack
grow={1}
direction={{
md: 'row',
xs: 'column',
}}
>
<Stack grow={1} wrap={'wrap'}>
{variableControls.map((c) => (
<c.Component model={c} key={c.state.key} />
))}
<Box grow={1} />
<linkControls.Component model={linkControls} />
</Stack>
<Stack justifyContent={'flex-end'}>
{timeControls.map((c) => (
<c.Component model={c} key={c.state.key} />
))}
</Stack>
</Stack>
);
}

View File

@ -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<DashboardLinksControlsState> {
static Component = DashboardLinksControlsRenderer;
}
function DashboardLinksControlsRenderer({ model }: SceneComponentProps<DashboardLinksControls>) {
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 <DashboardLinksDashboard key={key} link={link} linkInfo={linkInfo} dashboardUID={dashboardUID} />;
}
const icon = linkIconMap[link.icon];
const linkElement = (
<DashboardLinkButton
icon={icon}
href={sanitizeUrl(linkInfo.href)}
target={link.targetBlank ? '_blank' : undefined}
rel="noreferrer"
data-testid={selectors.components.DashboardLinks.link}
>
{linkInfo.title}
</DashboardLinkButton>
);
return (
<div key={key} data-testid={selectors.components.DashboardLinks.container}>
{link.tooltip ? <Tooltip content={linkInfo.tooltip}>{linkElement}</Tooltip> : linkElement}
</div>
);
})}
</>
);
}

View File

@ -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);

View File

@ -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,
}),
}),
],
});
}

View File

@ -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!,

View File

@ -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);
});

View File

@ -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',

View File

@ -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 = {

View File

@ -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 = (
<a
className="gf-form-label gf-form-label--dashlink"
<DashboardLinkButton
href={sanitizeUrl(linkInfo.href)}
target={link.targetBlank ? '_blank' : undefined}
rel="noreferrer"
data-testid={selectors.components.DashboardLinks.link}
icon={icon}
>
{icon && <Icon aria-hidden name={icon} style={{ marginRight: '4px' }} />}
<span>{linkInfo.title}</span>
</a>
{linkInfo.title}
</DashboardLinkButton>
);
return (

View File

@ -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';

View File

@ -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<HTMLUListElement>(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 (
<Menu>
<div className={styles.dropdown}>
<CustomScrollbar>
{resolvedLinks.map((resolvedLink, index) => {
return (
<Menu.Item
url={resolvedLink.url}
target={link.targetBlank ? '_blank' : undefined}
key={`dashlinks-dropdown-item-${resolvedLink.uid}-${index}`}
label={resolvedLink.title}
testId={selectors.components.DashboardLinks.link}
aria-label={`${resolvedLink.title} dashboard`}
/>
);
})}
</CustomScrollbar>
</div>
</Menu>
);
}
export const DashboardLinksDashboard = (props: Props) => {
const { link, linkInfo, dashboardUID } = props;
const resolvedLinks = useResolvedLinks(props);
const styles = useStyles2(getStyles);
if (link.asDropdown) {
return (
<LinkElement link={link} key="dashlinks-dropdown" data-testid={selectors.components.DashboardLinks.dropDown}>
<>
<ToolbarButton
onClick={() => 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"
>
<Icon aria-hidden name="bars" className={styles.iconMargin} />
<span>{linkInfo.title}</span>
</ToolbarButton>
<ul
id="dropdown-list"
className={`dropdown-menu ${styles.dropdown} ${dropdownCssClass}`}
role="menu"
ref={listRef}
>
{resolvedLinks.length > 0 &&
resolvedLinks.map((resolvedLink, index) => {
return (
<li role="none" key={`dashlinks-dropdown-item-${resolvedLink.uid}-${index}`}>
<a
role="menuitem"
href={resolvedLink.url}
target={link.targetBlank ? '_blank' : undefined}
rel="noreferrer"
data-testid={selectors.components.DashboardLinks.link}
aria-label={`${resolvedLink.title} dashboard`}
>
{resolvedLink.title}
</a>
</li>
);
})}
</ul>
</>
</LinkElement>
<Dropdown overlay={<DashboardLinksMenu link={link} dashboardUID={dashboardUID} />}>
<DashboardLinkButton
data-placement="bottom"
data-toggle="dropdown"
aria-controls="dropdown-list"
aria-haspopup="menu"
fill="outline"
variant="secondary"
data-testid={selectors.components.DashboardLinks.dropDown}
>
<Icon aria-hidden name="bars" className={styles.iconMargin} />
<span>{linkInfo.title}</span>
</DashboardLinkButton>
</Dropdown>
);
}
@ -80,49 +83,27 @@ export const DashboardLinksDashboard = (props: Props) => {
{resolvedLinks.length > 0 &&
resolvedLinks.map((resolvedLink, index) => {
return (
<LinkElement
link={link}
<DashboardLinkButton
key={`dashlinks-list-item-${resolvedLink.uid}-${index}`}
data-testid={selectors.components.DashboardLinks.container}
icon="apps"
variant="secondary"
fill="outline"
href={resolvedLink.url}
target={link.targetBlank ? '_blank' : undefined}
rel="noreferrer"
data-testid={selectors.components.DashboardLinks.link}
>
<a
className="gf-form-label gf-form-label--dashlink"
href={resolvedLink.url}
target={link.targetBlank ? '_blank' : undefined}
rel="noreferrer"
data-testid={selectors.components.DashboardLinks.link}
aria-label={`${resolvedLink.title} dashboard`}
>
<Icon aria-hidden name="apps" style={{ marginRight: '4px' }} />
<span>{resolvedLink.title}</span>
</a>
</LinkElement>
{resolvedLink.title}
</DashboardLinkButton>
);
})}
</>
);
};
interface LinkElementProps {
link: DashboardLink;
key: string;
children: JSX.Element;
}
const LinkElement = (props: LinkElementProps) => {
const { link, children, ...rest } = props;
return (
<div {...rest} className="gf-form">
{link.tooltip && <Tooltip content={link.tooltip}>{children}</Tooltip>}
{!link.tooltip && <>{children}</>}
</div>
);
};
const useResolvedLinks = ({ link, dashboardUID }: Props, opened: number): ResolvedLinkDTO[] => {
const useResolvedLinks = ({ link, dashboardUID }: Pick<Props, 'link' | 'dashboardUID'>): 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<unknown, ButtonLinkProps>(({ className, ...otherProps }, ref) => {
const styles = useStyles2(getStyles);
const Component = otherProps.href ? LinkButton : Button;
return (
<Component
{...otherProps}
variant="secondary"
fill="outline"
className={cx(className, styles.dashButton)}
ref={ref as any}
/>
);
});
DashboardLinkButton.displayName = 'DashboardLinkButton';

View File

@ -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';

View File

@ -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;

View File

@ -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);
}

View File

@ -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');

View File

@ -205,7 +205,7 @@ export const ContextMenuView = ({
<MenuGroup key={`${group.label}${index}`} label={group.label}>
{(group.items || []).map((item) => (
<MenuItem
key={item.label}
key={item.url}
url={item.url}
label={item.label}
target={item.target}