diff --git a/apps/dashboard/kinds/v2beta1/dashboard_spec.cue b/apps/dashboard/kinds/v2beta1/dashboard_spec.cue index cc240be9a11..0e9dcee43b4 100644 --- a/apps/dashboard/kinds/v2beta1/dashboard_spec.cue +++ b/apps/dashboard/kinds/v2beta1/dashboard_spec.cue @@ -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") diff --git a/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_kind.cue b/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_kind.cue index f0797bc480b..895a7bc946a 100644 --- a/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_kind.cue +++ b/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_kind.cue @@ -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). diff --git a/apps/dashboard/pkg/apis/dashboard/v1beta1/dashboard_kind.cue b/apps/dashboard/pkg/apis/dashboard/v1beta1/dashboard_kind.cue index f0797bc480b..895a7bc946a 100644 --- a/apps/dashboard/pkg/apis/dashboard/v1beta1/dashboard_kind.cue +++ b/apps/dashboard/pkg/apis/dashboard/v1beta1/dashboard_kind.cue @@ -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). diff --git a/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec.cue b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec.cue index ecca9d311e9..000383a069e 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec.cue +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec.cue @@ -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") diff --git a/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec_gen.go b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec_gen.go index 220813ac547..fedd4dfd20a 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec_gen.go +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec_gen.go @@ -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 diff --git a/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.openapi.go b/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.openapi.go index ef920f197e0..8e3e22c83be 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.openapi.go +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.openapi.go @@ -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"}, }, diff --git a/kinds/dashboard/dashboard_kind.cue b/kinds/dashboard/dashboard_kind.cue index 9276f28a85e..ab4da8db7d9 100644 --- a/kinds/dashboard/dashboard_kind.cue +++ b/kinds/dashboard/dashboard_kind.cue @@ -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). diff --git a/packages/grafana-schema/src/index.gen.ts b/packages/grafana-schema/src/index.gen.ts index 4a7a575e83b..b608be01349 100644 --- a/packages/grafana-schema/src/index.gen.ts +++ b/packages/grafana-schema/src/index.gen.ts @@ -14,6 +14,7 @@ export type { VariableOption, DashboardLink, DashboardLinkType, + DashboardLinkPlacement, VariableType, FieldColorSeriesByMode, FieldColor, diff --git a/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts b/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts index 1bc8b1e1890..baf09ea16ce 100644 --- a/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts +++ b/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts @@ -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 = { */ 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. diff --git a/packages/grafana-schema/src/schema/dashboard/v2beta1/types.spec.gen.ts b/packages/grafana-schema/src/schema/dashboard/v2beta1/types.spec.gen.ts index 0bc4a00348d..65739dc88ec 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2beta1/types.spec.gen.ts +++ b/packages/grafana-schema/src/schema/dashboard/v2beta1/types.spec.gen.ts @@ -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 { diff --git a/pkg/kinds/dashboard/dashboard_spec_gen.go b/pkg/kinds/dashboard/dashboard_spec_gen.go index fc2b9343d77..e01c43d2229 100644 --- a/pkg/kinds/dashboard/dashboard_spec_gen.go +++ b/pkg/kinds/dashboard/dashboard_spec_gen.go @@ -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. diff --git a/public/app/features/dashboard-scene/scene/DashboardControls.tsx b/public/app/features/dashboard-scene/scene/DashboardControls.tsx index d8ae6b05b2d..6d25737dda1 100644 --- a/public/app/features/dashboard-scene/scene/DashboardControls.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardControls.tsx @@ -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 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 )} - {hasControlMenuVariables && ( + {(hasControlMenuVariables || hasControlMenuLinks) && ( - + )} {showDebugger && } diff --git a/public/app/features/dashboard-scene/scene/DropdownVariableControls.test.tsx b/public/app/features/dashboard-scene/scene/DashboardControlsMenu.test.tsx similarity index 79% rename from public/app/features/dashboard-scene/scene/DropdownVariableControls.test.tsx rename to public/app/features/dashboard-scene/scene/DashboardControlsMenu.test.tsx index 1e90d69fcc1..9552b19e2b8 100644 --- a/public/app/features/dashboard-scene/scene/DropdownVariableControls.test.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardControlsMenu.test.tsx @@ -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(); + const { container } = render(); expect(container.firstChild).toBeNull(); }); @@ -24,7 +24,7 @@ describe('DropdownVariableControls', () => { showInControlsMenu: false, }), ]; - const { container } = render(); + const { container } = render(); expect(container.firstChild).toBeNull(); }); @@ -37,13 +37,13 @@ describe('DropdownVariableControls', () => { }), ]; - render(); + render(); // 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(); + act(() => { + render(); + }); // Should have rendered a dropdown expect(screen.getByRole('button')).toBeInTheDocument(); @@ -96,7 +98,7 @@ describe('DropdownVariableControls', () => { }), ]; - render(); + render(); // Should still render dropdown since we have variables with showInControlsMenu=true expect(screen.getByRole('button')).toBeInTheDocument(); diff --git a/public/app/features/dashboard-scene/scene/DropdownVariableControls.tsx b/public/app/features/dashboard-scene/scene/DashboardControlsMenu.tsx similarity index 52% rename from public/app/features/dashboard-scene/scene/DropdownVariableControls.tsx rename to public/app/features/dashboard-scene/scene/DashboardControlsMenu.tsx index 756a1c3225a..b3eba43cd8f 100644 --- a/public/app/features/dashboard-scene/scene/DropdownVariableControls.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardControlsMenu.tsx @@ -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) => (
))} + + {variables.length > 0 && filteredLinks.length > 0 && } + + {/* Links */} + {filteredLinks.map((link, index) => ( +
+ +
+ ))} } > ({ + getLinkSrv: jest.fn(() => ({ + getAnchorInfo: mockGetAnchorInfo, + getLinkUrl: mockGetLinkUrl, + })), +})); + +jest.mock('app/features/dashboard/components/SubMenu/DashboardLinksDashboard', () => ({ + DashboardLinksDashboard: jest.fn(({ linkInfo, dashboardUID }) => ( +
+ Dashboard Link: {linkInfo.title} (UID: {dashboardUID}) +
+ )), + DashboardLinkButton: jest.fn(({ children, href, target, icon, ...props }) => ( + + {icon && {icon}} + {children} + + )), +})); + +jest.mock('@grafana/ui', () => ({ + ...jest.requireActual('@grafana/ui'), + Tooltip: jest.fn(({ children }) =>
{children}
), +})); + +describe('DashboardLinkRenderer', () => { + const dashboardUID = 'test-dashboard-uid'; + + beforeEach(() => { + mockGetAnchorInfo.mockClear(); + mockGetLinkUrl.mockClear(); + }); + + it('renders a dashboard type link using the 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + // 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(); + + 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(); + + 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(); + + 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(); + + expect(mockGetAnchorInfo).toHaveBeenCalledWith(link); + expect(mockGetAnchorInfo).toHaveBeenCalledTimes(1); + }); +}); diff --git a/public/app/features/dashboard-scene/scene/DashboardLinkRenderer.tsx b/public/app/features/dashboard-scene/scene/DashboardLinkRenderer.tsx new file mode 100644 index 00000000000..cf60d70b91e --- /dev/null +++ b/public/app/features/dashboard-scene/scene/DashboardLinkRenderer.tsx @@ -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 ; + } + + const icon = LINK_ICON_MAP[link.icon]; + + const linkElement = ( + + {linkInfo.title} + + ); + + return ( +
+ {link.tooltip ? {linkElement} : linkElement} +
+ ); +} diff --git a/public/app/features/dashboard-scene/scene/DashboardLinksControls.tsx b/public/app/features/dashboard-scene/scene/DashboardLinksControls.tsx index db7fb59dad7..8053055fe81 100644 --- a/public/app/features/dashboard-scene/scene/DashboardLinksControls.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardLinksControls.tsx @@ -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 ; - } - - const icon = LINK_ICON_MAP[link.icon]; - - const linkElement = ( - - {linkInfo.title} - - ); - - return ( -
- {link.tooltip ? {linkElement} : linkElement} -
- ); - })} + {links + .filter((link) => link.placement === undefined) + .map((link: DashboardLink, index: number) => ( + + ))} ); }