Dashboard Controls: Allow displaying dashboard links (#110666)

* feat: add `placement` to dashboard links (v1)

* feat: add `placement` property for dashboard links (v2)

* refactor: rename the `DropdownVariableControls` to `DashboardControlsMenu`

* feat: make it possible to render links under the dashboard controls dropdown

* fix: update the v2beta1 (and remove changes from v2alpha1)

* fix: linting errors

* chore: update codegen

* fix: frontend unit tests

* tests(DashboardLinkRenderer): add tests

* fix: hide divider if not necessary

* Update apps/dashboard/pkg/apis/dashboard/v1beta1/dashboard_kind.cue

Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>

* Update packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update kinds/dashboard/dashboard_kind.cue

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update pkg/kinds/dashboard/dashboard_spec_gen.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.openapi.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec_gen.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec.cue

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update packages/grafana-schema/src/schema/dashboard/v2beta1/types.spec.gen.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update apps/dashboard/kinds/v2beta1/dashboard_spec.cue

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* feat: remove "default" placement option for links (review note)

* feat: hide the <Stack> wrapper if there are no links or variables

---------

Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Levente Balogh
2025-09-11 13:16:46 +02:00
committed by GitHub
co-authored by Dominik Prokop Copilot
parent 2e0fdb2a60
commit 273731ed8e
17 changed files with 478 additions and 61 deletions
@@ -109,8 +109,14 @@ DashboardLink: {
includeVars: bool | *false
// If true, includes current time range in the link as query params
keepTime: bool | *false
// Placement can be used to display the link somewhere else on the dashboard other than above the visualisations.
placement?: DashboardLinkPlacement
}
// Dashboard Link placement. Defines where the link should be displayed.
// - "inControlsMenu" renders the link in bottom part of the dashboard controls dropdown menu
DashboardLinkPlacement: "inControlsMenu"
// A topic is attached to DataFrame metadata in query results.
// This specifies where the data should be used.
DataTopic: "series" | "annotations" | "alertStates" @cog(kind="enum",memberNames="Series|Annotations|AlertStates")
@@ -284,17 +284,24 @@ lineage: schemas: [{
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
asDropdown: bool | *false
// Placement can be used to display the link somewhere else on the dashboard other than above the visualisations.
placement?: #DashboardLinkPlacement
// If true, the link will be opened in a new tab
targetBlank: bool | *false
// If true, includes current template variables values in the link as query params
includeVars: bool | *false
// If true, includes current time range in the link as query params
keepTime: bool | *false
} @cuetsy(kind="interface")
// Dashboard Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource)
#DashboardLinkType: "link" | "dashboards" @cuetsy(kind="type")
// Dashboard Link placement. Defines where the link should be displayed.
// - "inControlsMenu" renders the link in bottom part of the dashboard controls dropdown menu
#DashboardLinkPlacement: "inControlsMenu" @cuetsy(kind="type")
// Dashboard variable type
// `query`: Query-generated list of values such as metric names, server names, sensor IDs, data centers, and so on.
// `adhoc`: Key/value filters that are automatically added to all metric queries for a data source (Prometheus, Loki, InfluxDB, and Elasticsearch only).
@@ -284,17 +284,24 @@ lineage: schemas: [{
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
asDropdown: bool | *false
// Placement can be used to display the link somewhere else on the dashboard other than above the visualisations.
placement?: #DashboardLinkPlacement
// If true, the link will be opened in a new tab
targetBlank: bool | *false
// If true, includes current template variables values in the link as query params
includeVars: bool | *false
// If true, includes current time range in the link as query params
keepTime: bool | *false
} @cuetsy(kind="interface")
// Dashboard Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource)
#DashboardLinkType: "link" | "dashboards" @cuetsy(kind="type")
// Dashboard Link placement. Defines where the link should be displayed.
// - "inControlsMenu" renders the link in bottom part of the dashboard controls dropdown menu
#DashboardLinkPlacement: "inControlsMenu" @cuetsy(kind="type")
// Dashboard variable type
// `query`: Query-generated list of values such as metric names, server names, sensor IDs, data centers, and so on.
// `adhoc`: Key/value filters that are automatically added to all metric queries for a data source (Prometheus, Loki, InfluxDB, and Elasticsearch only).
@@ -113,8 +113,14 @@ DashboardLink: {
includeVars: bool | *false
// If true, includes current time range in the link as query params
keepTime: bool | *false
// Placement can be used to display the link somewhere else on the dashboard other than above the visualisations.
placement?: DashboardLinkPlacement
}
// Dashboard Link placement. Defines where the link should be displayed.
// - "inControlsMenu" renders the link in bottom part of the dashboard controls dropdown menu
DashboardLinkPlacement: "inControlsMenu"
// A topic is attached to DataFrame metadata in query results.
// This specifies where the data should be used.
DataTopic: "series" | "annotations" | "alertStates" @cog(kind="enum",memberNames="Series|Annotations|AlertStates")
@@ -1106,6 +1106,8 @@ type DashboardDashboardLink struct {
IncludeVars bool `json:"includeVars"`
// If true, includes current time range in the link as query params
KeepTime bool `json:"keepTime"`
// Placement can be used to display the link somewhere else on the dashboard other than above the visualisations.
Placement string `json:"placement,omitempty"`
}
// NewDashboardDashboardLink creates a new DashboardDashboardLink object.
@@ -1116,6 +1118,7 @@ func NewDashboardDashboardLink() *DashboardDashboardLink {
TargetBlank: false,
IncludeVars: false,
KeepTime: false,
Placement: DashboardDashboardLinkPlacement,
}
}
@@ -1128,6 +1131,11 @@ const (
DashboardDashboardLinkTypeDashboards DashboardDashboardLinkType = "dashboards"
)
// Dashboard Link placement. Defines where the link should be displayed.
// - "inControlsMenu" renders the link in bottom part of the dashboard controls dropdown menu
// +k8s:openapi-gen=true
const DashboardDashboardLinkPlacement = "inControlsMenu"
// Time configuration
// It defines the default time config for the time picker, the refresh picker for the specific dashboard.
// +k8s:openapi-gen=true
@@ -1478,6 +1478,13 @@ func schema_pkg_apis_dashboard_v2beta1_DashboardDashboardLink(ref common.Referen
Format: "",
},
},
"placement": {
SchemaProps: spec.SchemaProps{
Description: "Placement can be used to display the link somewhere else on the dashboard other than above the visualisations.",
Type: []string{"string"},
Format: "",
},
},
},
Required: []string{"title", "type", "icon", "tooltip", "tags", "asDropdown", "targetBlank", "includeVars", "keepTime"},
},
+7
View File
@@ -280,17 +280,24 @@ lineage: schemas: [{
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
asDropdown: bool | *false
// Placement can be used to display the link somewhere else on the dashboard other than above the visualisations.
placement?: #DashboardLinkPlacement
// If true, the link will be opened in a new tab
targetBlank: bool | *false
// If true, includes current template variables values in the link as query params
includeVars: bool | *false
// If true, includes current time range in the link as query params
keepTime: bool | *false
} @cuetsy(kind="interface")
// Dashboard Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource)
#DashboardLinkType: "link" | "dashboards" @cuetsy(kind="type")
// Dashboard Link placement. Defines where the link should be displayed.
// - "inControlsMenu" renders the link in bottom part of the dashboard controls dropdown menu
#DashboardLinkPlacement: "inControlsMenu" @cuetsy(kind="type")
// Dashboard variable type
// `query`: Query-generated list of values such as metric names, server names, sensor IDs, data centers, and so on.
// `adhoc`: Key/value filters that are automatically added to all metric queries for a data source (Prometheus, Loki, InfluxDB, and Elasticsearch only).
+1
View File
@@ -14,6 +14,7 @@ export type {
VariableOption,
DashboardLink,
DashboardLinkType,
DashboardLinkPlacement,
VariableType,
FieldColorSeriesByMode,
FieldColor,
@@ -314,6 +314,10 @@ export interface DashboardLink {
* If true, includes current time range in the link as query params
*/
keepTime: boolean;
/**
* Placement can be used to display the link somewhere else on the dashboard other than above the visualisations.
*/
placement?: DashboardLinkPlacement;
/**
* List of tags to limit the linked dashboards. If empty, all dashboards will be displayed. Only valid if the type is dashboards
*/
@@ -353,6 +357,12 @@ export const defaultDashboardLink: Partial<DashboardLink> = {
*/
export type DashboardLinkType = ('link' | 'dashboards');
/**
* Dashboard Link placement. Defines where the link should be displayed.
* - "inControlsMenu" renders the link in bottom part of the dashboard controls dropdown menu
*/
export type DashboardLinkPlacement = 'inControlsMenu';
/**
* Dashboard variable type
* `query`: Query-generated list of values such as metric names, server names, sensor IDs, data centers, and so on.
@@ -885,6 +885,8 @@ export interface DashboardLink {
includeVars: boolean;
// If true, includes current time range in the link as query params
keepTime: boolean;
// Placement can be used to display the link somewhere else on the dashboard other than above the visualisations.
placement?: "inControlsMenu";
}
export const defaultDashboardLink = (): DashboardLink => ({
@@ -904,6 +906,10 @@ export type DashboardLinkType = "link" | "dashboards";
export const defaultDashboardLinkType = (): DashboardLinkType => ("link");
// Dashboard Link placement. Defines where the link should be displayed.
// - "inControlsMenu" renders the link in bottom part of the dashboard controls dropdown menu
export const DashboardLinkPlacement = "inControlsMenu";
// Time configuration
// It defines the default time config for the time picker, the refresh picker for the specific dashboard.
export interface TimeSettingsSpec {
@@ -272,6 +272,8 @@ type DashboardLink struct {
Tags []string `json:"tags"`
// 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
AsDropdown bool `json:"asDropdown"`
// Placement can be used to display the link somewhere else on the dashboard other than above the visualisations.
Placement string `json:"placement,omitempty"`
// If true, the link will be opened in a new tab
TargetBlank bool `json:"targetBlank"`
// If true, includes current template variables values in the link as query params
@@ -285,6 +287,7 @@ func NewDashboardLink() *DashboardLink {
return &DashboardLink{
Tags: []string{},
AsDropdown: false,
Placement: DashboardLinkPlacement,
TargetBlank: false,
IncludeVars: false,
KeepTime: false,
@@ -299,6 +302,10 @@ const (
DashboardLinkTypeDashboards DashboardLinkType = "dashboards"
)
// Dashboard Link placement. Defines where the link should be displayed.
// - "inControlsMenu" renders the link in bottom part of the dashboard controls dropdown menu
const DashboardLinkPlacement = "inControlsMenu"
// Transformations allow to manipulate data returned by a query before the system applies a visualization.
// Using transformations you can: rename fields, join time series data, perform mathematical operations across queries,
// use the output of one transformation as the input to another transformation, etc.
@@ -20,9 +20,9 @@ import { Box, Stack, useStyles2 } from '@grafana/ui';
import { PanelEditControls } from '../panel-edit/PanelEditControls';
import { getDashboardSceneFor } from '../utils/utils';
import { DashboardControlsMenu } from './DashboardControlsMenu';
import { DashboardLinksControls } from './DashboardLinksControls';
import { DashboardScene } from './DashboardScene';
import { DropdownVariableControls } from './DropdownVariableControls';
import { VariableControls } from './VariableControls';
export interface DashboardControlsState extends SceneObjectState {
@@ -128,6 +128,7 @@ function DashboardControlsRenderer({ model }: SceneComponentProps<DashboardContr
.getVariables(dashboard)
.useState()
.variables.some((v) => v.state.showInControlsMenu === true);
const hasControlMenuLinks = links.some((link) => link.placement === 'inControlsMenu');
if (!model.hasControls()) {
// To still have spacing when no controls are rendered
@@ -156,9 +157,9 @@ function DashboardControlsRenderer({ model }: SceneComponentProps<DashboardContr
<refreshPicker.Component model={refreshPicker} />
</Stack>
)}
{hasControlMenuVariables && (
{(hasControlMenuVariables || hasControlMenuLinks) && (
<Stack>
<DropdownVariableControls dashboard={dashboard} />
<DashboardControlsMenu dashboard={dashboard} />
</Stack>
)}
{showDebugger && <SceneDebugger scene={model} key={'scene-debugger'} />}
@@ -1,18 +1,18 @@
import { render, screen } from '@testing-library/react';
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SceneVariableSet, TextBoxVariable, QueryVariable, CustomVariable, SceneVariable } from '@grafana/scenes';
import { DashboardScene } from './DashboardScene';
import {
DROPDOWN_CONTROLS_ARIA_LABEL,
DROPDOWN_CONTROLS_TITLE,
DropdownVariableControls,
} from './DropdownVariableControls';
DASHBOARD_CONTROLS_MENU_ARIA_LABEL,
DASHBOARD_CONTROLS_MENU_TITLE,
DashboardControlsMenu,
} from './DashboardControlsMenu';
import { DashboardScene } from './DashboardScene';
describe('DropdownVariableControls', () => {
describe('DashboardControlsMenu', () => {
it('should return null and not render anything when there are no variables', () => {
const { container } = render(<DropdownVariableControls dashboard={getDashboard([])} />);
const { container } = render(<DashboardControlsMenu dashboard={getDashboard([])} />);
expect(container.firstChild).toBeNull();
});
@@ -24,7 +24,7 @@ describe('DropdownVariableControls', () => {
showInControlsMenu: false,
}),
];
const { container } = render(<DropdownVariableControls dashboard={getDashboard(variables)} />);
const { container } = render(<DashboardControlsMenu dashboard={getDashboard(variables)} />);
expect(container.firstChild).toBeNull();
});
@@ -37,13 +37,13 @@ describe('DropdownVariableControls', () => {
}),
];
render(<DropdownVariableControls dashboard={getDashboard(variables)} />);
render(<DashboardControlsMenu dashboard={getDashboard(variables)} />);
// Should render the toolbar button
const button = screen.getByRole('button');
expect(button).toBeInTheDocument();
expect(button).toHaveAttribute('aria-label', DROPDOWN_CONTROLS_ARIA_LABEL);
expect(button).toHaveAttribute('title', DROPDOWN_CONTROLS_TITLE);
expect(button).toHaveAttribute('aria-label', DASHBOARD_CONTROLS_MENU_ARIA_LABEL);
expect(button).toHaveAttribute('title', DASHBOARD_CONTROLS_MENU_TITLE);
});
it('should render multiple variables in dropdown menu', async () => {
@@ -65,7 +65,9 @@ describe('DropdownVariableControls', () => {
}),
];
render(<DropdownVariableControls dashboard={getDashboard(variables)} />);
act(() => {
render(<DashboardControlsMenu dashboard={getDashboard(variables)} />);
});
// Should have rendered a dropdown
expect(screen.getByRole('button')).toBeInTheDocument();
@@ -96,7 +98,7 @@ describe('DropdownVariableControls', () => {
}),
];
render(<DropdownVariableControls dashboard={getDashboard(variables)} />);
render(<DashboardControlsMenu dashboard={getDashboard(variables)} />);
// Should still render dropdown since we have variables with showInControlsMenu=true
expect(screen.getByRole('button')).toBeInTheDocument();
@@ -5,20 +5,23 @@ import { t } from '@grafana/i18n';
import { sceneGraph } from '@grafana/scenes';
import { Dropdown, Menu, ToolbarButton, useStyles2 } from '@grafana/ui';
import { DashboardLinkRenderer } from './DashboardLinkRenderer';
import { DashboardScene } from './DashboardScene';
import { VariableValueSelectWrapper } from './VariableControls';
export const DROPDOWN_CONTROLS_ARIA_LABEL = 'Dashboard controls menu';
export const DROPDOWN_CONTROLS_TITLE = 'Dashboard controls';
export const DASHBOARD_CONTROLS_MENU_ARIA_LABEL = 'Dashboard controls menu';
export const DASHBOARD_CONTROLS_MENU_TITLE = 'Dashboard controls';
export function DropdownVariableControls({ dashboard }: { dashboard: DashboardScene }) {
export function DashboardControlsMenu({ dashboard }: { dashboard: DashboardScene }) {
const styles = useStyles2(getStyles);
const { links, uid } = dashboard.useState();
const filteredLinks = links.filter((link) => link.placement === 'inControlsMenu');
const variables = sceneGraph
.getVariables(dashboard)!
.useState()
.variables.filter((v) => v.state.showInControlsMenu === true);
if (variables.length === 0) {
if ((variables.length === 0 && filteredLinks.length === 0) || !uid) {
return null;
}
@@ -30,17 +33,27 @@ export function DropdownVariableControls({ dashboard }: { dashboard: DashboardSc
e.stopPropagation();
}}
>
{/* Variables */}
{variables.map((variable) => (
<div className={styles.menuItem} key={variable.state.key}>
<VariableValueSelectWrapper variable={variable} />
</div>
))}
{variables.length > 0 && filteredLinks.length > 0 && <Menu.Divider />}
{/* Links */}
{filteredLinks.map((link, index) => (
<div className={styles.menuItem} key={`${link.title}-$${index}`}>
<DashboardLinkRenderer link={link} dashboardUID={uid} />
</div>
))}
</Menu>
}
>
<ToolbarButton
aria-label={t('dashboard.controls.menu.aria-label', DROPDOWN_CONTROLS_ARIA_LABEL)}
title={t('dashboard.controls.menu.title', DROPDOWN_CONTROLS_TITLE)}
aria-label={t('dashboard.controls.menu.aria-label', DASHBOARD_CONTROLS_MENU_ARIA_LABEL)}
title={t('dashboard.controls.menu.title', DASHBOARD_CONTROLS_MENU_TITLE)}
icon="ellipsis-v"
iconSize="md"
narrow
@@ -0,0 +1,317 @@
import { render, screen } from '@testing-library/react';
import { selectors } from '@grafana/e2e-selectors';
import { DashboardLink } from '@grafana/schema';
import { DashboardLinkRenderer } from './DashboardLinkRenderer';
const mockGetAnchorInfo = jest.fn();
const mockGetLinkUrl = jest.fn();
jest.mock('app/features/panel/panellinks/link_srv', () => ({
getLinkSrv: jest.fn(() => ({
getAnchorInfo: mockGetAnchorInfo,
getLinkUrl: mockGetLinkUrl,
})),
}));
jest.mock('app/features/dashboard/components/SubMenu/DashboardLinksDashboard', () => ({
DashboardLinksDashboard: jest.fn(({ linkInfo, dashboardUID }) => (
<div data-testid="dashboard-links-dashboard">
Dashboard Link: {linkInfo.title} (UID: {dashboardUID})
</div>
)),
DashboardLinkButton: jest.fn(({ children, href, target, icon, ...props }) => (
<a href={href} target={target} {...props}>
{icon && <span data-testid="link-icon">{icon}</span>}
{children}
</a>
)),
}));
jest.mock('@grafana/ui', () => ({
...jest.requireActual('@grafana/ui'),
Tooltip: jest.fn(({ children }) => <div data-testid="tooltip-wrapper">{children}</div>),
}));
describe('DashboardLinkRenderer', () => {
const dashboardUID = 'test-dashboard-uid';
beforeEach(() => {
mockGetAnchorInfo.mockClear();
mockGetLinkUrl.mockClear();
});
it('renders a dashboard type link using the <DashboardLinksDashboard> component', () => {
const link: DashboardLink = {
type: 'dashboards', // dashboard type link
title: 'Dashboard Link',
url: '',
icon: 'dashboard',
tooltip: '',
tags: ['tag1'],
asDropdown: false,
targetBlank: false,
keepTime: false,
includeVars: false,
};
const linkInfo = {
title: 'Dashboard Link',
href: '/d/test-uid/dashboard-link',
tooltip: null,
};
mockGetAnchorInfo.mockReturnValue(linkInfo);
render(<DashboardLinkRenderer link={link} dashboardUID={dashboardUID} />);
expect(screen.getByTestId('dashboard-links-dashboard')).toBeInTheDocument();
expect(screen.getByText('Dashboard Link: Dashboard Link (UID: test-dashboard-uid)')).toBeInTheDocument();
});
it('renders a regular link with proper attributes', () => {
const link: DashboardLink = {
type: 'link', // regular link
title: 'External Link',
url: 'https://example.com',
icon: 'external link',
tooltip: '',
tags: [],
asDropdown: false,
targetBlank: true,
keepTime: false,
includeVars: false,
};
const linkInfo = {
title: 'External Link',
href: 'https://example.com',
tooltip: null,
};
mockGetAnchorInfo.mockReturnValue(linkInfo);
render(<DashboardLinkRenderer link={link} dashboardUID={dashboardUID} />);
const container = screen.getByTestId(selectors.components.DashboardLinks.container);
expect(container).toBeInTheDocument();
const linkElement = screen.getByTestId(selectors.components.DashboardLinks.link);
expect(linkElement).toBeInTheDocument();
expect(linkElement).toHaveAttribute('href', 'https://example.com');
expect(linkElement).toHaveAttribute('target', '_blank');
expect(linkElement).toHaveAttribute('rel', 'noreferrer');
expect(linkElement).toHaveTextContent('External Link');
});
it('renders link without target="_blank" when `targetBlank` is false', () => {
const link: DashboardLink = {
type: 'link',
title: 'Internal Link',
url: '/dashboard/internal',
icon: 'dashboard',
tooltip: '',
tags: [],
asDropdown: false,
targetBlank: false,
keepTime: false,
includeVars: false,
};
const linkInfo = {
title: 'Internal Link',
href: '/dashboard/internal',
tooltip: null,
};
mockGetAnchorInfo.mockReturnValue(linkInfo);
render(<DashboardLinkRenderer link={link} dashboardUID={dashboardUID} />);
const linkElement = screen.getByTestId(selectors.components.DashboardLinks.link);
expect(linkElement).not.toHaveAttribute('target');
expect(linkElement).toHaveAttribute('rel', 'noreferrer');
});
it('renders link with correct icon from LINK_ICON_MAP', () => {
const link: DashboardLink = {
type: 'link',
title: 'Question Link',
url: '/help',
icon: 'question',
tooltip: '',
tags: [],
asDropdown: false,
targetBlank: false,
keepTime: false,
includeVars: false,
};
const linkInfo = {
title: 'Question Link',
href: '/help',
tooltip: null,
};
mockGetAnchorInfo.mockReturnValue(linkInfo);
render(<DashboardLinkRenderer link={link} dashboardUID={dashboardUID} />);
const iconElement = screen.getByTestId('link-icon');
expect(iconElement).toBeInTheDocument();
expect(iconElement).toHaveTextContent('question-circle');
});
it('renders link with tooltip when tooltip is provided', () => {
const link: DashboardLink = {
type: 'link',
title: 'Link with Tooltip',
url: '/dashboard/test',
icon: 'info',
tooltip: 'This is a helpful tooltip',
tags: [],
asDropdown: false,
targetBlank: false,
keepTime: false,
includeVars: false,
};
const linkInfo = {
title: 'Link with Tooltip',
href: '/dashboard/test',
tooltip: 'This is a helpful tooltip',
};
mockGetAnchorInfo.mockReturnValue(linkInfo);
render(<DashboardLinkRenderer link={link} dashboardUID={dashboardUID} />);
// The tooltip component should be rendered around the link
const container = screen.getByTestId(selectors.components.DashboardLinks.container);
expect(container).toBeInTheDocument();
const linkElement = screen.getByTestId(selectors.components.DashboardLinks.link);
expect(linkElement).toHaveTextContent('Link with Tooltip');
});
it('renders link without tooltip wrapper when tooltip is empty', () => {
const link: DashboardLink = {
type: 'link',
title: 'Link without Tooltip',
url: '/dashboard/test',
icon: 'bolt',
tooltip: '',
tags: [],
asDropdown: false,
targetBlank: false,
keepTime: false,
includeVars: false,
};
const linkInfo = {
title: 'Link without Tooltip',
href: '/dashboard/test',
tooltip: null,
};
mockGetAnchorInfo.mockReturnValue(linkInfo);
render(<DashboardLinkRenderer link={link} dashboardUID={dashboardUID} />);
const container = screen.getByTestId(selectors.components.DashboardLinks.container);
expect(container).toBeInTheDocument();
const linkElement = screen.getByTestId(selectors.components.DashboardLinks.link);
expect(linkElement).toHaveTextContent('Link without Tooltip');
});
it('handles undefined icon gracefully', () => {
const link: DashboardLink = {
type: 'link',
title: 'Link with Unknown Icon',
url: '/dashboard/test',
icon: 'unknown-icon',
tooltip: '',
tags: [],
asDropdown: false,
targetBlank: false,
keepTime: false,
includeVars: false,
};
const linkInfo = {
title: 'Link with Unknown Icon',
href: '/dashboard/test',
tooltip: null,
};
mockGetAnchorInfo.mockReturnValue(linkInfo);
render(<DashboardLinkRenderer link={link} dashboardUID={dashboardUID} />);
const linkElement = screen.getByTestId(selectors.components.DashboardLinks.link);
expect(linkElement).toHaveTextContent('Link with Unknown Icon');
// Should not render an icon when the icon is not in the map
expect(screen.queryByTestId('link-icon')).not.toBeInTheDocument();
});
it('sanitizes the URL from linkInfo', () => {
const link: DashboardLink = {
type: 'link',
title: 'Potentially Unsafe Link',
url: 'javascript:alert("xss")',
icon: 'external link',
tooltip: '',
tags: [],
asDropdown: false,
targetBlank: false,
keepTime: false,
includeVars: false,
};
const linkInfo = {
title: 'Potentially Unsafe Link',
href: 'javascript:alert("xss")',
tooltip: null,
};
mockGetAnchorInfo.mockReturnValue(linkInfo);
render(<DashboardLinkRenderer link={link} dashboardUID={dashboardUID} />);
const linkElement = screen.getByTestId(selectors.components.DashboardLinks.link);
// The sanitizeUrl function should clean the href
expect(linkElement).toHaveAttribute('href', 'about:blank');
expect(linkElement).toBeInTheDocument();
});
it('calls getLinkSrv().getAnchorInfo with the provided link', () => {
const link: DashboardLink = {
type: 'link',
title: 'Test Link',
url: '/test',
icon: 'bolt',
tooltip: '',
tags: [],
asDropdown: false,
targetBlank: false,
keepTime: false,
includeVars: false,
};
const linkInfo = {
title: 'Test Link',
href: '/test',
tooltip: null,
};
mockGetAnchorInfo.mockReturnValue(linkInfo);
render(<DashboardLinkRenderer link={link} dashboardUID={dashboardUID} />);
expect(mockGetAnchorInfo).toHaveBeenCalledWith(link);
expect(mockGetAnchorInfo).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,44 @@
import { sanitizeUrl } from '@grafana/data/internal';
import { selectors } from '@grafana/e2e-selectors';
import { DashboardLink } from '@grafana/schema';
import { Tooltip } from '@grafana/ui';
import {
DashboardLinkButton,
DashboardLinksDashboard,
} from 'app/features/dashboard/components/SubMenu/DashboardLinksDashboard';
import { getLinkSrv } from 'app/features/panel/panellinks/link_srv';
import { LINK_ICON_MAP } from '../settings/links/utils';
export interface Props {
link: DashboardLink;
dashboardUID: string;
}
export function DashboardLinkRenderer({ link, dashboardUID }: Props) {
const linkInfo = getLinkSrv().getAnchorInfo(link);
if (link.type === 'dashboards') {
return <DashboardLinksDashboard link={link} linkInfo={linkInfo} dashboardUID={dashboardUID} />;
}
const icon = LINK_ICON_MAP[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 data-testid={selectors.components.DashboardLinks.container}>
{link.tooltip ? <Tooltip content={linkInfo.tooltip}>{linkElement}</Tooltip> : linkElement}
</div>
);
}
@@ -1,16 +1,7 @@
import { sanitizeUrl } from '@grafana/data/internal';
import { selectors } from '@grafana/e2e-selectors';
import { sceneGraph } from '@grafana/scenes';
import { DashboardLink } from '@grafana/schema';
import { Tooltip } from '@grafana/ui';
import {
DashboardLinkButton,
DashboardLinksDashboard,
} from 'app/features/dashboard/components/SubMenu/DashboardLinksDashboard';
import { getLinkSrv } from 'app/features/panel/panellinks/link_srv';
import { LINK_ICON_MAP } from '../settings/links/utils';
import { DashboardLinkRenderer } from './DashboardLinkRenderer';
import { DashboardScene } from './DashboardScene';
export interface Props {
@@ -28,34 +19,11 @@ export function DashboardLinksControls({ links, dashboard }: Props) {
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={uid} />;
}
const icon = LINK_ICON_MAP[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>
);
})}
{links
.filter((link) => link.placement === undefined)
.map((link: DashboardLink, index: number) => (
<DashboardLinkRenderer link={link} dashboardUID={uid} key={`${link.title}-$${index}`} />
))}
</>
);
}