From 6ff3af7e831d919100d0e1442158a91ca7acce6c Mon Sep 17 00:00:00 2001 From: Oscar Kilhed Date: Wed, 2 Apr 2025 14:35:51 +0200 Subject: [PATCH] Dynamic dashboards: Copy paste rows tabs and auto grid items (#103237) * copy paste tab row works * refactor to hook * add buttons to canvas * make i18n * remove paste from add pane and refactor * pasting auto grid panel works * add paste for default grid * set height/width and postion for auto grid items moving to default grid * clean up --- public/app/core/constants.ts | 3 +- .../edit-pane/DashboardAddPane.tsx | 20 --- .../dashboard-scene/scene/DashboardScene.tsx | 31 ++++- .../DefaultGridLayoutManager.tsx | 10 ++ .../ResponsiveGridLayoutManager.tsx | 8 ++ .../scene/layout-rows/RowItem.tsx | 19 ++- .../scene/layout-rows/RowsLayoutManager.tsx | 18 ++- .../layout-rows/RowsLayoutManagerRenderer.tsx | 7 ++ .../scene/layout-tabs/TabItem.tsx | 18 ++- .../scene/layout-tabs/TabsLayoutManager.tsx | 12 +- .../layout-tabs/TabsLayoutManagerRenderer.tsx | 7 ++ .../layouts-shared/CanvasGridAddActions.tsx | 14 +++ .../scene/layouts-shared/paste.ts | 116 ++++++++++++++++++ .../scene/layouts-shared/useClipboardState.ts | 36 ++++++ .../scene/types/DashboardLayoutManager.ts | 5 + .../DefaultGridLayoutSerializer.ts | 75 +++++++---- .../ResponsiveGridLayoutSerializer.ts | 101 ++++++++------- .../layoutSerializers/RowsLayoutSerializer.ts | 108 ++++++++-------- .../layoutSerializers/TabsLayoutSerializer.ts | 48 +++++--- .../layoutSerializerRegistry.ts | 3 +- .../serialization/layoutSerializers/utils.ts | 28 ++++- .../transformSceneToSaveModelSchemaV2.ts | 2 +- public/locales/en-US/grafana.json | 9 +- 23 files changed, 518 insertions(+), 180 deletions(-) create mode 100644 public/app/features/dashboard-scene/scene/layouts-shared/paste.ts create mode 100644 public/app/features/dashboard-scene/scene/layouts-shared/useClipboardState.ts diff --git a/public/app/core/constants.ts b/public/app/core/constants.ts index 1de5d88801b..2ae7e63f16a 100644 --- a/public/app/core/constants.ts +++ b/public/app/core/constants.ts @@ -9,7 +9,8 @@ export const DEFAULT_ROW_HEIGHT = 250; export const MIN_PANEL_HEIGHT = GRID_CELL_HEIGHT * 3; export const LS_PANEL_COPY_KEY = 'panel-copy'; - +export const LS_ROW_COPY_KEY = 'row-copy'; +export const LS_TAB_COPY_KEY = 'tab-copy'; export const PANEL_BORDER = 2; export const EDIT_PANEL_ID = 23763571993; diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardAddPane.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardAddPane.tsx index e2392e8af0b..1edf168e6de 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardAddPane.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardAddPane.tsx @@ -1,12 +1,9 @@ import { css } from '@emotion/css'; -import { useEffect, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { Box, Card, Icon, IconButton, IconName, useStyles2 } from '@grafana/ui'; -import { LS_PANEL_COPY_KEY } from 'app/core/constants'; import { t } from 'app/core/internationalization'; -import store from 'app/core/store'; import { DashboardInteractions } from '../utils/interactions'; import { getDashboardSceneFor } from '../utils/utils'; @@ -29,15 +26,6 @@ interface CardConfig { export function DashboardAddPane({ editPane }: Props) { const styles = useStyles2(getStyles); const dashboard = getDashboardSceneFor(editPane); - const [hasCopiedPanel, setHasCopiedPanel] = useState(store.exists(LS_PANEL_COPY_KEY)); - - useEffect(() => { - const unsubscribe = store.subscribe(LS_PANEL_COPY_KEY, () => { - setHasCopiedPanel(store.exists(LS_PANEL_COPY_KEY)); - }); - - return () => unsubscribe(); - }, []); const cards: CardConfig[] = [ { @@ -74,14 +62,6 @@ export function DashboardAddPane({ editPane }: Props) { testId: selectors.components.PageToolbar.itemButton('add_tab'), onClick: () => dashboard.onCreateNewTab(), }, - { - hide: !hasCopiedPanel, - icon: 'clipboard-alt', - heading: t('dashboard.edit-pane.add.paste-panel.heading', 'Paste panel'), - title: t('dashboard.edit-pane.add.paste-panel.title', 'Paste a panel from the clipboard'), - testId: selectors.components.PageToolbar.itemButton('paste_panel'), - onClick: () => dashboard.pastePanel(), - }, ]; return ( diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index 023e026ffe8..b18b882eefe 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -1,6 +1,6 @@ import * as H from 'history'; -import { AppEvents, CoreApp, DataQueryRequest, NavIndex, NavModelItem, locationUtil } from '@grafana/data'; +import { CoreApp, DataQueryRequest, NavIndex, NavModelItem, locationUtil } from '@grafana/data'; import { config, locationService, RefreshEvent } from '@grafana/runtime'; import { sceneGraph, @@ -40,6 +40,9 @@ import { DashboardSceneChangeTracker } from '../saving/DashboardSceneChangeTrack import { SaveDashboardDrawer } from '../saving/SaveDashboardDrawer'; import { DashboardChangeInfo } from '../saving/shared'; import { DashboardSceneSerializerLike, getDashboardSceneSerializer } from '../serialization/DashboardSceneSerializer'; +import { gridItemToGridLayoutItemKind } from '../serialization/layoutSerializers/DefaultGridLayoutSerializer'; +import { serializeAutoGridItem } from '../serialization/layoutSerializers/ResponsiveGridLayoutSerializer'; +import { getElement } from '../serialization/layoutSerializers/utils'; import { buildGridItemForPanel, transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; import { gridItemToPanel } from '../serialization/transformSceneToSaveModel'; import { DecoratedRevisionModel } from '../settings/VersionsEditView'; @@ -71,8 +74,10 @@ import { isUsingAngularDatasourcePlugin, isUsingAngularPanelPlugin } from './ang import { setupKeyboardShortcuts } from './keyboardShortcuts'; import { DashboardGridItem } from './layout-default/DashboardGridItem'; import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager'; +import { AutoGridItem } from './layout-responsive-grid/ResponsiveGridItem'; import { LayoutRestorer } from './layouts-shared/LayoutRestorer'; import { addNewRowTo, addNewTabTo } from './layouts-shared/addNew'; +import { clearClipboard } from './layouts-shared/paste'; import { DashboardLayoutManager } from './types/DashboardLayoutManager'; import { isLayoutParent, LayoutParent } from './types/LayoutParent'; @@ -523,6 +528,28 @@ export class DashboardScene extends SceneObjectBase impleme } public copyPanel(vizPanel: VizPanel) { + if (config.featureToggles.dashboardNewLayouts) { + const gridItem = vizPanel.parent; + + if (gridItem instanceof AutoGridItem) { + const elements = getElement(gridItem, this); + const gridItemKind = serializeAutoGridItem(gridItem); + + clearClipboard(); + store.set(LS_PANEL_COPY_KEY, JSON.stringify({ elements, gridItem: gridItemKind })); + } else if (gridItem instanceof DashboardGridItem) { + const elements = getElement(gridItem, this); + const gridItemKind = gridItemToGridLayoutItemKind(gridItem); + + clearClipboard(); + store.set(LS_PANEL_COPY_KEY, JSON.stringify({ elements, gridItem: gridItemKind })); + } else { + console.error('Trying to copy a panel that is not DashboardGridItem child'); + throw new Error('Trying to copy a panel that is not DashboardGridItem child'); + } + return; + } + if (!vizPanel.parent) { return; } @@ -536,8 +563,8 @@ export class DashboardScene extends SceneObjectBase impleme const jsonData = gridItemToPanel(gridItem); + clearClipboard(); store.set(LS_PANEL_COPY_KEY, JSON.stringify(jsonData)); - appEvents.emit(AppEvents.alertSuccess, ['Panel copied. Use **Paste panel** toolbar action to paste.']); } public pastePanel() { diff --git a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx index 966c5d15b63..b45ecabe58b 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx @@ -38,8 +38,10 @@ import { getGridItemKeyForPanelId, useDashboard, getLayoutOrchestratorFor, + getDashboardSceneFor, } from '../../utils/utils'; import { CanvasGridAddActions } from '../layouts-shared/CanvasGridAddActions'; +import { clearClipboard, getDashboardGridItemFromClipboard } from '../layouts-shared/paste'; import { DashboardLayoutManager } from '../types/DashboardLayoutManager'; import { LayoutRegistryItem } from '../types/LayoutRegistryItem'; @@ -134,6 +136,14 @@ export class DefaultGridLayoutManager this.publishEvent(new NewObjectAddedToCanvasEvent(vizPanel), true); } + public pastePanel() { + const emptySpace = findSpaceForNewPanel(this.state.grid); + const panel = getDashboardGridItemFromClipboard(getDashboardSceneFor(this), emptySpace); + this.state.grid.setState({ children: [...this.state.grid.state.children, panel] }); + this.publishEvent(new NewObjectAddedToCanvasEvent(panel), true); + clearClipboard(); + } + public removePanel(panel: VizPanel) { const gridItem = panel.parent!; diff --git a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.tsx index 57dc386d75a..5b8ec553396 100644 --- a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.tsx @@ -15,6 +15,7 @@ import { getPanelIdForVizPanel, getVizPanelKeyForPanelId, } from '../../utils/utils'; +import { clearClipboard, getAutoGridItemFromClipboard } from '../layouts-shared/paste'; import { DashboardLayoutManager } from '../types/DashboardLayoutManager'; import { LayoutRegistryItem } from '../types/LayoutRegistryItem'; @@ -98,6 +99,13 @@ export class AutoGridLayoutManager this.publishEvent(new NewObjectAddedToCanvasEvent(vizPanel), true); } + public pastePanel() { + const panel = getAutoGridItemFromClipboard(getDashboardSceneFor(this)); + this.state.layout.setState({ children: [...this.state.layout.state.children, panel] }); + this.publishEvent(new NewObjectAddedToCanvasEvent(panel), true); + clearClipboard(); + } + public removePanel(panel: VizPanel) { const element = panel.parent; this.state.layout.setState({ children: this.state.layout.state.children.filter((child) => child !== element) }); diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx index de70e459535..ff155d2bfe6 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx @@ -8,14 +8,20 @@ import { VariableDependencyConfig, VizPanel, } from '@grafana/scenes'; +import { RowsLayoutRowKind } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0'; +import { LS_ROW_COPY_KEY } from 'app/core/constants'; import { t } from 'app/core/internationalization'; +import store from 'app/core/store'; import kbn from 'app/core/utils/kbn'; import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; import { ConditionalRendering } from '../../conditional-rendering/ConditionalRendering'; -import { getDefaultVizPanel } from '../../utils/utils'; +import { serializeRow } from '../../serialization/layoutSerializers/RowsLayoutSerializer'; +import { getElements } from '../../serialization/layoutSerializers/utils'; +import { getDashboardSceneFor, getDefaultVizPanel } from '../../utils/utils'; import { AutoGridLayoutManager } from '../layout-responsive-grid/ResponsiveGridLayoutManager'; import { LayoutRestorer } from '../layouts-shared/LayoutRestorer'; +import { clearClipboard } from '../layouts-shared/paste'; import { scrollCanvasElementIntoView } from '../layouts-shared/scrollCanvasElementIntoView'; import { BulkActionElement } from '../types/BulkActionElement'; import { DashboardDropTarget } from '../types/DashboardDropTarget'; @@ -116,6 +122,17 @@ export class RowItem return this.clone({ key: undefined, layout: this.getLayout().duplicate() }); } + public serialize(): RowsLayoutRowKind { + return serializeRow(this); + } + + public onCopy() { + const elements = getElements(this.getLayout(), getDashboardSceneFor(this)); + + clearClipboard(); + store.set(LS_ROW_COPY_KEY, JSON.stringify({ elements, row: this.serialize() })); + } + public onAddPanel(panel = getDefaultVizPanel()) { this.getLayout().addPanel(panel); } diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx index 15a7ac95834..e3459375e32 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx @@ -10,10 +10,12 @@ import { import { serializeRowsLayout } from '../../serialization/layoutSerializers/RowsLayoutSerializer'; import { isClonedKey } from '../../utils/clone'; import { dashboardSceneGraph } from '../../utils/dashboardSceneGraph'; +import { getDashboardSceneFor } from '../../utils/utils'; import { DashboardGridItem } from '../layout-default/DashboardGridItem'; import { DefaultGridLayoutManager } from '../layout-default/DefaultGridLayoutManager'; import { RowRepeaterBehavior } from '../layout-default/RowRepeaterBehavior'; import { TabsLayoutManager } from '../layout-tabs/TabsLayoutManager'; +import { getRowFromClipboard } from '../layouts-shared/paste'; import { DashboardLayoutManager } from '../types/DashboardLayoutManager'; import { LayoutRegistryItem } from '../types/LayoutRegistryItem'; @@ -91,17 +93,23 @@ export class RowsLayoutManager extends SceneObjectBase i this.publishEvent(new NewObjectAddedToCanvasEvent(newRow), true); } - public addNewRow(): RowItem { - const row = new RowItem({ isNew: true }); - this.setState({ rows: [...this.state.rows, row] }); - this.publishEvent(new NewObjectAddedToCanvasEvent(row), true); - return row; + public addNewRow(row?: RowItem): RowItem { + const newRow = row ?? new RowItem({ isNew: true }); + this.setState({ rows: [...this.state.rows, newRow] }); + this.publishEvent(new NewObjectAddedToCanvasEvent(newRow), true); + return newRow; } public editModeChanged(isEditing: boolean) { this.state.rows.forEach((row) => row.getLayout().editModeChanged?.(isEditing)); } + public pasteRow() { + const scene = getDashboardSceneFor(this); + const row = getRowFromClipboard(scene); + this.addNewRow(row); + } + public activateRepeaters() { this.state.rows.forEach((row) => { if (!row.isActive) { diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManagerRenderer.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManagerRenderer.tsx index 488a3ad8dad..e1a6f1f3900 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManagerRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManagerRenderer.tsx @@ -6,6 +6,7 @@ import { Button, useStyles2 } from '@grafana/ui'; import { Trans } from 'app/core/internationalization'; import { useDashboardState } from '../../utils/utils'; +import { useClipboardState } from '../layouts-shared/useClipboardState'; import { RowsLayoutManager } from './RowsLayoutManager'; @@ -13,6 +14,7 @@ export function RowLayoutManagerRenderer({ model }: SceneComponentProps @@ -24,6 +26,11 @@ export function RowLayoutManagerRenderer({ model }: SceneComponentProps model.addNewRow()}> New row + {hasCopiedRow && ( + + )} )} diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx index 0403ecd7a3a..88037e9655d 100644 --- a/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx @@ -8,13 +8,19 @@ import { SceneObject, VizPanel, } from '@grafana/scenes'; +import { TabsLayoutTabKind } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0'; +import { LS_TAB_COPY_KEY } from 'app/core/constants'; import { t } from 'app/core/internationalization'; +import store from 'app/core/store'; import kbn from 'app/core/utils/kbn'; import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; -import { getDefaultVizPanel } from '../../utils/utils'; +import { serializeTab } from '../../serialization/layoutSerializers/TabsLayoutSerializer'; +import { getElements } from '../../serialization/layoutSerializers/utils'; +import { getDashboardSceneFor, getDefaultVizPanel } from '../../utils/utils'; import { AutoGridLayoutManager } from '../layout-responsive-grid/ResponsiveGridLayoutManager'; import { LayoutRestorer } from '../layouts-shared/LayoutRestorer'; +import { clearClipboard } from '../layouts-shared/paste'; import { scrollCanvasElementIntoView } from '../layouts-shared/scrollCanvasElementIntoView'; import { BulkActionElement } from '../types/BulkActionElement'; import { DashboardDropTarget } from '../types/DashboardDropTarget'; @@ -90,6 +96,16 @@ export class TabItem layout.removeTab(this); } + public serialize(): TabsLayoutTabKind { + return serializeTab(this); + } + + public onCopy() { + const elements = getElements(this.getLayout(), getDashboardSceneFor(this)); + clearClipboard(); + store.set(LS_TAB_COPY_KEY, JSON.stringify({ elements, tab: this.serialize() })); + } + public createMultiSelectedElement(items: SceneObject[]): TabItems { return new TabItems(items.filter((item) => item instanceof TabItem)); } diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx index 5707379910d..d4317f97b83 100644 --- a/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx @@ -14,8 +14,10 @@ import { ObjectsReorderedOnCanvasEvent, } from '../../edit-pane/shared'; import { serializeTabsLayout } from '../../serialization/layoutSerializers/TabsLayoutSerializer'; +import { getDashboardSceneFor } from '../../utils/utils'; import { RowItem } from '../layout-rows/RowItem'; import { RowsLayoutManager } from '../layout-rows/RowsLayoutManager'; +import { getTabFromClipboard } from '../layouts-shared/paste'; import { DashboardLayoutManager } from '../types/DashboardLayoutManager'; import { LayoutRegistryItem } from '../types/LayoutRegistryItem'; @@ -121,8 +123,8 @@ export class TabsLayoutManager extends SceneObjectBase i throw new Error('Method not implemented.'); } - public addNewTab() { - const newTab = new TabItem({ isNew: true }); + public addNewTab(tab?: TabItem) { + const newTab = tab ?? new TabItem({ isNew: true }); this.setState({ tabs: [...this.state.tabs, newTab], currentTabIndex: this.state.tabs.length }); this.publishEvent(new NewObjectAddedToCanvasEvent(newTab), true); return newTab; @@ -132,6 +134,12 @@ export class TabsLayoutManager extends SceneObjectBase i this.state.tabs.forEach((tab) => tab.getLayout().editModeChanged?.(isEditing)); } + public pasteTab() { + const scene = getDashboardSceneFor(this); + const tab = getTabFromClipboard(scene); + this.addNewTab(tab); + } + public activateRepeaters() { this.state.tabs.forEach((tab) => tab.getLayout().activateRepeaters?.()); } diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManagerRenderer.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManagerRenderer.tsx index 8451c44876d..522d8cb82cd 100644 --- a/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManagerRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManagerRenderer.tsx @@ -7,6 +7,7 @@ import { Button, TabContent, TabsBar, useStyles2 } from '@grafana/ui'; import { Trans } from 'app/core/internationalization'; import { getDashboardSceneFor } from '../../utils/utils'; +import { useClipboardState } from '../layouts-shared/useClipboardState'; import { TabsLayoutManager } from './TabsLayoutManager'; @@ -17,6 +18,7 @@ export function TabsLayoutManagerRenderer({ model }: SceneComponentProps @@ -34,6 +36,11 @@ export function TabsLayoutManagerRenderer({ model }: SceneComponentProps model.addNewTab()}> New tab + {hasCopiedTab && ( + + )} )} diff --git a/public/app/features/dashboard-scene/scene/layouts-shared/CanvasGridAddActions.tsx b/public/app/features/dashboard-scene/scene/layouts-shared/CanvasGridAddActions.tsx index c6abc6a30fd..571a7c06ca0 100644 --- a/public/app/features/dashboard-scene/scene/layouts-shared/CanvasGridAddActions.tsx +++ b/public/app/features/dashboard-scene/scene/layouts-shared/CanvasGridAddActions.tsx @@ -8,6 +8,7 @@ import { getDefaultVizPanel } from '../../utils/utils'; import { DashboardLayoutManager } from '../types/DashboardLayoutManager'; import { addNewRowTo, addNewTabTo } from './addNew'; +import { useClipboardState } from './useClipboardState'; export interface Props { layoutManager: DashboardLayoutManager; @@ -15,6 +16,7 @@ export interface Props { export function CanvasGridAddActions({ layoutManager }: Props) { const styles = useStyles2(getStyles); + const { hasCopiedPanel } = useClipboardState(); return (
@@ -50,6 +52,18 @@ export function CanvasGridAddActions({ layoutManager }: Props) { Group panels + {hasCopiedPanel && layoutManager.pastePanel && ( + + )}
); } diff --git a/public/app/features/dashboard-scene/scene/layouts-shared/paste.ts b/public/app/features/dashboard-scene/scene/layouts-shared/paste.ts new file mode 100644 index 00000000000..9c7eadd6aef --- /dev/null +++ b/public/app/features/dashboard-scene/scene/layouts-shared/paste.ts @@ -0,0 +1,116 @@ +import { + AutoGridLayoutItemKind, + DashboardV2Spec, + GridLayoutItemKind, + RowsLayoutRowKind, + TabsLayoutTabKind, +} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0'; +import { LS_PANEL_COPY_KEY, LS_ROW_COPY_KEY, LS_TAB_COPY_KEY } from 'app/core/constants'; +import store from 'app/core/store'; + +import { deserializeGridItem } from '../../serialization/layoutSerializers/DefaultGridLayoutSerializer'; +import { deserializeAutoGridItem } from '../../serialization/layoutSerializers/ResponsiveGridLayoutSerializer'; +import { deserializeRow } from '../../serialization/layoutSerializers/RowsLayoutSerializer'; +import { deserializeTab } from '../../serialization/layoutSerializers/TabsLayoutSerializer'; +import { dashboardSceneGraph } from '../../utils/dashboardSceneGraph'; +import { DashboardScene } from '../DashboardScene'; +import { DashboardGridItem } from '../layout-default/DashboardGridItem'; +import { GridCell } from '../layout-default/findSpaceForNewPanel'; +import { AutoGridItem } from '../layout-responsive-grid/ResponsiveGridItem'; +import { RowItem } from '../layout-rows/RowItem'; +import { TabItem } from '../layout-tabs/TabItem'; + +export function clearClipboard() { + store.delete(LS_PANEL_COPY_KEY); + store.delete(LS_ROW_COPY_KEY); + store.delete(LS_TAB_COPY_KEY); +} + +export interface RowStore { + elements: DashboardV2Spec['elements']; + row: RowsLayoutRowKind; +} + +export interface TabStore { + elements: DashboardV2Spec['elements']; + tab: TabsLayoutTabKind; +} + +export interface PanelStore { + elements: DashboardV2Spec['elements']; + gridItem: GridLayoutItemKind | AutoGridLayoutItemKind; +} + +export function getRowFromClipboard(scene: DashboardScene): RowItem { + const jsonData = store.get(LS_ROW_COPY_KEY); + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const jsonObj: RowStore = JSON.parse(jsonData) as RowStore; + clearClipboard(); + const panelIdGenerator = getPanelIdGenerator(dashboardSceneGraph.getNextPanelId(scene)); + + let row; + // We don't control the local storage content, so if it's out of sync with the code all bets are off. + try { + row = deserializeRow(jsonObj.row, jsonObj.elements, false, panelIdGenerator); + } catch (error) { + throw new Error('Error pasting row from clipboard, please try to copy again'); + } + + return row; +} + +export function getTabFromClipboard(scene: DashboardScene): TabItem { + const jsonData = store.get(LS_TAB_COPY_KEY); + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const jsonObj: TabStore = JSON.parse(jsonData) as TabStore; + clearClipboard(); + const panelIdGenerator = getPanelIdGenerator(dashboardSceneGraph.getNextPanelId(scene)); + let tab; + try { + tab = deserializeTab(jsonObj.tab, jsonObj.elements, false, panelIdGenerator); + } catch (error) { + throw new Error('Error pasting tab from clipboard, please try to copy again'); + } + + return tab; +} + +export function getPanelFromClipboard(scene: DashboardScene): DashboardGridItem | AutoGridItem { + const jsonData = store.get(LS_PANEL_COPY_KEY); + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const { elements, gridItem }: PanelStore = JSON.parse(jsonData) as PanelStore; + + if (gridItem.kind === 'GridLayoutItem') { + return deserializeGridItem(gridItem, elements, getPanelIdGenerator(dashboardSceneGraph.getNextPanelId(scene))); + } + return deserializeAutoGridItem(gridItem, elements, getPanelIdGenerator(dashboardSceneGraph.getNextPanelId(scene))); +} + +export function getAutoGridItemFromClipboard(scene: DashboardScene): AutoGridItem { + const panel = getPanelFromClipboard(scene); + if (panel instanceof AutoGridItem) { + return panel; + } + // Convert to AutoGridItem + return new AutoGridItem({ body: panel.state.body, key: panel.state.key, variableName: panel.state.variableName }); +} + +export function getDashboardGridItemFromClipboard(scene: DashboardScene, gridCell: GridCell | null): DashboardGridItem { + const panel = getPanelFromClipboard(scene); + if (panel instanceof DashboardGridItem) { + return panel; + } + // Convert to DashboardGridItem + return new DashboardGridItem({ + ...gridCell, + body: panel.state.body, + key: panel.state.key, + variableName: panel.state.variableName, + }); +} + +function getPanelIdGenerator(start: number) { + let id = start; + return () => id++; +} diff --git a/public/app/features/dashboard-scene/scene/layouts-shared/useClipboardState.ts b/public/app/features/dashboard-scene/scene/layouts-shared/useClipboardState.ts new file mode 100644 index 00000000000..354761fde11 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/layouts-shared/useClipboardState.ts @@ -0,0 +1,36 @@ +import { useEffect, useState } from 'react'; + +import { LS_PANEL_COPY_KEY, LS_ROW_COPY_KEY, LS_TAB_COPY_KEY } from 'app/core/constants'; +import store from 'app/core/store'; + +export function useClipboardState() { + const [hasCopiedPanel, setHasCopiedPanel] = useState(store.exists(LS_PANEL_COPY_KEY)); + const [hasCopiedRow, setHasCopiedRow] = useState(store.exists(LS_ROW_COPY_KEY)); + const [hasCopiedTab, setHasCopiedTab] = useState(store.exists(LS_TAB_COPY_KEY)); + + useEffect(() => { + const unsubscribe = store.subscribe(LS_PANEL_COPY_KEY, () => { + setHasCopiedPanel(store.exists(LS_PANEL_COPY_KEY)); + }); + + const unsubscribeRow = store.subscribe(LS_ROW_COPY_KEY, () => { + setHasCopiedRow(store.exists(LS_ROW_COPY_KEY)); + }); + + const unsubscribeTab = store.subscribe(LS_TAB_COPY_KEY, () => { + setHasCopiedTab(store.exists(LS_TAB_COPY_KEY)); + }); + + return () => { + unsubscribe(); + unsubscribeRow(); + unsubscribeTab(); + }; + }, []); + + return { + hasCopiedPanel, + hasCopiedRow, + hasCopiedTab, + }; +} diff --git a/public/app/features/dashboard-scene/scene/types/DashboardLayoutManager.ts b/public/app/features/dashboard-scene/scene/types/DashboardLayoutManager.ts index ff73cdffdcb..06a8ba01772 100644 --- a/public/app/features/dashboard-scene/scene/types/DashboardLayoutManager.ts +++ b/public/app/features/dashboard-scene/scene/types/DashboardLayoutManager.ts @@ -76,6 +76,11 @@ export interface DashboardLayoutManager extends SceneObject { * Duplicate, like clone but with new keys */ duplicate(): DashboardLayoutManager; + + /** + * Paste a panel from the clipboard + */ + pastePanel?(): void; } export interface LayoutManagerSerializer { diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/DefaultGridLayoutSerializer.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/DefaultGridLayoutSerializer.ts index 12d4add0286..a8e25ba331c 100644 --- a/public/app/features/dashboard-scene/serialization/layoutSerializers/DefaultGridLayoutSerializer.ts +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/DefaultGridLayoutSerializer.ts @@ -38,7 +38,8 @@ export function serializeDefaultGridLayout( export function deserializeDefaultGridLayout( layout: DashboardV2Spec['layout'], elements: DashboardV2Spec['elements'], - preload: boolean + preload: boolean, + panelIdGenerator?: () => number ): DefaultGridLayoutManager { if (layout.kind !== 'GridLayout') { throw new Error('Invalid layout kind'); @@ -46,7 +47,7 @@ export function deserializeDefaultGridLayout( return new DefaultGridLayoutManager({ grid: new SceneGridLayout({ isLazy: !(preload || contextSrv.user.authenticatedBy === 'render'), - children: createSceneGridLayoutForItems(layout, elements), + children: createSceneGridLayoutForItems(layout, elements, panelIdGenerator), }), }); } @@ -108,7 +109,7 @@ function gridRowToLayoutRowKind(row: SceneGridRow, isSnapshot = false): GridLayo }; } -function gridItemToGridLayoutItemKind(gridItem: DashboardGridItem, yOverride?: number): GridLayoutItemKind { +export function gridItemToGridLayoutItemKind(gridItem: DashboardGridItem, yOverride?: number): GridLayoutItemKind { let elementGridItem: GridLayoutItemKind | undefined; let x = 0, y = 0, @@ -223,34 +224,37 @@ function repeaterToLayoutItems(repeater: DashboardGridItem, isSnapshot = false): } } -function createSceneGridLayoutForItems(layout: GridLayoutKind, elements: Record): SceneGridItemLike[] { - const gridElements = layout.spec.items; +function createSceneGridLayoutForItems( + layout: GridLayoutKind, + elements: Record, + panelIdGenerator?: () => number +): SceneGridItemLike[] { + const gridItems = layout.spec.items; - return gridElements.map((element) => { - if (element.kind === 'GridLayoutItem') { - const panel = elements[element.spec.element.name]; - - if (!panel) { - throw new Error(`Panel with uid ${element.spec.element.name} not found in the dashboard elements`); - } - return buildGridItem(element.spec, panel); - } else if (element.kind === 'GridLayoutRow') { - const children = element.spec.elements.map((gridElement) => { + return gridItems.map((item) => { + if (item.kind === 'GridLayoutItem') { + return deserializeGridItem(item, elements, panelIdGenerator); + } else if (item.kind === 'GridLayoutRow') { + const children = item.spec.elements.map((gridElement) => { const panel = elements[getOriginalKey(gridElement.spec.element.name)]; if (panel.kind === 'Panel' || panel.kind === 'LibraryPanel') { - return buildGridItem(gridElement.spec, panel, element.spec.y + GRID_ROW_HEIGHT + gridElement.spec.y); + let id: number | undefined; + if (panelIdGenerator) { + id = panelIdGenerator(); + } + return buildGridItem(gridElement.spec, panel, item.spec.y + GRID_ROW_HEIGHT + gridElement.spec.y, id); } else { throw new Error(`Unknown element kind: ${gridElement.kind}`); } }); let behaviors: SceneObject[] | undefined; - if (element.spec.repeat) { - behaviors = [new RowRepeaterBehavior({ variableName: element.spec.repeat.value })]; + if (item.spec.repeat) { + behaviors = [new RowRepeaterBehavior({ variableName: item.spec.repeat.value })]; } return new SceneGridRow({ - y: element.spec.y, - isCollapsed: element.spec.collapsed, - title: element.spec.title, + y: item.spec.y, + isCollapsed: item.spec.collapsed, + title: item.spec.title, $behaviors: behaviors, actions: new RowActions({}), children, @@ -258,7 +262,7 @@ function createSceneGridLayoutForItems(layout: GridLayoutKind, elements: Record< } else { // If this has been validated by the schema we should never reach this point, which is why TS is telling us this is an error. //@ts-expect-error - throw new Error(`Unknown layout element kind: ${element.kind}`); + throw new Error(`Unknown layout element kind: ${item.kind}`); } }); } @@ -266,16 +270,17 @@ function createSceneGridLayoutForItems(layout: GridLayoutKind, elements: Record< function buildGridItem( gridItem: GridLayoutItemSpec, panel: PanelKind | LibraryPanelKind, - yOverride?: number + yOverride?: number, + id?: number ): DashboardGridItem { let vizPanel: VizPanel; if (panel.kind === 'Panel') { - vizPanel = buildVizPanel(panel); + vizPanel = buildVizPanel(panel, id); } else { - vizPanel = buildLibraryPanel(panel); + vizPanel = buildLibraryPanel(panel, id); } return new DashboardGridItem({ - key: `grid-item-${panel.spec.id}`, + key: `grid-item-${id ?? panel.spec.id}`, x: gridItem.x, y: yOverride ?? gridItem.y, width: gridItem.repeat?.direction === 'h' ? 24 : gridItem.width, @@ -287,3 +292,21 @@ function buildGridItem( maxPerRow: gridItem.repeat?.maxPerRow, }); } + +export function deserializeGridItem( + item: GridLayoutItemKind, + elements: DashboardV2Spec['elements'], + panelIdGenerator?: () => number +): DashboardGridItem { + const panel = elements[item.spec.element.name]; + + if (!panel) { + throw new Error(`Panel with uid ${item.spec.element.name} not found in the dashboard elements`); + } + + let id: number | undefined; + if (panelIdGenerator) { + id = panelIdGenerator(); + } + return buildGridItem(item.spec, panel, undefined, id); +} diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/ResponsiveGridLayoutSerializer.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/ResponsiveGridLayoutSerializer.ts index 4fe3e05a9e0..9e3892a974f 100644 --- a/public/app/features/dashboard-scene/serialization/layoutSerializers/ResponsiveGridLayoutSerializer.ts +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/ResponsiveGridLayoutSerializer.ts @@ -31,45 +31,46 @@ export function serializeAutoGridLayout(layoutManager: AutoGridLayoutManager): D fillScreen: fillScreen === defaults.fillScreen ? undefined : fillScreen, ...serializeAutoGridColumnWidth(columnWidth), ...serializeAutoGridRowHeight(rowHeight), - items: layout.state.children.map((child) => { - if (!(child instanceof AutoGridItem)) { - throw new Error('Expected AutoGridItem'); - } - // For serialization we should retrieve the original element key - const elementKey = dashboardSceneGraph.getElementIdentifierForVizPanel(child.state?.body); - - const layoutItem: AutoGridLayoutItemKind = { - kind: 'AutoGridLayoutItem', - spec: { - element: { - kind: 'ElementReference', - name: elementKey, - }, - }, - }; - - const conditionalRenderingRootGroup = child.state.conditionalRendering?.serialize(); - // Only serialize the conditional rendering if it has items - if (conditionalRenderingRootGroup?.spec.items.length) { - layoutItem.spec.conditionalRendering = conditionalRenderingRootGroup; - } - - if (child.state.variableName) { - layoutItem.spec.repeat = { - mode: 'variable', - value: child.state.variableName, - }; - } - - return layoutItem; - }), + items: layout.state.children.map(serializeAutoGridItem), }, }; } +export function serializeAutoGridItem(item: AutoGridItem): AutoGridLayoutItemKind { + // For serialization we should retrieve the original element key + const elementKey = dashboardSceneGraph.getElementIdentifierForVizPanel(item.state?.body); + + const layoutItem: AutoGridLayoutItemKind = { + kind: 'AutoGridLayoutItem', + spec: { + element: { + kind: 'ElementReference', + name: elementKey, + }, + }, + }; + + const conditionalRenderingRootGroup = item.state.conditionalRendering?.serialize(); + // Only serialize the conditional rendering if it has items + if (conditionalRenderingRootGroup?.spec.items.length) { + layoutItem.spec.conditionalRendering = conditionalRenderingRootGroup; + } + + if (item.state.variableName) { + layoutItem.spec.repeat = { + mode: 'variable', + value: item.state.variableName, + }; + } + + return layoutItem; +} + export function deserializeAutoGridLayout( layout: DashboardV2Spec['layout'], - elements: DashboardV2Spec['elements'] + elements: DashboardV2Spec['elements'], + preload: boolean, + panelIdGenerator?: () => number ): AutoGridLayoutManager { if (layout.kind !== 'AutoGridLayout') { throw new Error('Invalid layout kind'); @@ -78,18 +79,7 @@ export function deserializeAutoGridLayout( const defaults = defaultAutoGridLayoutSpec(); const { maxColumnCount, columnWidthMode, columnWidth, rowHeightMode, rowHeight, fillScreen } = layout.spec; - const children = layout.spec.items.map((item) => { - const panel = elements[item.spec.element.name]; - if (!panel) { - throw new Error(`Panel with uid ${item.spec.element.name} not found in the dashboard elements`); - } - return new AutoGridItem({ - key: getGridItemKeyForPanelId(panel.spec.id), - body: panel.kind === 'LibraryPanel' ? buildLibraryPanel(panel) : buildVizPanel(panel), - variableName: item.spec.repeat?.value, - conditionalRendering: getConditionalRendering(item), - }); - }); + const children = layout.spec.items.map((item) => deserializeAutoGridItem(item, elements, panelIdGenerator)); const columnWidthCombined = columnWidthMode === 'custom' ? columnWidth : columnWidthMode; const rowHeightCombined = rowHeightMode === 'custom' ? rowHeight : rowHeightMode; @@ -123,3 +113,24 @@ function serializeAutoGridRowHeight(rowHeight: AutoGridRowHeight) { rowHeight: typeof rowHeight === 'number' ? rowHeight : undefined, }; } + +export function deserializeAutoGridItem( + item: AutoGridLayoutItemKind, + elements: DashboardV2Spec['elements'], + panelIdGenerator?: () => number +): AutoGridItem { + const panel = elements[item.spec.element.name]; + if (!panel) { + throw new Error(`Panel with uid ${item.spec.element.name} not found in the dashboard elements`); + } + let id: number | undefined; + if (panelIdGenerator) { + id = panelIdGenerator(); + } + return new AutoGridItem({ + key: getGridItemKeyForPanelId(id ?? panel.spec.id), + body: panel.kind === 'LibraryPanel' ? buildLibraryPanel(panel, id) : buildVizPanel(panel, id), + variableName: item.spec.repeat?.value, + conditionalRendering: getConditionalRendering(item), + }); +} diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.ts index 2d0458d702d..d309ab6d029 100644 --- a/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.ts +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.ts @@ -12,65 +12,75 @@ export function serializeRowsLayout(layoutManager: RowsLayoutManager): Dashboard return { kind: 'RowsLayout', spec: { - rows: layoutManager.state.rows.map((row) => { - const layout = row.state.layout.serialize(); - const rowKind: RowsLayoutRowKind = { - kind: 'RowsLayoutRow', - spec: { - title: row.state.title, - collapse: row.state.collapse, - layout: layout, - fillScreen: row.state.fillScreen, - hideHeader: row.state.hideHeader, - }, - }; - - const conditionalRenderingRootGroup = row.state.conditionalRendering?.serialize(); - // Only serialize the conditional rendering if it has items - if (conditionalRenderingRootGroup?.spec.items.length) { - rowKind.spec.conditionalRendering = conditionalRenderingRootGroup; - } - - if (row.state.$behaviors) { - for (const behavior of row.state.$behaviors) { - if (behavior instanceof RowItemRepeaterBehavior) { - if (rowKind.spec.repeat) { - throw new Error('Multiple repeaters are not supported'); - } - rowKind.spec.repeat = { value: behavior.state.variableName, mode: 'variable' }; - } - } - } - return rowKind; - }), + rows: layoutManager.state.rows.map(serializeRow), }, }; } +export function serializeRow(row: RowItem): RowsLayoutRowKind { + const layout = row.state.layout.serialize(); + const rowKind: RowsLayoutRowKind = { + kind: 'RowsLayoutRow', + spec: { + title: row.state.title, + collapse: row.state.collapse, + layout: layout, + fillScreen: row.state.fillScreen, + hideHeader: row.state.hideHeader, + }, + }; + + const conditionalRenderingRootGroup = row.state.conditionalRendering?.serialize(); + // Only serialize the conditional rendering if it has items + if (conditionalRenderingRootGroup?.spec.items.length) { + rowKind.spec.conditionalRendering = conditionalRenderingRootGroup; + } + + if (row.state.$behaviors) { + for (const behavior of row.state.$behaviors) { + if (behavior instanceof RowItemRepeaterBehavior) { + if (rowKind.spec.repeat) { + throw new Error('Multiple repeaters are not supported'); + } + rowKind.spec.repeat = { value: behavior.state.variableName, mode: 'variable' }; + } + } + } + return rowKind; +} + export function deserializeRowsLayout( layout: DashboardV2Spec['layout'], elements: DashboardV2Spec['elements'], - preload: boolean + preload: boolean, + panelIdGenerator?: () => number ): RowsLayoutManager { if (layout.kind !== 'RowsLayout') { throw new Error('Invalid layout kind'); } - const rows = layout.spec.rows.map((row) => { - const layout = row.spec.layout; - const behaviors: SceneObject[] = []; - if (row.spec.repeat) { - behaviors.push(new RowItemRepeaterBehavior({ variableName: row.spec.repeat.value })); - } - - return new RowItem({ - title: row.spec.title, - collapse: row.spec.collapse, - hideHeader: row.spec.hideHeader, - fillScreen: row.spec.fillScreen, - $behaviors: behaviors, - layout: layoutDeserializerRegistry.get(layout.kind).deserialize(layout, elements, preload), - conditionalRendering: getConditionalRendering(row), - }); - }); + const rows = layout.spec.rows.map((row) => deserializeRow(row, elements, preload, panelIdGenerator)); return new RowsLayoutManager({ rows }); } + +export function deserializeRow( + row: RowsLayoutRowKind, + elements: DashboardV2Spec['elements'], + preload: boolean, + panelIdGenerator?: () => number +): RowItem { + const layout = row.spec.layout; + const behaviors: SceneObject[] = []; + if (row.spec.repeat) { + behaviors.push(new RowItemRepeaterBehavior({ variableName: row.spec.repeat.value })); + } + + return new RowItem({ + title: row.spec.title, + collapse: row.spec.collapse, + hideHeader: row.spec.hideHeader, + fillScreen: row.spec.fillScreen, + $behaviors: behaviors, + layout: layoutDeserializerRegistry.get(layout.kind).deserialize(layout, elements, preload, panelIdGenerator), + conditionalRendering: getConditionalRendering(row), + }); +} diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/TabsLayoutSerializer.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/TabsLayoutSerializer.ts index 729aaa83ec2..145d55b0ad7 100644 --- a/public/app/features/dashboard-scene/serialization/layoutSerializers/TabsLayoutSerializer.ts +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/TabsLayoutSerializer.ts @@ -1,4 +1,4 @@ -import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0'; +import { DashboardV2Spec, TabsLayoutTabKind } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0'; import { TabItem } from '../../scene/layout-tabs/TabItem'; import { TabsLayoutManager } from '../../scene/layout-tabs/TabsLayoutManager'; @@ -9,16 +9,18 @@ export function serializeTabsLayout(layoutManager: TabsLayoutManager): Dashboard return { kind: 'TabsLayout', spec: { - tabs: layoutManager.state.tabs.map((tab) => { - const layout = tab.state.layout.serialize(); - return { - kind: 'TabsLayoutTab', - spec: { - title: tab.state.title, - layout: layout, - }, - }; - }), + tabs: layoutManager.state.tabs.map(serializeTab), + }, + }; +} + +export function serializeTab(tab: TabItem): TabsLayoutTabKind { + const layout = tab.state.layout.serialize(); + return { + kind: 'TabsLayoutTab', + spec: { + title: tab.state.title, + layout: layout, }, }; } @@ -26,17 +28,29 @@ export function serializeTabsLayout(layoutManager: TabsLayoutManager): Dashboard export function deserializeTabsLayout( layout: DashboardV2Spec['layout'], elements: DashboardV2Spec['elements'], - preload: boolean + preload: boolean, + panelIdGenerator?: () => number ): TabsLayoutManager { if (layout.kind !== 'TabsLayout') { throw new Error('Invalid layout kind'); } + const tabs = layout.spec.tabs.map((tab) => { - const layout = tab.spec.layout; - return new TabItem({ - title: tab.spec.title, - layout: layoutDeserializerRegistry.get(layout.kind).deserialize(layout, elements, preload), - }); + return deserializeTab(tab, elements, preload, panelIdGenerator); }); + return new TabsLayoutManager({ tabs }); } + +export function deserializeTab( + tab: TabsLayoutTabKind, + elements: DashboardV2Spec['elements'], + preload: boolean, + panelIdGenerator?: () => number +): TabItem { + const layout = tab.spec.layout; + return new TabItem({ + title: tab.spec.title, + layout: layoutDeserializerRegistry.get(layout.kind).deserialize(layout, elements, preload, panelIdGenerator), + }); +} diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/layoutSerializerRegistry.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/layoutSerializerRegistry.ts index b0d8bd79a7b..9ef055991a9 100644 --- a/public/app/features/dashboard-scene/serialization/layoutSerializers/layoutSerializerRegistry.ts +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/layoutSerializerRegistry.ts @@ -12,7 +12,8 @@ interface LayoutSerializerRegistryItem extends RegistryItem { deserialize: ( layout: DashboardV2Spec['layout'], elements: DashboardV2Spec['elements'], - preload: boolean + preload: boolean, + panelIdGenerator?: () => number ) => DashboardLayoutManager; } diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.ts index c6b78dd6fb1..8c89fdc0334 100644 --- a/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.ts +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.ts @@ -25,18 +25,22 @@ import { ConditionalRendering } from '../../conditional-rendering/ConditionalRen import { ConditionalRenderingGroup } from '../../conditional-rendering/ConditionalRenderingGroup'; import { conditionalRenderingSerializerRegistry } from '../../conditional-rendering/serializers'; import { DashboardDatasourceBehaviour } from '../../scene/DashboardDatasourceBehaviour'; +import { DashboardScene } from '../../scene/DashboardScene'; import { LibraryPanelBehavior } from '../../scene/LibraryPanelBehavior'; import { VizPanelLinks, VizPanelLinksMenu } from '../../scene/PanelLinks'; import { panelLinksBehavior, panelMenuBehavior } from '../../scene/PanelMenuBehavior'; import { PanelNotices } from '../../scene/PanelNotices'; import { PanelTimeRange } from '../../scene/PanelTimeRange'; import { AngularDeprecation } from '../../scene/angular/AngularDeprecation'; +import { DashboardGridItem } from '../../scene/layout-default/DashboardGridItem'; +import { AutoGridItem } from '../../scene/layout-responsive-grid/ResponsiveGridItem'; import { setDashboardPanelContext } from '../../scene/setDashboardPanelContext'; import { DashboardLayoutManager } from '../../scene/types/DashboardLayoutManager'; import { getVizPanelKeyForPanelId } from '../../utils/utils'; +import { createElements, vizPanelToSchemaV2 } from '../transformSceneToSaveModelSchemaV2'; import { transformMappingsToV1 } from '../transformToV1TypesUtils'; -export function buildVizPanel(panel: PanelKind): VizPanel { +export function buildVizPanel(panel: PanelKind, id?: number): VizPanel { const titleItems: SceneObject[] = []; if (config.featureToggles.angularDeprecationUI) { @@ -56,7 +60,7 @@ export function buildVizPanel(panel: PanelKind): VizPanel { const timeOverrideShown = (queryOptions.timeFrom || queryOptions.timeShift) && !queryOptions.hideTimeOverride; const vizPanelState: VizPanelState = { - key: getVizPanelKeyForPanelId(panel.spec.id), + key: getVizPanelKeyForPanelId(id ?? panel.spec.id), title: panel.spec.title?.substring(0, 5000), description: panel.spec.description, pluginId: panel.spec.vizConfig.kind, @@ -90,7 +94,7 @@ export function buildVizPanel(panel: PanelKind): VizPanel { return new VizPanel(vizPanelState); } -export function buildLibraryPanel(panel: LibraryPanelKind): VizPanel { +export function buildLibraryPanel(panel: LibraryPanelKind, id?: number): VizPanel { const titleItems: SceneObject[] = []; if (config.featureToggles.angularDeprecationUI) { @@ -107,7 +111,7 @@ export function buildLibraryPanel(panel: LibraryPanelKind): VizPanel { titleItems.push(new PanelNotices()); const vizPanelState: VizPanelState = { - key: getVizPanelKeyForPanelId(panel.spec.id), + key: getVizPanelKeyForPanelId(id ?? panel.spec.id), titleItems, $behaviors: [ new LibraryPanelBehavior({ @@ -242,3 +246,19 @@ export function getConditionalRendering(item: RowsLayoutRowKind | AutoGridLayout return new ConditionalRendering({ rootGroup: rootGroup }); } + +export function getElements(layout: DashboardLayoutManager, scene: DashboardScene): DashboardV2Spec['elements'] { + const panels = layout.getVizPanels(); + const dsReferencesMapping = scene.serializer.getDSReferencesMapping(); + const panelsArray = panels.map((vizPanel) => { + return vizPanelToSchemaV2(vizPanel, dsReferencesMapping); + }); + return createElements(panelsArray, scene); +} + +export function getElement( + gridItem: AutoGridItem | DashboardGridItem, + scene: DashboardScene +): DashboardV2Spec['elements'] { + return createElements([vizPanelToSchemaV2(gridItem.state.body, scene.serializer.getDSReferencesMapping())], scene); +} diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts index a770b4ba0fc..7bab825b423 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts @@ -348,7 +348,7 @@ function getVizPanelQueryOptions(vizPanel: VizPanel): QueryOptionsSpec { return queryOptions; } -function createElements(panels: Element[], scene: DashboardScene): Record { +export function createElements(panels: Element[], scene: DashboardScene): Record { return panels.reduce>((elements, panel) => { let elementKey = scene.serializer.getElementIdForPanel(panel.spec.id); elements[elementKey!] = panel; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index b37077dc212..04f8f28cf5e 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -1417,7 +1417,10 @@ "group-into-tab": "Group into tab", "group-panels": "Group panels", "new-row": "New row", - "new-tab": "New tab" + "new-tab": "New tab", + "paste-panel": "Paste panel", + "paste-row": "Paste row", + "paste-tab": "Paste tab" }, "conditional-rendering": { "data": { @@ -1523,10 +1526,6 @@ "heading": "Panel", "title": "A container for visualizations and other content" }, - "paste-panel": { - "heading": "Paste panel", - "title": "Paste a panel from the clipboard" - }, "row": { "heading": "Row", "title": "A group of panels with an optional header"