diff --git a/public/app/features/dashboard-scene/sharing/ExportButton/ExportAsCode.tsx b/public/app/features/dashboard-scene/sharing/ExportButton/ExportAsCode.tsx index e6db41d98a3..aabd9707377 100644 --- a/public/app/features/dashboard-scene/sharing/ExportButton/ExportAsCode.tsx +++ b/public/app/features/dashboard-scene/sharing/ExportButton/ExportAsCode.tsx @@ -13,7 +13,6 @@ import { notifyApp } from 'app/core/actions'; import { createSuccessNotification } from 'app/core/copy/appNotification'; import { dispatch } from 'app/store/store'; -import { DashboardInteractions } from '../../utils/interactions'; import { ShareExportTab } from '../ShareExportTab'; import { ExportMode, ResourceExport } from './ResourceExport'; @@ -120,9 +119,7 @@ function ExportAsCodeRenderer({ model }: SceneComponentProps) { icon="copy" disabled={dashboardJson.loading} getText={() => stringifiedDashboard ?? ''} - onClipboardCopy={() => { - DashboardInteractions.exportCopyJsonClicked(); - }} + onClipboardCopy={model.onClipboardCopy} > Copy to clipboard diff --git a/public/app/features/dashboard-scene/sharing/ShareExportTab.tsx b/public/app/features/dashboard-scene/sharing/ShareExportTab.tsx index 88f4df0beb6..1c19882b877 100644 --- a/public/app/features/dashboard-scene/sharing/ShareExportTab.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareExportTab.tsx @@ -13,7 +13,7 @@ import { Button, ClipboardButton, CodeEditor, Field, Modal, Stack, Switch } from import { ObjectMeta } from 'app/features/apiserver/types'; import { transformDashboardV2SpecToV1 } from 'app/features/dashboard/api/ResponseTransformers'; import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types'; -import { isDashboardV2Spec } from 'app/features/dashboard/api/utils'; +import { isDashboardV2Spec, isV1ClassicDashboard } from 'app/features/dashboard/api/utils'; import { K8S_V1_DASHBOARD_API_CONFIG } from 'app/features/dashboard/api/v1'; import { K8S_V2_DASHBOARD_API_CONFIG } from 'app/features/dashboard/api/v2'; import { shareDashboardType } from 'app/features/dashboard/components/ShareModal/utils'; @@ -27,7 +27,7 @@ import { transformSceneToSaveModel } from '../serialization/transformSceneToSave import { transformSceneToSaveModelSchemaV2 } from '../serialization/transformSceneToSaveModelSchemaV2'; import { getVariablesCompatibility } from '../utils/getVariablesCompatibility'; import { DashboardInteractions } from '../utils/interactions'; -import { getDashboardSceneFor } from '../utils/utils'; +import { getDashboardSceneFor, hasLibraryPanelsInV1Dashboard } from '../utils/utils'; import { ExportMode, ResourceExport } from './ExportButton/ResourceExport'; import { SceneShareTabState, ShareView } from './types'; @@ -171,7 +171,7 @@ export class ShareExportTab extends SceneObjectBase impleme status: {}, }, initialSaveModelVersion, - hasLibraryPanels: undefined, + hasLibraryPanels: hasLibraryPanelsInV1Dashboard(spec1), }; } catch (err) { return { @@ -195,7 +195,7 @@ export class ShareExportTab extends SceneObjectBase impleme status: {}, }, initialSaveModelVersion, - hasLibraryPanels: undefined, + hasLibraryPanels: hasLibraryPanelsInV1Dashboard(spec), }; } } @@ -216,6 +216,10 @@ export class ShareExportTab extends SceneObjectBase impleme status: {}, }, initialSaveModelVersion, + hasLibraryPanels: + initialSaveModelVersion === 'v1' && !isDashboardV2Spec(origDashboard) + ? hasLibraryPanelsInV1Dashboard(origDashboard) + : false, }; } @@ -238,15 +242,19 @@ export class ShareExportTab extends SceneObjectBase impleme const exportableV1 = isSharingExternally ? await makeExportableV1(oldModel) : initialSaveModel; return { json: exportableV1, - hasLibraryPanels: undefined, + hasLibraryPanels: hasLibraryPanelsInV1Dashboard(initialSaveModel), initialSaveModelVersion, }; } // legacy mode or classic mode when dashboardNewLayouts is disabled + // At this point we know that dashboard should be V1 or could have produced an error return { json: exportable, - hasLibraryPanels: undefined, + hasLibraryPanels: + 'error' in exportable || !isV1ClassicDashboard(origDashboard) + ? false + : hasLibraryPanelsInV1Dashboard(origDashboard), initialSaveModelVersion, }; }; @@ -267,8 +275,27 @@ export class ShareExportTab extends SceneObjectBase impleme } const extension = isViewingYAML ? 'yaml' : 'json'; saveAs(blob, `${title}-${time}.${extension}`); + DashboardInteractions.exportDownloadJsonClicked({ externally: isSharingExternally, + dashboard_schema_version: dashboard.initialSaveModelVersion, + has_library_panels: Boolean(dashboard.hasLibraryPanels), + format: isViewingYAML ? 'yaml' : 'json', + action: 'download', + }); + }; + + public onClipboardCopy = async () => { + const dashboard = await this.getExportableDashboardJson(); + const { isSharingExternally, isViewingYAML, exportMode } = this.state; + + DashboardInteractions.exportCopyJsonClicked({ + externally: isSharingExternally, + dashboard_schema_version: dashboard.initialSaveModelVersion, + has_library_panels: Boolean(dashboard.hasLibraryPanels), + export_mode: exportMode || 'classic', + format: isViewingYAML ? 'yaml' : 'json', + action: 'copy', }); }; } @@ -428,6 +455,7 @@ function ShareExportTabRenderer({ model }: SceneComponentProps) icon="copy" disabled={dashboardJson.loading} getText={() => stringifiedDashboard ?? ''} + onClipboardCopy={model.onClipboardCopy} > Copy to Clipboard diff --git a/public/app/features/dashboard-scene/utils/utils.test.ts b/public/app/features/dashboard-scene/utils/utils.test.ts new file mode 100644 index 00000000000..a80b385ddf5 --- /dev/null +++ b/public/app/features/dashboard-scene/utils/utils.test.ts @@ -0,0 +1,372 @@ +import { Dashboard, Panel, RowPanel } from '@grafana/schema'; + +import { isValidLibraryPanelRef, hasLibraryPanelsInV1Dashboard } from './utils'; + +describe('utils', () => { + describe('isValidLibraryPanelRef', () => { + it('should return true for valid library panel reference', () => { + const panel: Panel = { + id: 1, + title: 'Test Panel', + type: 'graph', + gridPos: { x: 0, y: 0, w: 12, h: 8 }, + libraryPanel: { + uid: 'lib-panel-uid', + name: 'Library Panel Name', + }, + }; + + expect(isValidLibraryPanelRef(panel)).toBe(true); + }); + + it('should return false for panel without libraryPanel property', () => { + const panel: Panel = { + id: 1, + title: 'Test Panel', + type: 'graph', + gridPos: { x: 0, y: 0, w: 12, h: 8 }, + }; + + expect(isValidLibraryPanelRef(panel)).toBe(false); + }); + + it('should return false for panel with libraryPanel but missing uid', () => { + const panel: Panel = { + id: 1, + title: 'Test Panel', + type: 'graph', + gridPos: { x: 0, y: 0, w: 12, h: 8 }, + // @ts-expect-error - Testing invalid library panel ref without uid + libraryPanel: { + name: 'Library Panel Name', + }, + }; + + expect(isValidLibraryPanelRef(panel)).toBe(false); + }); + + it('should return false for panel with libraryPanel but missing name', () => { + const panel: Panel = { + id: 1, + title: 'Test Panel', + type: 'graph', + gridPos: { x: 0, y: 0, w: 12, h: 8 }, + // @ts-expect-error - Testing invalid library panel ref without name + libraryPanel: { + uid: 'lib-panel-uid', + }, + }; + + expect(isValidLibraryPanelRef(panel)).toBe(false); + }); + + it('should return false for panel with libraryPanel but empty uid', () => { + const panel: Panel = { + id: 1, + title: 'Test Panel', + type: 'graph', + gridPos: { x: 0, y: 0, w: 12, h: 8 }, + libraryPanel: { + uid: '', + name: 'Library Panel Name', + }, + }; + + expect(isValidLibraryPanelRef(panel)).toBe(false); + }); + + it('should return false for panel with libraryPanel but empty name', () => { + const panel: Panel = { + id: 1, + title: 'Test Panel', + type: 'graph', + gridPos: { x: 0, y: 0, w: 12, h: 8 }, + libraryPanel: { + uid: 'lib-panel-uid', + name: '', + }, + }; + + expect(isValidLibraryPanelRef(panel)).toBe(false); + }); + + it('should return false for panel with null libraryPanel', () => { + const panel: Panel = { + id: 1, + title: 'Test Panel', + type: 'graph', + gridPos: { x: 0, y: 0, w: 12, h: 8 }, + // @ts-expect-error - Testing invalid library panel ref + libraryPanel: null, + }; + + expect(isValidLibraryPanelRef(panel)).toBe(false); + }); + }); + + describe('hasLibraryPanelsInV1Dashboard', () => { + it('should return false for undefined dashboard', () => { + expect(hasLibraryPanelsInV1Dashboard(undefined)).toBe(false); + }); + + it('should return false for dashboard without panels', () => { + const dashboard: Dashboard = { + id: 1, + title: 'Test Dashboard', + tags: [], + timezone: 'browser', + panels: [], + time: { from: 'now-6h', to: 'now' }, + timepicker: {}, + templating: { list: [] }, + annotations: { list: [] }, + refresh: '', + schemaVersion: 30, + version: 1, + links: [], + }; + + expect(hasLibraryPanelsInV1Dashboard(dashboard)).toBe(false); + }); + + it('should return false for dashboard with no library panels', () => { + const dashboard: Dashboard = { + id: 1, + title: 'Test Dashboard', + tags: [], + timezone: 'browser', + panels: [ + { + id: 1, + title: 'Regular Panel', + type: 'graph', + gridPos: { x: 0, y: 0, w: 12, h: 8 }, + }, + ], + time: { from: 'now-6h', to: 'now' }, + timepicker: {}, + templating: { list: [] }, + annotations: { list: [] }, + refresh: '', + schemaVersion: 30, + version: 1, + links: [], + }; + + expect(hasLibraryPanelsInV1Dashboard(dashboard)).toBe(false); + }); + + it('should return true for dashboard with library panels', () => { + const dashboard: Dashboard = { + id: 1, + title: 'Test Dashboard', + tags: [], + timezone: 'browser', + panels: [ + { + id: 1, + title: 'Library Panel', + type: 'graph', + gridPos: { x: 0, y: 0, w: 12, h: 8 }, + libraryPanel: { + uid: 'lib-panel-uid', + name: 'Library Panel Name', + }, + }, + ], + time: { from: 'now-6h', to: 'now' }, + timepicker: {}, + templating: { list: [] }, + annotations: { list: [] }, + refresh: '', + schemaVersion: 30, + version: 1, + links: [], + }; + + expect(hasLibraryPanelsInV1Dashboard(dashboard)).toBe(true); + }); + + it('should return true for dashboard with library panels in collapsed row', () => { + const collapsedRowPanel: RowPanel = { + id: 1, + title: 'Row Panel', + type: 'row', + gridPos: { x: 0, y: 0, w: 24, h: 1 }, + collapsed: true, + panels: [ + { + id: 2, + title: 'Library Panel in Row', + type: 'graph', + gridPos: { x: 0, y: 1, w: 12, h: 8 }, + libraryPanel: { + uid: 'lib-panel-uid', + name: 'Library Panel Name', + }, + }, + ], + }; + + const dashboard: Dashboard = { + id: 1, + title: 'Test Dashboard', + tags: [], + timezone: 'browser', + panels: [collapsedRowPanel], + time: { from: 'now-6h', to: 'now' }, + timepicker: {}, + templating: { list: [] }, + annotations: { list: [] }, + refresh: '', + schemaVersion: 30, + version: 1, + links: [], + }; + + expect(hasLibraryPanelsInV1Dashboard(dashboard)).toBe(true); + }); + + it('should return false for dashboard with collapsed row but no library panels', () => { + const collapsedRowPanel: RowPanel = { + id: 1, + title: 'Row Panel', + type: 'row', + gridPos: { x: 0, y: 0, w: 24, h: 1 }, + collapsed: true, + panels: [ + { + id: 2, + title: 'Regular Panel in Row', + type: 'graph', + gridPos: { x: 0, y: 1, w: 12, h: 8 }, + }, + ], + }; + + const dashboard: Dashboard = { + id: 1, + title: 'Test Dashboard', + tags: [], + timezone: 'browser', + panels: [collapsedRowPanel], + time: { from: 'now-6h', to: 'now' }, + timepicker: {}, + templating: { list: [] }, + annotations: { list: [] }, + refresh: '', + schemaVersion: 30, + version: 1, + links: [], + }; + + expect(hasLibraryPanelsInV1Dashboard(dashboard)).toBe(false); + }); + + it('should return false for dashboard with expanded row (not collapsed)', () => { + const expandedRowPanel: RowPanel = { + id: 1, + title: 'Row Panel', + type: 'row', + gridPos: { x: 0, y: 0, w: 24, h: 1 }, + collapsed: false, + panels: [ + { + id: 2, + title: 'Library Panel in Row', + type: 'graph', + gridPos: { x: 0, y: 1, w: 12, h: 8 }, + libraryPanel: { + uid: 'lib-panel-uid', + name: 'Library Panel Name', + }, + }, + ], + }; + + const dashboard: Dashboard = { + id: 1, + title: 'Test Dashboard', + tags: [], + timezone: 'browser', + panels: [expandedRowPanel], + time: { from: 'now-6h', to: 'now' }, + timepicker: {}, + templating: { list: [] }, + annotations: { list: [] }, + refresh: '', + schemaVersion: 30, + version: 1, + links: [], + }; + + expect(hasLibraryPanelsInV1Dashboard(dashboard)).toBe(false); + }); + + it('should return false for dashboard with row panel without panels property', () => { + //@ts-expect-error - Testing invalid row panel + const rowPanel: RowPanel = { + id: 1, + title: 'Row Panel', + type: 'row', + gridPos: { x: 0, y: 0, w: 24, h: 1 }, + collapsed: true, + }; + + const dashboard: Dashboard = { + id: 1, + title: 'Test Dashboard', + tags: [], + timezone: 'browser', + panels: [rowPanel], + time: { from: 'now-6h', to: 'now' }, + timepicker: {}, + templating: { list: [] }, + annotations: { list: [] }, + refresh: '', + schemaVersion: 30, + version: 1, + links: [], + }; + + expect(hasLibraryPanelsInV1Dashboard(dashboard)).toBe(false); + }); + + it('should return true for dashboard with mixed panels and library panels', () => { + const dashboard: Dashboard = { + id: 1, + title: 'Test Dashboard', + tags: [], + timezone: 'browser', + panels: [ + { + id: 1, + title: 'Regular Panel', + type: 'graph', + gridPos: { x: 0, y: 0, w: 12, h: 8 }, + }, + { + id: 2, + title: 'Library Panel', + type: 'graph', + gridPos: { x: 12, y: 0, w: 12, h: 8 }, + libraryPanel: { + uid: 'lib-panel-uid', + name: 'Library Panel Name', + }, + }, + ], + time: { from: 'now-6h', to: 'now' }, + timepicker: {}, + templating: { list: [] }, + annotations: { list: [] }, + refresh: '', + schemaVersion: 30, + version: 1, + links: [], + }; + + expect(hasLibraryPanelsInV1Dashboard(dashboard)).toBe(true); + }); + }); +}); diff --git a/public/app/features/dashboard-scene/utils/utils.ts b/public/app/features/dashboard-scene/utils/utils.ts index 84880a788b7..7a5c41a08cd 100644 --- a/public/app/features/dashboard-scene/utils/utils.ts +++ b/public/app/features/dashboard-scene/utils/utils.ts @@ -13,6 +13,7 @@ import { VizPanel, VizPanelMenu, } from '@grafana/scenes'; +import { Dashboard, Panel, RowPanel } from '@grafana/schema'; import { createLogger } from '@grafana/ui'; import { initialIntervalVariableModelState } from 'app/features/variables/interval/reducer'; @@ -32,6 +33,11 @@ import { containsCloneKey, getLastKeyFromClone, getOriginalKey, isInCloneChain } export const NEW_PANEL_HEIGHT = 8; export const NEW_PANEL_WIDTH = 12; +const V1_PANEL_PROPERTIES = { + LIBRARY_PANEL: 'libraryPanel', + COLLAPSED: 'collapsed', +} as const; + export function getVizPanelKeyForPanelId(panelId: number) { return `panel-${panelId}`; } @@ -470,4 +476,42 @@ export function getLayoutOrchestratorFor(scene: SceneObject): DashboardLayoutOrc return getDashboardSceneFor(scene).state.layoutOrchestrator; } +// @returns true if the panel is a valid library panel reference +// a valid library panel reference is a panel with this +// property: `libraryPanel: {name: string, uid: string}` + +export function isValidLibraryPanelRef(panel: Panel): boolean { + return ( + (V1_PANEL_PROPERTIES.LIBRARY_PANEL in panel && + panel.libraryPanel && + Boolean(panel.libraryPanel?.uid && panel.libraryPanel?.name)) || + false + ); +} + +/** + * Checks if a V1 dashboard contains library panels + * @returns true if the dashboard contains library panels + */ +export function hasLibraryPanelsInV1Dashboard(dashboard: Dashboard | undefined): boolean { + if (!dashboard?.panels) { + return false; + } + + return dashboard.panels.some((panel: Panel | RowPanel) => { + if (isValidLibraryPanelRef(panel)) { + return true; + } + // Check if this is a collapsed row containing library panels + const isCollapsedRow = + V1_PANEL_PROPERTIES.COLLAPSED in panel && panel.collapsed && 'panels' in panel && panel.panels; + + if (!isCollapsedRow) { + return false; + } + + return panel.panels.some(isValidLibraryPanelRef); + }); +} + export const dashboardLog = createLogger('Dashboard'); diff --git a/public/app/features/dashboard/api/utils.ts b/public/app/features/dashboard/api/utils.ts index ea9ba97cf57..d027960bce7 100644 --- a/public/app/features/dashboard/api/utils.ts +++ b/public/app/features/dashboard/api/utils.ts @@ -74,6 +74,10 @@ export function isV1DashboardCommand( return !isDashboardV2Spec(cmd.dashboard); } +export function isV1ClassicDashboard(obj: Dashboard | DashboardV2Spec): obj is Dashboard { + return !isDashboardV2Spec(obj); +} + export function isV2DashboardCommand( cmd: SaveDashboardCommand ): cmd is SaveDashboardCommand {