mirror of
https://github.com/grafana/grafana.git
synced 2025-07-28 18:32:19 +08:00
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:
@ -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"]
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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> = {
|
||||
|
@ -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>(
|
||||
(
|
||||
|
@ -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}
|
||||
|
@ -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',
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
@ -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)
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
@ -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);
|
||||
|
@ -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,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -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!,
|
||||
|
@ -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);
|
||||
});
|
||||
|
@ -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',
|
||||
|
@ -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 = {
|
||||
|
@ -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 (
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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';
|
||||
|
@ -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';
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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');
|
||||
|
@ -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}
|
||||
|
Reference in New Issue
Block a user