mirror of
https://github.com/grafana/grafana.git
synced 2025-07-29 01:12:24 +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.", "0"],
|
||||||
[0, 0, 0, "Styles should be written using objects.", "1"]
|
[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": [
|
"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.", "0"],
|
||||||
[0, 0, 0, "Styles should be written using objects.", "1"]
|
[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.", "21"],
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "22"],
|
[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.", "23"],
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "24"],
|
[0, 0, 0, "Do not use any type assertions.", "24"],
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "25"],
|
[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"]
|
|
||||||
],
|
],
|
||||||
"public/app/features/dashboard/state/PanelModel.test.ts:5381": [
|
"public/app/features/dashboard/state/PanelModel.test.ts:5381": [
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
[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.", "0"],
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
|
[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.", "2"],
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "3"],
|
[0, 0, 0, "Unexpected any. Specify a different type.", "3"]
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "4"]
|
|
||||||
],
|
],
|
||||||
"public/app/features/panel/state/actions.ts:5381": [
|
"public/app/features/panel/state/actions.ts:5381": [
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
[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 |
|
| `title` | string | **Yes** | | Title to display with the link |
|
||||||
| `tooltip` | string | **Yes** | | Tooltip to display when the user hovers their mouse over it |
|
| `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`. |
|
| `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
|
### Snapshot
|
||||||
|
|
||||||
|
@ -265,7 +265,7 @@ lineage: schemas: [{
|
|||||||
// Tooltip to display when the user hovers their mouse over it
|
// Tooltip to display when the user hovers their mouse over it
|
||||||
tooltip: string
|
tooltip: string
|
||||||
// Link URL. Only required/valid if the type is link
|
// 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
|
// List of tags to limit the linked dashboards. If empty, all dashboards will be displayed. Only valid if the type is dashboards
|
||||||
tags: [...string]
|
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
|
// 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
|
* Link URL. Only required/valid if the type is link
|
||||||
*/
|
*/
|
||||||
url: string;
|
url?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const defaultDashboardLink: Partial<DashboardLink> = {
|
export const defaultDashboardLink: Partial<DashboardLink> = {
|
||||||
|
@ -83,7 +83,9 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|||||||
|
|
||||||
Button.displayName = 'Button';
|
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>(
|
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 itemsGroup: MenuItemsGroup[] = [{ items: linkModelToContextMenuItems(links), label: 'Data links' }];
|
||||||
const linksCounter = itemsGroup[0].items.length;
|
const linksCounter = itemsGroup[0].items.length;
|
||||||
const renderMenuGroupItems = () => {
|
const renderMenuGroupItems = () => {
|
||||||
return itemsGroup.map((group, index) => (
|
return itemsGroup.map((group, groupIdx) => (
|
||||||
<MenuGroup key={`${group.label}${index}`} label={group.label}>
|
<MenuGroup key={`${group.label}${groupIdx}`} label={group.label}>
|
||||||
{(group.items || []).map((item) => (
|
{(group.items || []).map((item, itemIdx) => (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
key={item.label}
|
key={`${group.label}-${groupIdx}-${itemIdx}}`}
|
||||||
url={item.url}
|
url={item.url}
|
||||||
label={item.label}
|
label={item.label}
|
||||||
target={item.target}
|
target={item.target}
|
||||||
|
@ -166,7 +166,7 @@ export const MenuItem = React.memo(
|
|||||||
>
|
>
|
||||||
<Stack direction="row" justifyContent="flex-start" alignItems="center">
|
<Stack direction="row" justifyContent="flex-start" alignItems="center">
|
||||||
{icon && <Icon name={icon} className={styles.icon} aria-hidden />}
|
{icon && <Icon name={icon} className={styles.icon} aria-hidden />}
|
||||||
{label}
|
<span className={styles.ellipsis}>{label}</span>
|
||||||
<div className={cx(styles.rightWrapper, { [styles.withShortcut]: hasShortcut })}>
|
<div className={cx(styles.rightWrapper, { [styles.withShortcut]: hasShortcut })}>
|
||||||
{hasShortcut && (
|
{hasShortcut && (
|
||||||
<div className={styles.shortcut}>
|
<div className={styles.shortcut}>
|
||||||
@ -188,7 +188,7 @@ export const MenuItem = React.memo(
|
|||||||
</Stack>
|
</Stack>
|
||||||
{description && (
|
{description && (
|
||||||
<div
|
<div
|
||||||
className={cx(styles.description, {
|
className={cx(styles.description, styles.ellipsis, {
|
||||||
[styles.descriptionWithIcon]: icon !== undefined,
|
[styles.descriptionWithIcon]: icon !== undefined,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
@ -283,5 +283,10 @@ const getStyles = (theme: GrafanaTheme2) => {
|
|||||||
descriptionWithIcon: css({
|
descriptionWithIcon: css({
|
||||||
marginLeft: theme.spacing(3),
|
marginLeft: theme.spacing(3),
|
||||||
}),
|
}),
|
||||||
|
ellipsis: css({
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -270,7 +270,7 @@ type Link struct {
|
|||||||
Type LinkType `json:"type"`
|
Type LinkType `json:"type"`
|
||||||
|
|
||||||
// Link URL. Only required/valid if the type is link
|
// 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)
|
// 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 { DASHBOARD_DATASOURCE_PLUGIN_ID } from 'app/plugins/datasource/dashboard/types';
|
||||||
import { DashboardDataDTO } from 'app/types';
|
import { DashboardDataDTO } from 'app/types';
|
||||||
|
|
||||||
|
import { DashboardControls } from '../scene/DashboardControls';
|
||||||
import { PanelRepeaterGridItem } from '../scene/PanelRepeaterGridItem';
|
import { PanelRepeaterGridItem } from '../scene/PanelRepeaterGridItem';
|
||||||
import { PanelTimeRange } from '../scene/PanelTimeRange';
|
import { PanelTimeRange } from '../scene/PanelTimeRange';
|
||||||
import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior';
|
import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior';
|
||||||
@ -100,8 +101,11 @@ describe('transformSaveModelToScene', () => {
|
|||||||
expect(scene.state?.$timeRange?.state.weekStart).toEqual('saturday');
|
expect(scene.state?.$timeRange?.state.weekStart).toEqual('saturday');
|
||||||
expect(scene.state?.$variables?.state.variables).toHaveLength(1);
|
expect(scene.state?.$variables?.state.variables).toHaveLength(1);
|
||||||
expect(scene.state.controls).toBeDefined();
|
expect(scene.state.controls).toBeDefined();
|
||||||
expect(scene.state.controls![1]).toBeInstanceOf(AdHocFilterSet);
|
expect(scene.state.controls![0]).toBeInstanceOf(DashboardControls);
|
||||||
expect((scene.state.controls![1] as AdHocFilterSet).state.name).toBe('CoolFilters');
|
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', () => {
|
it('should apply cursor sync behavior', () => {
|
||||||
@ -723,7 +727,9 @@ describe('transformSaveModelToScene', () => {
|
|||||||
const scene = transformSaveModelToScene({ dashboard: dashboard_to_load1 as any, meta: {} });
|
const scene = transformSaveModelToScene({ dashboard: dashboard_to_load1 as any, meta: {} });
|
||||||
|
|
||||||
expect(scene.state.$data).toBeInstanceOf(SceneDataLayers);
|
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;
|
const dataLayers = scene.state.$data as SceneDataLayers;
|
||||||
expect(dataLayers.state.layers).toHaveLength(4);
|
expect(dataLayers.state.layers).toHaveLength(4);
|
||||||
@ -751,7 +757,9 @@ describe('transformSaveModelToScene', () => {
|
|||||||
const scene = transformSaveModelToScene({ dashboard: dashboard_to_load1 as any, meta: {} });
|
const scene = transformSaveModelToScene({ dashboard: dashboard_to_load1 as any, meta: {} });
|
||||||
|
|
||||||
expect(scene.state.$data).toBeInstanceOf(SceneDataLayers);
|
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;
|
const dataLayers = scene.state.$data as SceneDataLayers;
|
||||||
expect(dataLayers.state.layers).toHaveLength(5);
|
expect(dataLayers.state.layers).toHaveLength(5);
|
||||||
@ -765,7 +773,9 @@ describe('transformSaveModelToScene', () => {
|
|||||||
const scene = transformSaveModelToScene({ dashboard: dashboard_to_load1 as any, meta: {} });
|
const scene = transformSaveModelToScene({ dashboard: dashboard_to_load1 as any, meta: {} });
|
||||||
|
|
||||||
expect(scene.state.$data).toBeInstanceOf(SceneDataLayers);
|
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;
|
const dataLayers = scene.state.$data as SceneDataLayers;
|
||||||
expect(dataLayers.state.layers).toHaveLength(5);
|
expect(dataLayers.state.layers).toHaveLength(5);
|
||||||
|
@ -17,7 +17,6 @@ import {
|
|||||||
SceneRefreshPicker,
|
SceneRefreshPicker,
|
||||||
SceneGridItem,
|
SceneGridItem,
|
||||||
SceneObject,
|
SceneObject,
|
||||||
SceneControlsSpacer,
|
|
||||||
VizPanelMenu,
|
VizPanelMenu,
|
||||||
behaviors,
|
behaviors,
|
||||||
VizPanelState,
|
VizPanelState,
|
||||||
@ -32,6 +31,8 @@ import { DashboardDTO } from 'app/types';
|
|||||||
|
|
||||||
import { AlertStatesDataLayer } from '../scene/AlertStatesDataLayer';
|
import { AlertStatesDataLayer } from '../scene/AlertStatesDataLayer';
|
||||||
import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer';
|
import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer';
|
||||||
|
import { DashboardControls } from '../scene/DashboardControls';
|
||||||
|
import { DashboardLinksControls } from '../scene/DashboardLinksControls';
|
||||||
import { registerDashboardMacro } from '../scene/DashboardMacro';
|
import { registerDashboardMacro } from '../scene/DashboardMacro';
|
||||||
import { DashboardScene } from '../scene/DashboardScene';
|
import { DashboardScene } from '../scene/DashboardScene';
|
||||||
import { LibraryVizPanel } from '../scene/LibraryVizPanel';
|
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({
|
return new DashboardScene({
|
||||||
title: oldModel.title,
|
title: oldModel.title,
|
||||||
uid: oldModel.uid,
|
uid: oldModel.uid,
|
||||||
@ -261,7 +245,24 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel)
|
|||||||
layers,
|
layers,
|
||||||
})
|
})
|
||||||
: undefined,
|
: 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 { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard';
|
||||||
import { GrafanaQueryType } from 'app/plugins/datasource/grafana/types';
|
import { GrafanaQueryType } from 'app/plugins/datasource/grafana/types';
|
||||||
|
|
||||||
|
import { DashboardControls } from '../scene/DashboardControls';
|
||||||
import { DashboardScene } from '../scene/DashboardScene';
|
import { DashboardScene } from '../scene/DashboardScene';
|
||||||
import { LibraryVizPanel } from '../scene/LibraryVizPanel';
|
import { LibraryVizPanel } from '../scene/LibraryVizPanel';
|
||||||
import { PanelRepeaterGridItem } from '../scene/PanelRepeaterGridItem';
|
import { PanelRepeaterGridItem } from '../scene/PanelRepeaterGridItem';
|
||||||
@ -81,8 +82,9 @@ export function transformSceneToSaveModel(scene: DashboardScene, isSnapshot = fa
|
|||||||
variables = sceneVariablesSetToVariables(variablesSet);
|
variables = sceneVariablesSetToVariables(variablesSet);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.controls) {
|
if (state.controls && state.controls[0] instanceof DashboardControls) {
|
||||||
for (const control of state.controls) {
|
const variableControls = state.controls[0].state.variableControls;
|
||||||
|
for (const control of variableControls) {
|
||||||
if (control instanceof AdHocFilterSet) {
|
if (control instanceof AdHocFilterSet) {
|
||||||
variables.push({
|
variables.push({
|
||||||
name: control.state.name!,
|
name: control.state.name!,
|
||||||
|
@ -125,14 +125,14 @@ describe('LinksSettings', () => {
|
|||||||
// Checking the original order
|
// Checking the original order
|
||||||
assertRowHasText(0, links[0].title);
|
assertRowHasText(0, links[0].title);
|
||||||
assertRowHasText(1, links[1].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' })[0]);
|
||||||
await userEvent.click(within(getTableBody()).getAllByRole('button', { name: 'Move link down' })[1]);
|
await userEvent.click(within(getTableBody()).getAllByRole('button', { name: 'Move link down' })[1]);
|
||||||
await userEvent.click(within(getTableBody()).getAllByRole('button', { name: 'Move link up' })[0]);
|
await userEvent.click(within(getTableBody()).getAllByRole('button', { name: 'Move link up' })[0]);
|
||||||
|
|
||||||
// Checking if it has changed the sorting accordingly
|
// Checking if it has changed the sorting accordingly
|
||||||
assertRowHasText(0, links[2].url);
|
assertRowHasText(0, links[2].url!);
|
||||||
assertRowHasText(1, links[1].title);
|
assertRowHasText(1, links[1].title);
|
||||||
assertRowHasText(2, links[0].title);
|
assertRowHasText(2, links[0].title);
|
||||||
});
|
});
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
import { SelectableValue } from '@grafana/data';
|
import { SelectableValue } from '@grafana/data';
|
||||||
|
import { DashboardLink } from '@grafana/schema';
|
||||||
import { CollapsableSection, TagsInput, Select, Field, Input, Checkbox, Button, IconName } from '@grafana/ui';
|
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 = {
|
export const newLink: DashboardLink = {
|
||||||
icon: 'external link',
|
icon: 'external link',
|
||||||
|
@ -2,10 +2,11 @@ import { css } from '@emotion/css';
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
import { arrayUtils } from '@grafana/data';
|
import { arrayUtils } from '@grafana/data';
|
||||||
|
import { DashboardLink } from '@grafana/schema';
|
||||||
import { DeleteButton, HorizontalGroup, Icon, IconButton, TagList, useStyles2 } from '@grafana/ui';
|
import { DeleteButton, HorizontalGroup, Icon, IconButton, TagList, useStyles2 } from '@grafana/ui';
|
||||||
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
|
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
|
||||||
|
|
||||||
import { DashboardModel, DashboardLink } from '../../state/DashboardModel';
|
import { DashboardModel } from '../../state/DashboardModel';
|
||||||
import { ListNewButton } from '../DashboardSettings/ListNewButton';
|
import { ListNewButton } from '../DashboardSettings/ListNewButton';
|
||||||
|
|
||||||
type LinkSettingsListProps = {
|
type LinkSettingsListProps = {
|
||||||
|
@ -4,14 +4,14 @@ import { useEffectOnce } from 'react-use';
|
|||||||
import { sanitizeUrl } from '@grafana/data/src/text/sanitize';
|
import { sanitizeUrl } from '@grafana/data/src/text/sanitize';
|
||||||
import { selectors } from '@grafana/e2e-selectors';
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
import { TimeRangeUpdatedEvent } from '@grafana/runtime';
|
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 { getLinkSrv } from '../../../panel/panellinks/link_srv';
|
||||||
import { DashboardModel } from '../../state';
|
import { DashboardModel } from '../../state';
|
||||||
import { DashboardLink } from '../../state/DashboardModel';
|
|
||||||
import { linkIconMap } from '../LinksSettings/LinkSettingsEdit';
|
import { linkIconMap } from '../LinksSettings/LinkSettingsEdit';
|
||||||
|
|
||||||
import { DashboardLinksDashboard } from './DashboardLinksDashboard';
|
import { DashboardLinkButton, DashboardLinksDashboard } from './DashboardLinksDashboard';
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
dashboard: DashboardModel;
|
dashboard: DashboardModel;
|
||||||
@ -43,16 +43,15 @@ export const DashboardLinks = ({ dashboard, links }: Props) => {
|
|||||||
const icon = linkIconMap[link.icon];
|
const icon = linkIconMap[link.icon];
|
||||||
|
|
||||||
const linkElement = (
|
const linkElement = (
|
||||||
<a
|
<DashboardLinkButton
|
||||||
className="gf-form-label gf-form-label--dashlink"
|
|
||||||
href={sanitizeUrl(linkInfo.href)}
|
href={sanitizeUrl(linkInfo.href)}
|
||||||
target={link.targetBlank ? '_blank' : undefined}
|
target={link.targetBlank ? '_blank' : undefined}
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
data-testid={selectors.components.DashboardLinks.link}
|
data-testid={selectors.components.DashboardLinks.link}
|
||||||
|
icon={icon}
|
||||||
>
|
>
|
||||||
{icon && <Icon aria-hidden name={icon} style={{ marginRight: '4px' }} />}
|
{linkInfo.title}
|
||||||
<span>{linkInfo.title}</span>
|
</DashboardLinkButton>
|
||||||
</a>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
|
import { DashboardLink } from '@grafana/schema';
|
||||||
import { backendSrv } from 'app/core/services/__mocks__/backend_srv';
|
import { backendSrv } from 'app/core/services/__mocks__/backend_srv';
|
||||||
import { LinkSrv } from 'app/features/panel/panellinks/link_srv';
|
import { LinkSrv } from 'app/features/panel/panellinks/link_srv';
|
||||||
|
|
||||||
import { DashboardSearchItem, DashboardSearchItemType } from '../../../search/types';
|
import { DashboardSearchItem, DashboardSearchItemType } from '../../../search/types';
|
||||||
import { DashboardLink } from '../../state/DashboardModel';
|
|
||||||
|
|
||||||
import { resolveLinks, searchForTags } from './DashboardLinksDashboard';
|
import { resolveLinks, searchForTags } from './DashboardLinksDashboard';
|
||||||
|
|
||||||
|
@ -1,16 +1,17 @@
|
|||||||
import { css, cx } from '@emotion/css';
|
import { css, cx } from '@emotion/css';
|
||||||
import React, { useRef, useState, useLayoutEffect } from 'react';
|
import React from 'react';
|
||||||
import { useAsync } from 'react-use';
|
import { useAsync } from 'react-use';
|
||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
import { sanitize, sanitizeUrl } from '@grafana/data/src/text/sanitize';
|
import { sanitize, sanitizeUrl } from '@grafana/data/src/text/sanitize';
|
||||||
import { selectors } from '@grafana/e2e-selectors';
|
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 { getBackendSrv } from 'app/core/services/backend_srv';
|
||||||
import { DashboardSearchItem } from 'app/features/search/types';
|
import { DashboardSearchItem } from 'app/features/search/types';
|
||||||
|
|
||||||
import { getLinkSrv } from '../../../panel/panellinks/link_srv';
|
import { getLinkSrv } from '../../../panel/panellinks/link_srv';
|
||||||
import { DashboardLink } from '../../state/DashboardModel';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
link: DashboardLink;
|
link: DashboardLink;
|
||||||
@ -18,60 +19,62 @@ interface Props {
|
|||||||
dashboardUID: string;
|
dashboardUID: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DashboardLinksDashboard = (props: Props) => {
|
interface DashboardLinksMenuProps {
|
||||||
const { link, linkInfo } = props;
|
link: DashboardLink;
|
||||||
const listRef = useRef<HTMLUListElement>(null);
|
dashboardUID: string;
|
||||||
const [dropdownCssClass, setDropdownCssClass] = useState('invisible');
|
}
|
||||||
const [opened, setOpened] = useState(0);
|
|
||||||
const resolvedLinks = useResolvedLinks(props, opened);
|
|
||||||
const styles = useStyles2(getStyles);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
function DashboardLinksMenu({ dashboardUID, link }: DashboardLinksMenuProps) {
|
||||||
setDropdownCssClass(getDropdownLocationCssClass(listRef.current));
|
const styles = useStyles2(getStyles);
|
||||||
}, [resolvedLinks]);
|
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) {
|
if (link.asDropdown) {
|
||||||
return (
|
return (
|
||||||
<LinkElement link={link} key="dashlinks-dropdown" data-testid={selectors.components.DashboardLinks.dropDown}>
|
<Dropdown overlay={<DashboardLinksMenu link={link} dashboardUID={dashboardUID} />}>
|
||||||
<>
|
<DashboardLinkButton
|
||||||
<ToolbarButton
|
data-placement="bottom"
|
||||||
onClick={() => setOpened(Date.now())}
|
data-toggle="dropdown"
|
||||||
className={cx('gf-form-label gf-form-label--dashlink', styles.button)}
|
aria-controls="dropdown-list"
|
||||||
data-placement="bottom"
|
aria-haspopup="menu"
|
||||||
data-toggle="dropdown"
|
fill="outline"
|
||||||
aria-expanded={!!opened}
|
variant="secondary"
|
||||||
aria-controls="dropdown-list"
|
data-testid={selectors.components.DashboardLinks.dropDown}
|
||||||
aria-haspopup="menu"
|
>
|
||||||
>
|
<Icon aria-hidden name="bars" className={styles.iconMargin} />
|
||||||
<Icon aria-hidden name="bars" className={styles.iconMargin} />
|
<span>{linkInfo.title}</span>
|
||||||
<span>{linkInfo.title}</span>
|
</DashboardLinkButton>
|
||||||
</ToolbarButton>
|
</Dropdown>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -80,49 +83,27 @@ export const DashboardLinksDashboard = (props: Props) => {
|
|||||||
{resolvedLinks.length > 0 &&
|
{resolvedLinks.length > 0 &&
|
||||||
resolvedLinks.map((resolvedLink, index) => {
|
resolvedLinks.map((resolvedLink, index) => {
|
||||||
return (
|
return (
|
||||||
<LinkElement
|
<DashboardLinkButton
|
||||||
link={link}
|
|
||||||
key={`dashlinks-list-item-${resolvedLink.uid}-${index}`}
|
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
|
{resolvedLink.title}
|
||||||
className="gf-form-label gf-form-label--dashlink"
|
</DashboardLinkButton>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface LinkElementProps {
|
const useResolvedLinks = ({ link, dashboardUID }: Pick<Props, 'link' | 'dashboardUID'>): ResolvedLinkDTO[] => {
|
||||||
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 { tags } = link;
|
const { tags } = link;
|
||||||
const result = useAsync(() => searchForTags(tags), [tags, opened]);
|
const result = useAsync(() => searchForTags(tags), [tags]);
|
||||||
if (!result.value) {
|
if (!result.value) {
|
||||||
return [];
|
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) {
|
function getStyles(theme: GrafanaTheme2) {
|
||||||
return {
|
return {
|
||||||
iconMargin: css({
|
iconMargin: css({
|
||||||
@ -195,14 +157,30 @@ function getStyles(theme: GrafanaTheme2) {
|
|||||||
maxWidth: 'max(30vw, 300px)',
|
maxWidth: 'max(30vw, 300px)',
|
||||||
maxHeight: '70vh',
|
maxHeight: '70vh',
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
a: {
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
button: css({
|
button: css({
|
||||||
color: theme.colors.text.primary,
|
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 { connect, MapStateToProps } from 'react-redux';
|
||||||
|
|
||||||
import { AnnotationQuery, DataQuery, TypedVariableModel, GrafanaTheme2 } from '@grafana/data';
|
import { AnnotationQuery, DataQuery, TypedVariableModel, GrafanaTheme2 } from '@grafana/data';
|
||||||
|
import { DashboardLink } from '@grafana/schema';
|
||||||
import { stylesFactory, Themeable2, withTheme2 } from '@grafana/ui';
|
import { stylesFactory, Themeable2, withTheme2 } from '@grafana/ui';
|
||||||
|
|
||||||
import { StoreState } from '../../../../types';
|
import { StoreState } from '../../../../types';
|
||||||
import { getSubMenuVariables, getVariablesState } from '../../../variables/state/selectors';
|
import { getSubMenuVariables, getVariablesState } from '../../../variables/state/selectors';
|
||||||
import { DashboardModel } from '../../state';
|
import { DashboardModel } from '../../state';
|
||||||
import { DashboardLink } from '../../state/DashboardModel';
|
|
||||||
|
|
||||||
import { Annotations } from './Annotations';
|
import { Annotations } from './Annotations';
|
||||||
import { DashboardLinks } from './DashboardLinks';
|
import { DashboardLinks } from './DashboardLinks';
|
||||||
|
@ -18,7 +18,7 @@ import {
|
|||||||
UrlQueryValue,
|
UrlQueryValue,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { RefreshEvent, TimeRangeUpdatedEvent, config } from '@grafana/runtime';
|
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 { DEFAULT_ANNOTATION_COLOR } from '@grafana/ui';
|
||||||
import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT, REPEAT_DIR_VERTICAL } from 'app/core/constants';
|
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';
|
import { contextSrv } from 'app/core/services/context_srv';
|
||||||
@ -56,20 +56,6 @@ export interface CloneOptions {
|
|||||||
|
|
||||||
export type DashboardLinkType = 'link' | 'dashboards';
|
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 {
|
export class DashboardModel implements TimeModel {
|
||||||
/** @deprecated use UID */
|
/** @deprecated use UID */
|
||||||
id: any;
|
id: any;
|
||||||
|
@ -20,11 +20,8 @@ import {
|
|||||||
VariableSuggestionsScope,
|
VariableSuggestionsScope,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { getTemplateSrv } from '@grafana/runtime';
|
import { getTemplateSrv } from '@grafana/runtime';
|
||||||
import { VariableFormatID } from '@grafana/schema';
|
import { DashboardLink, VariableFormatID } from '@grafana/schema';
|
||||||
import { getConfig } from 'app/core/config';
|
import { getConfig } from 'app/core/config';
|
||||||
import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv';
|
|
||||||
|
|
||||||
import { getVariablesUrlParams } from '../../variables/getAllVariableValuesForUrl';
|
|
||||||
|
|
||||||
const timeRangeVars = [
|
const timeRangeVars = [
|
||||||
{
|
{
|
||||||
@ -254,24 +251,20 @@ export interface LinkService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class LinkSrv implements LinkService {
|
export class LinkSrv implements LinkService {
|
||||||
getLinkUrl(link: any) {
|
getLinkUrl(link: DashboardLink) {
|
||||||
let url = locationUtil.assureBaseUrl(getTemplateSrv().replace(link.url || ''));
|
|
||||||
let params: { [key: string]: any } = {};
|
let params: { [key: string]: any } = {};
|
||||||
|
|
||||||
if (link.keepTime) {
|
if (link.keepTime) {
|
||||||
const range = getTimeSrv().timeRangeForUrl();
|
params[`\$${DataLinkBuiltInVars.keepTime}`] = true;
|
||||||
params['from'] = range.from;
|
|
||||||
params['to'] = range.to;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (link.includeVars) {
|
if (link.includeVars) {
|
||||||
params = {
|
params[`\$${DataLinkBuiltInVars.includeVars}`] = true;
|
||||||
...params,
|
|
||||||
...getVariablesUrlParams(),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
return getConfig().disableSanitizeHtml ? url : textUtil.sanitizeUrl(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { FieldType, GrafanaConfig, locationUtil, toDataFrame, VariableOrigin } from '@grafana/data';
|
import { FieldType, GrafanaConfig, locationUtil, toDataFrame, VariableOrigin } from '@grafana/data';
|
||||||
import { setTemplateSrv } from '@grafana/runtime';
|
import { setTemplateSrv } from '@grafana/runtime';
|
||||||
|
import { DashboardLink } from '@grafana/schema';
|
||||||
import { ContextSrv } from 'app/core/services/context_srv';
|
import { ContextSrv } from 'app/core/services/context_srv';
|
||||||
import { getTimeSrv, setTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv';
|
import { getTimeSrv, setTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv';
|
||||||
import { TimeModel } from 'app/features/dashboard/state/TimeModel';
|
import { TimeModel } from 'app/features/dashboard/state/TimeModel';
|
||||||
@ -176,9 +177,27 @@ describe('linkSrv', () => {
|
|||||||
it('converts link urls', () => {
|
it('converts link urls', () => {
|
||||||
const linkUrl = linkSrv.getLinkUrl({
|
const linkUrl = linkSrv.getLinkUrl({
|
||||||
url: '/graph',
|
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({
|
const linkUrlWithVar = linkSrv.getLinkUrl({
|
||||||
url: '/graph?home=$home',
|
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');
|
expect(linkUrl).toBe('/graph');
|
||||||
@ -187,8 +206,16 @@ describe('linkSrv', () => {
|
|||||||
|
|
||||||
it('appends current dashboard time range if keepTime is true', () => {
|
it('appends current dashboard time range if keepTime is true', () => {
|
||||||
const anchorInfoKeepTime = linkSrv.getLinkUrl({
|
const anchorInfoKeepTime = linkSrv.getLinkUrl({
|
||||||
keepTime: true,
|
|
||||||
url: '/graph',
|
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');
|
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', () => {
|
it('adds all variables to the url if includeVars is true', () => {
|
||||||
const anchorInfoIncludeVars = linkSrv.getLinkUrl({
|
const anchorInfoIncludeVars = linkSrv.getLinkUrl({
|
||||||
includeVars: true,
|
|
||||||
url: '/graph',
|
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');
|
expect(anchorInfoIncludeVars).toBe('/graph?var-home=127.0.0.1&var-server1=192.168.0.100');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('respects config disableSanitizeHtml', () => {
|
it('respects config disableSanitizeHtml', () => {
|
||||||
const anchorInfo = {
|
const anchorInfo: DashboardLink = {
|
||||||
url: 'javascript:alert(document.domain)',
|
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');
|
expect(linkSrv.getLinkUrl(anchorInfo)).toBe('about:blank');
|
||||||
|
@ -205,7 +205,7 @@ export const ContextMenuView = ({
|
|||||||
<MenuGroup key={`${group.label}${index}`} label={group.label}>
|
<MenuGroup key={`${group.label}${index}`} label={group.label}>
|
||||||
{(group.items || []).map((item) => (
|
{(group.items || []).map((item) => (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
key={item.label}
|
key={item.url}
|
||||||
url={item.url}
|
url={item.url}
|
||||||
label={item.label}
|
label={item.label}
|
||||||
target={item.target}
|
target={item.target}
|
||||||
|
Reference in New Issue
Block a user