diff --git a/.betterer.results b/.betterer.results index fcaefeed57e..ba53b876c21 100644 --- a/.betterer.results +++ b/.betterer.results @@ -2442,7 +2442,8 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "2"], [0, 0, 0, "Do not use any type assertions.", "3"], [0, 0, 0, "Do not use any type assertions.", "4"], - [0, 0, 0, "Do not use any type assertions.", "5"] + [0, 0, 0, "Do not use any type assertions.", "5"], + [0, 0, 0, "Do not use any type assertions.", "6"] ], "public/app/features/dashboard-scene/settings/variables/components/VariableSelectField.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] diff --git a/public/app/features/dashboard-scene/inspect/InspectJsonTab.test.tsx b/public/app/features/dashboard-scene/inspect/InspectJsonTab.test.tsx index b1b91bc6ec1..903ce6daddc 100644 --- a/public/app/features/dashboard-scene/inspect/InspectJsonTab.test.tsx +++ b/public/app/features/dashboard-scene/inspect/InspectJsonTab.test.tsx @@ -12,6 +12,7 @@ import { import { getStandardTransformers } from 'app/features/transformers/standardTransformers'; import { DashboardScene } from '../scene/DashboardScene'; +import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks'; import { activateFullSceneTree } from '../utils/test-utils'; import { findVizPanelByKey } from '../utils/utils'; @@ -90,6 +91,7 @@ async function buildTestScene() { title: 'Panel A', pluginId: 'table', key: 'panel-12', + titleItems: [new VizPanelLinks({ menu: new VizPanelLinksMenu({}) })], $data: new SceneDataTransformer({ transformations: [ { diff --git a/public/app/features/dashboard-scene/scene/PanelLinks.tsx b/public/app/features/dashboard-scene/scene/PanelLinks.tsx index 2a0bcd7501e..62643f15e38 100644 --- a/public/app/features/dashboard-scene/scene/PanelLinks.tsx +++ b/public/app/features/dashboard-scene/scene/PanelLinks.tsx @@ -1,10 +1,13 @@ import React from 'react'; -import { LinkModel } from '@grafana/data'; -import { SceneComponentProps, SceneObjectBase, SceneObjectState } from '@grafana/scenes'; -import { Dropdown, Menu, ToolbarButton } from '@grafana/ui'; +import { DataLink, LinkModel } from '@grafana/data'; +import { SceneComponentProps, SceneObjectBase, SceneObjectState, VizPanel } from '@grafana/scenes'; +import { Dropdown, Icon, Menu, PanelChrome, ToolbarButton } from '@grafana/ui'; + +import { getPanelLinks } from './PanelMenuBehavior'; interface VizPanelLinksState extends SceneObjectState { + rawLinks?: DataLink[]; links?: LinkModel[]; menu: VizPanelLinksMenu; } @@ -14,7 +17,24 @@ export class VizPanelLinks extends SceneObjectBase { } function VizPanelLinksRenderer({ model }: SceneComponentProps) { - const { menu } = model.useState(); + const { menu, rawLinks } = model.useState(); + + if (!(model.parent instanceof VizPanel)) { + throw new Error('VizPanelLinks must be a child of VizPanel'); + } + + if (!rawLinks || rawLinks.length === 0) { + return null; + } + + if (rawLinks.length === 1) { + const link = getPanelLinks(model.parent)[0]; + return ( + + + + ); + } return ( ) { ); } -export class VizPanelLinksMenu extends SceneObjectBase> { +export class VizPanelLinksMenu extends SceneObjectBase> { static Component = VizPanelLinksMenuRenderer; } diff --git a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx index d85d6dc7564..757c6599a64 100644 --- a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx +++ b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx @@ -10,9 +10,8 @@ import { config, getPluginLinkExtensions, locationService } from '@grafana/runti import { LocalValueVariable, SceneGridRow, VizPanel, VizPanelMenu, sceneGraph } from '@grafana/scenes'; import { DataQuery } from '@grafana/schema'; import { t } from 'app/core/internationalization'; -import { PanelModel } from 'app/features/dashboard/state'; import { InspectTab } from 'app/features/inspector/types'; -import { getPanelLinksSupplier } from 'app/features/panel/panellinks/linkSuppliers'; +import { getScenePanelLinksSupplier } from 'app/features/panel/panellinks/linkSuppliers'; import { createExtensionSubMenu } from 'app/features/plugins/extensions/utils'; import { addDataTrailPanelAction } from 'app/features/trails/dashboardIntegration'; @@ -23,7 +22,7 @@ import { getDashboardSceneFor, getPanelIdForVizPanel, getQueryRunnerFor } from ' import { DashboardScene } from './DashboardScene'; import { LibraryVizPanel } from './LibraryVizPanel'; -import { VizPanelLinks } from './PanelLinks'; +import { VizPanelLinks, VizPanelLinksMenu } from './PanelLinks'; /** * Behavior is called when VizPanelMenu is activated (ie when it's opened). @@ -217,29 +216,39 @@ function getInspectMenuItem( /** * Behavior is called when VizPanelLinksMenu is activated (when it's opened). */ -export function getPanelLinksBehavior(panel: PanelModel) { - return (panelLinksMenu: VizPanelLinks) => { - const interpolate: InterpolateFunction = (v, scopedVars) => { - return sceneGraph.interpolate(panelLinksMenu, v, scopedVars); - }; +export function panelLinksBehavior(panelLinksMenu: VizPanelLinksMenu) { + if (!(panelLinksMenu.parent instanceof VizPanelLinks)) { + throw new Error('parent of VizPanelLinksMenu must be VizPanelLinks'); + } + const panel = panelLinksMenu.parent.parent; - const linkSupplier = getPanelLinksSupplier(panel, interpolate); + if (!(panel instanceof VizPanel)) { + throw new Error('parent of VizPanelLinks must be VizPanel'); + } - if (!linkSupplier) { - return; - } + panelLinksMenu.setState({ links: getPanelLinks(panel) }); +} - const panelLinks = linkSupplier && linkSupplier.getLinks(interpolate); - - const links = panelLinks.map((panelLink) => ({ - ...panelLink, - onClick: (e: any, origin: any) => { - DashboardInteractions.panelLinkClicked({ has_multiple_links: panelLinks.length > 1 }); - panelLink.onClick?.(e, origin); - }, - })); - panelLinksMenu.setState({ links }); +export function getPanelLinks(panel: VizPanel) { + const interpolate: InterpolateFunction = (v, scopedVars) => { + return sceneGraph.interpolate(panel, v, scopedVars); }; + + const linkSupplier = getScenePanelLinksSupplier(panel, interpolate); + + if (!linkSupplier) { + return []; + } + + const panelLinks = linkSupplier.getLinks(interpolate); + + return panelLinks.map((panelLink) => ({ + ...panelLink, + onClick: (e: any, origin: any) => { + DashboardInteractions.panelLinkClicked({ has_multiple_links: panelLinks.length > 1 }); + panelLink.onClick?.(e, origin); + }, + })); } function createExtensionContext(panel: VizPanel, dashboard: DashboardScene): PluginExtensionPanelContext { diff --git a/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModel.test.ts.snap b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModel.test.ts.snap index cd2f371a21a..312b0161df7 100644 --- a/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModel.test.ts.snap +++ b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModel.test.ts.snap @@ -110,6 +110,7 @@ exports[`transformSceneToSaveModel Given a scene with rows Should transform back "y": 1, }, "id": 15, + "links": [], "options": { "code": { "language": "plaintext", @@ -164,6 +165,7 @@ exports[`transformSceneToSaveModel Given a scene with rows Should transform back "y": 26, }, "id": 30, + "links": [], "options": { "code": { "language": "plaintext", @@ -376,6 +378,7 @@ exports[`transformSceneToSaveModel Given a simple scene with custom settings Sho "y": 0, }, "id": 28, + "links": [], "options": { "legend": { "calcs": [], @@ -434,6 +437,7 @@ exports[`transformSceneToSaveModel Given a simple scene with custom settings Sho "y": 9, }, "id": 29, + "links": [], "options": {}, "targets": [ { @@ -464,6 +468,7 @@ exports[`transformSceneToSaveModel Given a simple scene with custom settings Sho "y": 9, }, "id": 25, + "links": [], "options": { "code": { "language": "plaintext", @@ -692,6 +697,7 @@ exports[`transformSceneToSaveModel Given a simple scene with variables Should tr "y": 0, }, "id": 28, + "links": [], "options": { "legend": { "calcs": [], @@ -750,6 +756,7 @@ exports[`transformSceneToSaveModel Given a simple scene with variables Should tr "y": 9, }, "id": 29, + "links": [], "options": {}, "targets": [ { @@ -780,6 +787,7 @@ exports[`transformSceneToSaveModel Given a simple scene with variables Should tr "y": 9, }, "id": 25, + "links": [], "options": { "code": { "language": "plaintext", diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts index 4817713fee9..6a1dae0679a 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts @@ -40,7 +40,7 @@ import { registerDashboardMacro } from '../scene/DashboardMacro'; import { DashboardScene } from '../scene/DashboardScene'; import { LibraryVizPanel } from '../scene/LibraryVizPanel'; import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks'; -import { getPanelLinksBehavior, panelMenuBehavior } from '../scene/PanelMenuBehavior'; +import { panelLinksBehavior, panelMenuBehavior } from '../scene/PanelMenuBehavior'; import { PanelNotices } from '../scene/PanelNotices'; import { PanelRepeaterGridItem } from '../scene/PanelRepeaterGridItem'; import { PanelTimeRange } from '../scene/PanelTimeRange'; @@ -409,16 +409,14 @@ export function buildGridItemForLibPanel(panel: PanelModel) { } export function buildGridItemForPanel(panel: PanelModel): SceneGridItemLike { - const hasPanelLinks = panel.links && panel.links.length > 0; const titleItems: SceneObject[] = []; - let panelLinks; - if (hasPanelLinks) { - panelLinks = new VizPanelLinks({ - menu: new VizPanelLinksMenu({ $behaviors: [getPanelLinksBehavior(panel)] }), - }); - titleItems.push(panelLinks); - } + titleItems.push( + new VizPanelLinks({ + rawLinks: panel.links, + menu: new VizPanelLinksMenu({ $behaviors: [panelLinksBehavior] }), + }) + ); titleItems.push(new PanelNotices()); diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts index f64ee741f18..86b675a6f81 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts @@ -277,6 +277,39 @@ describe('transformSceneToSaveModel', () => { expect(saveModel.gridPos?.w).toBe(12); expect(saveModel.gridPos?.h).toBe(8); }); + it('Given panel with links', () => { + const gridItem = buildGridItemFromPanelSchema({ + title: '', + type: 'text-plugin-34', + gridPos: { x: 1, y: 2, w: 12, h: 8 }, + links: [ + // @ts-expect-error Panel link is wrongly typed as DashboardLink + { + title: 'Link 1', + url: 'http://some.test.link1', + }, + // @ts-expect-error Panel link is wrongly typed as DashboardLink + { + targetBlank: true, + title: 'Link 2', + url: 'http://some.test.link2', + }, + ], + }); + + const saveModel = gridItemToPanel(gridItem); + expect(saveModel.links).toEqual([ + { + title: 'Link 1', + url: 'http://some.test.link1', + }, + { + targetBlank: true, + title: 'Link 2', + url: 'http://some.test.link2', + }, + ]); + }); }); describe('Library panels', () => { diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts index 0b773c401b1..8639b905295 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts @@ -17,6 +17,7 @@ import { import { AnnotationQuery, Dashboard, + DashboardLink, DataTransformerConfig, defaultDashboard, defaultTimePickerConfig, @@ -37,6 +38,7 @@ import { LibraryVizPanel } from '../scene/LibraryVizPanel'; import { PanelRepeaterGridItem } from '../scene/PanelRepeaterGridItem'; import { PanelTimeRange } from '../scene/PanelTimeRange'; import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior'; +import { dashboardSceneGraph } from '../utils/dashboardSceneGraph'; import { getPanelIdForVizPanel } from '../utils/utils'; import { GRAFANA_DATASOURCE_REF } from './const'; @@ -223,6 +225,9 @@ export function gridItemToPanel(gridItem: SceneGridItemLike, isSnapshot = false) panel.repeatDirection = gridItem.getRepeatDirection(); } + const panelLinks = dashboardSceneGraph.getPanelLinks(vizPanel); + panel.links = (panelLinks.state.rawLinks as DashboardLink[]) ?? []; + return panel; } diff --git a/public/app/features/dashboard-scene/utils/dashboardSceneGraph.test.ts b/public/app/features/dashboard-scene/utils/dashboardSceneGraph.test.ts index 44f045df7a8..8b4c65ab284 100644 --- a/public/app/features/dashboard-scene/utils/dashboardSceneGraph.test.ts +++ b/public/app/features/dashboard-scene/utils/dashboardSceneGraph.test.ts @@ -11,8 +11,10 @@ import { import { DashboardControls } from '../scene/DashboardControls'; import { DashboardLinksControls } from '../scene/DashboardLinksControls'; import { DashboardScene, DashboardSceneState } from '../scene/DashboardScene'; +import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks'; import { dashboardSceneGraph } from './dashboardSceneGraph'; +import { findVizPanelByKey } from './utils'; describe('dashboardSceneGraph', () => { describe('getTimePicker', () => { @@ -75,6 +77,20 @@ describe('dashboardSceneGraph', () => { expect(dashboardControls).not.toBeNull(); }); }); + + describe('getPanelLinks', () => { + it('should throw if no links object defined', () => { + const scene = buildTestScene(); + const panelWithNoLinks = findVizPanelByKey(scene, 'panel-1')!; + expect(() => dashboardSceneGraph.getPanelLinks(panelWithNoLinks)).toThrow(); + }); + + it('should resolve VizPanelLinks object', () => { + const scene = buildTestScene(); + const panelWithNoLinks = findVizPanelByKey(scene, 'panel-with-links')!; + expect(dashboardSceneGraph.getPanelLinks(panelWithNoLinks)).toBeInstanceOf(VizPanelLinks); + }); + }); }); function buildTestScene(overrides?: Partial) { @@ -121,6 +137,15 @@ function buildTestScene(overrides?: Partial) { $data: new SceneQueryRunner({ key: 'data-query-runner2', queries: [{ refId: 'A' }] }), }), }), + new SceneGridItem({ + body: new VizPanel({ + title: 'Panel B', + key: 'panel-with-links', + pluginId: 'table', + $data: new SceneQueryRunner({ key: 'data-query-runner3', queries: [{ refId: 'A' }] }), + titleItems: [new VizPanelLinks({ menu: new VizPanelLinksMenu({}) })], + }), + }), ], }), ...overrides, diff --git a/public/app/features/dashboard-scene/utils/dashboardSceneGraph.ts b/public/app/features/dashboard-scene/utils/dashboardSceneGraph.ts index 666b764b001..641b21c7f3b 100644 --- a/public/app/features/dashboard-scene/utils/dashboardSceneGraph.ts +++ b/public/app/features/dashboard-scene/utils/dashboardSceneGraph.ts @@ -1,7 +1,8 @@ -import { SceneTimePicker, SceneRefreshPicker } from '@grafana/scenes'; +import { SceneTimePicker, SceneRefreshPicker, VizPanel } from '@grafana/scenes'; import { DashboardControls } from '../scene/DashboardControls'; import { DashboardScene } from '../scene/DashboardScene'; +import { VizPanelLinks } from '../scene/PanelLinks'; function getTimePicker(scene: DashboardScene) { const dashboardControls = getDashboardControls(scene); @@ -36,8 +37,21 @@ function getDashboardControls(scene: DashboardScene) { return null; } +function getPanelLinks(panel: VizPanel) { + if ( + panel.state.titleItems && + Array.isArray(panel.state.titleItems) && + panel.state.titleItems[0] instanceof VizPanelLinks + ) { + return panel.state.titleItems[0]; + } + + throw new Error('VizPanelLinks links not found'); +} + export const dashboardSceneGraph = { getTimePicker, getRefreshPicker, getDashboardControls, + getPanelLinks, }; diff --git a/public/app/features/dashboard-scene/utils/test-utils.ts b/public/app/features/dashboard-scene/utils/test-utils.ts index aa8a8afe29f..9e6b18d0f97 100644 --- a/public/app/features/dashboard-scene/utils/test-utils.ts +++ b/public/app/features/dashboard-scene/utils/test-utils.ts @@ -15,6 +15,7 @@ import { DashboardLoaderSrv, setDashboardLoaderSrv } from 'app/features/dashboar import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from 'app/features/variables/constants'; import { DashboardDTO } from 'app/types'; +import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks'; import { PanelRepeaterGridItem, RepeatDirection } from '../scene/PanelRepeaterGridItem'; import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior'; @@ -120,7 +121,11 @@ export function buildPanelRepeaterScene(options: SceneOptions) { y: 0, width: 10, height: 10, - body: new VizPanel({ title: 'Panel $server', pluginId: 'timeseries' }), + body: new VizPanel({ + title: 'Panel $server', + pluginId: 'timeseries', + titleItems: [new VizPanelLinks({ menu: new VizPanelLinksMenu({}) })], + }), }); const rowChildren = defaults.usePanelRepeater ? repeater : gridItem; diff --git a/public/app/features/dashboard/components/PanelEditor/getPanelFrameOptions.tsx b/public/app/features/dashboard/components/PanelEditor/getPanelFrameOptions.tsx index b093470a8d8..d24b39c46ab 100644 --- a/public/app/features/dashboard/components/PanelEditor/getPanelFrameOptions.tsx +++ b/public/app/features/dashboard/components/PanelEditor/getPanelFrameOptions.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { config } from '@grafana/runtime'; import { VizPanel } from '@grafana/scenes'; import { DataLinksInlineEditor, Input, RadioButtonGroup, Select, Switch, TextArea } from '@grafana/ui'; +import { dashboardSceneGraph } from 'app/features/dashboard-scene/utils/dashboardSceneGraph'; import { getPanelLinksVariableSuggestions } from 'app/features/panel/panellinks/link_srv'; import { GenAIPanelDescriptionButton } from '../GenAI/GenAIPanelDescriptionButton'; @@ -180,6 +181,9 @@ export function getPanelFrameCategory2(panel: VizPanel): OptionsPaneCategoryDesc isOpenDefault: true, }); + const panelLinksObject = dashboardSceneGraph.getPanelLinks(panel); + const links = panelLinksObject.state.rawLinks; + return descriptor .addItem( new OptionsPaneItemDescriptor({ @@ -234,29 +238,31 @@ export function getPanelFrameCategory2(panel: VizPanel): OptionsPaneCategoryDesc ); }, }) + ) + .addCategory( + new OptionsPaneCategoryDescriptor({ + title: 'Panel links', + id: 'Panel links', + isOpenDefault: false, + itemsCount: links?.length, + }).addItem( + new OptionsPaneItemDescriptor({ + title: 'Panel links', + render: function renderLinks() { + const { rawLinks: links } = panelLinksObject.useState(); + return ( + panelLinksObject.setState({ rawLinks: links })} + getSuggestions={getPanelLinksVariableSuggestions} + data={[]} + /> + ); + }, + }) + ) ); - // .addCategory( - // new OptionsPaneCategoryDescriptor({ - // title: 'Panel links', - // id: 'Panel links', - // isOpenDefault: false, - // itemsCount: panel.state.links?.length, - // }).addItem( - // new OptionsPaneItemDescriptor({ - // title: 'Panel links', - // render: function renderLinks() { - // return ( - // onPanelConfigChange('links', links)} - // getSuggestions={getPanelLinksVariableSuggestions} - // data={[]} - // /> - // ); - // }, - // }) - // ) - // ) + // // .addCategory( // new OptionsPaneCategoryDescriptor({ // title: 'Repeat options', diff --git a/public/app/features/panel/panellinks/linkSuppliers.ts b/public/app/features/panel/panellinks/linkSuppliers.ts index 0fa5cf47fb0..4924278d01f 100644 --- a/public/app/features/panel/panellinks/linkSuppliers.ts +++ b/public/app/features/panel/panellinks/linkSuppliers.ts @@ -11,7 +11,9 @@ import { ScopedVar, ScopedVars, } from '@grafana/data'; +import { VizPanel } from '@grafana/scenes'; import { PanelModel } from 'app/features/dashboard/state/PanelModel'; +import { dashboardSceneGraph } from 'app/features/dashboard-scene/utils/dashboardSceneGraph'; import { getLinkSrv } from './link_srv'; @@ -157,3 +159,22 @@ export const getPanelLinksSupplier = ( }, }; }; + +export const getScenePanelLinksSupplier = ( + panel: VizPanel, + replaceVariables: InterpolateFunction +): LinkModelSupplier | undefined => { + const links = dashboardSceneGraph.getPanelLinks(panel).state.rawLinks; + + if (!links || links.length === 0) { + return undefined; + } + + return { + getLinks: () => { + return links.map((link) => { + return getLinkSrv().getDataLinkUIModel(link, replaceVariables, panel); + }); + }, + }; +};