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:
co-authored by
Dominik Prokop
Copilot
parent
2e0fdb2a60
commit
273731ed8e
@@ -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"},
|
||||
},
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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'} />}
|
||||
|
||||
+16
-14
@@ -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();
|
||||
+19
-6
@@ -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}`} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user