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
pull/102879/head
Oscar Kilhed 3 months ago committed by GitHub
parent 332f041c91
commit 6ff3af7e83
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 3
      public/app/core/constants.ts
  2. 20
      public/app/features/dashboard-scene/edit-pane/DashboardAddPane.tsx
  3. 31
      public/app/features/dashboard-scene/scene/DashboardScene.tsx
  4. 10
      public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx
  5. 8
      public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.tsx
  6. 19
      public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx
  7. 18
      public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx
  8. 7
      public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManagerRenderer.tsx
  9. 18
      public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx
  10. 12
      public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx
  11. 7
      public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManagerRenderer.tsx
  12. 14
      public/app/features/dashboard-scene/scene/layouts-shared/CanvasGridAddActions.tsx
  13. 116
      public/app/features/dashboard-scene/scene/layouts-shared/paste.ts
  14. 36
      public/app/features/dashboard-scene/scene/layouts-shared/useClipboardState.ts
  15. 5
      public/app/features/dashboard-scene/scene/types/DashboardLayoutManager.ts
  16. 75
      public/app/features/dashboard-scene/serialization/layoutSerializers/DefaultGridLayoutSerializer.ts
  17. 101
      public/app/features/dashboard-scene/serialization/layoutSerializers/ResponsiveGridLayoutSerializer.ts
  18. 104
      public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.ts
  19. 48
      public/app/features/dashboard-scene/serialization/layoutSerializers/TabsLayoutSerializer.ts
  20. 3
      public/app/features/dashboard-scene/serialization/layoutSerializers/layoutSerializerRegistry.ts
  21. 28
      public/app/features/dashboard-scene/serialization/layoutSerializers/utils.ts
  22. 2
      public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts
  23. 9
      public/locales/en-US/grafana.json

@ -9,7 +9,8 @@ export const DEFAULT_ROW_HEIGHT = 250;
export const MIN_PANEL_HEIGHT = GRID_CELL_HEIGHT * 3; export const MIN_PANEL_HEIGHT = GRID_CELL_HEIGHT * 3;
export const LS_PANEL_COPY_KEY = 'panel-copy'; 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 PANEL_BORDER = 2;
export const EDIT_PANEL_ID = 23763571993; export const EDIT_PANEL_ID = 23763571993;

@ -1,12 +1,9 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { useEffect, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
import { Box, Card, Icon, IconButton, IconName, useStyles2 } from '@grafana/ui'; 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 { t } from 'app/core/internationalization';
import store from 'app/core/store';
import { DashboardInteractions } from '../utils/interactions'; import { DashboardInteractions } from '../utils/interactions';
import { getDashboardSceneFor } from '../utils/utils'; import { getDashboardSceneFor } from '../utils/utils';
@ -29,15 +26,6 @@ interface CardConfig {
export function DashboardAddPane({ editPane }: Props) { export function DashboardAddPane({ editPane }: Props) {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const dashboard = getDashboardSceneFor(editPane); 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[] = [ const cards: CardConfig[] = [
{ {
@ -74,14 +62,6 @@ export function DashboardAddPane({ editPane }: Props) {
testId: selectors.components.PageToolbar.itemButton('add_tab'), testId: selectors.components.PageToolbar.itemButton('add_tab'),
onClick: () => dashboard.onCreateNewTab(), 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 ( return (

@ -1,6 +1,6 @@
import * as H from 'history'; 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 { config, locationService, RefreshEvent } from '@grafana/runtime';
import { import {
sceneGraph, sceneGraph,
@ -40,6 +40,9 @@ import { DashboardSceneChangeTracker } from '../saving/DashboardSceneChangeTrack
import { SaveDashboardDrawer } from '../saving/SaveDashboardDrawer'; import { SaveDashboardDrawer } from '../saving/SaveDashboardDrawer';
import { DashboardChangeInfo } from '../saving/shared'; import { DashboardChangeInfo } from '../saving/shared';
import { DashboardSceneSerializerLike, getDashboardSceneSerializer } from '../serialization/DashboardSceneSerializer'; 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 { buildGridItemForPanel, transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
import { gridItemToPanel } from '../serialization/transformSceneToSaveModel'; import { gridItemToPanel } from '../serialization/transformSceneToSaveModel';
import { DecoratedRevisionModel } from '../settings/VersionsEditView'; import { DecoratedRevisionModel } from '../settings/VersionsEditView';
@ -71,8 +74,10 @@ import { isUsingAngularDatasourcePlugin, isUsingAngularPanelPlugin } from './ang
import { setupKeyboardShortcuts } from './keyboardShortcuts'; import { setupKeyboardShortcuts } from './keyboardShortcuts';
import { DashboardGridItem } from './layout-default/DashboardGridItem'; import { DashboardGridItem } from './layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager'; import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager';
import { AutoGridItem } from './layout-responsive-grid/ResponsiveGridItem';
import { LayoutRestorer } from './layouts-shared/LayoutRestorer'; import { LayoutRestorer } from './layouts-shared/LayoutRestorer';
import { addNewRowTo, addNewTabTo } from './layouts-shared/addNew'; import { addNewRowTo, addNewTabTo } from './layouts-shared/addNew';
import { clearClipboard } from './layouts-shared/paste';
import { DashboardLayoutManager } from './types/DashboardLayoutManager'; import { DashboardLayoutManager } from './types/DashboardLayoutManager';
import { isLayoutParent, LayoutParent } from './types/LayoutParent'; import { isLayoutParent, LayoutParent } from './types/LayoutParent';
@ -523,6 +528,28 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
} }
public copyPanel(vizPanel: VizPanel) { 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) { if (!vizPanel.parent) {
return; return;
} }
@ -536,8 +563,8 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
const jsonData = gridItemToPanel(gridItem); const jsonData = gridItemToPanel(gridItem);
clearClipboard();
store.set(LS_PANEL_COPY_KEY, JSON.stringify(jsonData)); store.set(LS_PANEL_COPY_KEY, JSON.stringify(jsonData));
appEvents.emit(AppEvents.alertSuccess, ['Panel copied. Use **Paste panel** toolbar action to paste.']);
} }
public pastePanel() { public pastePanel() {

@ -38,8 +38,10 @@ import {
getGridItemKeyForPanelId, getGridItemKeyForPanelId,
useDashboard, useDashboard,
getLayoutOrchestratorFor, getLayoutOrchestratorFor,
getDashboardSceneFor,
} from '../../utils/utils'; } from '../../utils/utils';
import { CanvasGridAddActions } from '../layouts-shared/CanvasGridAddActions'; import { CanvasGridAddActions } from '../layouts-shared/CanvasGridAddActions';
import { clearClipboard, getDashboardGridItemFromClipboard } from '../layouts-shared/paste';
import { DashboardLayoutManager } from '../types/DashboardLayoutManager'; import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
import { LayoutRegistryItem } from '../types/LayoutRegistryItem'; import { LayoutRegistryItem } from '../types/LayoutRegistryItem';
@ -134,6 +136,14 @@ export class DefaultGridLayoutManager
this.publishEvent(new NewObjectAddedToCanvasEvent(vizPanel), true); 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) { public removePanel(panel: VizPanel) {
const gridItem = panel.parent!; const gridItem = panel.parent!;

@ -15,6 +15,7 @@ import {
getPanelIdForVizPanel, getPanelIdForVizPanel,
getVizPanelKeyForPanelId, getVizPanelKeyForPanelId,
} from '../../utils/utils'; } from '../../utils/utils';
import { clearClipboard, getAutoGridItemFromClipboard } from '../layouts-shared/paste';
import { DashboardLayoutManager } from '../types/DashboardLayoutManager'; import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
import { LayoutRegistryItem } from '../types/LayoutRegistryItem'; import { LayoutRegistryItem } from '../types/LayoutRegistryItem';
@ -98,6 +99,13 @@ export class AutoGridLayoutManager
this.publishEvent(new NewObjectAddedToCanvasEvent(vizPanel), true); 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) { public removePanel(panel: VizPanel) {
const element = panel.parent; const element = panel.parent;
this.state.layout.setState({ children: this.state.layout.state.children.filter((child) => child !== element) }); this.state.layout.setState({ children: this.state.layout.state.children.filter((child) => child !== element) });

@ -8,14 +8,20 @@ import {
VariableDependencyConfig, VariableDependencyConfig,
VizPanel, VizPanel,
} from '@grafana/scenes'; } 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 { t } from 'app/core/internationalization';
import store from 'app/core/store';
import kbn from 'app/core/utils/kbn'; import kbn from 'app/core/utils/kbn';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { ConditionalRendering } from '../../conditional-rendering/ConditionalRendering'; 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 { AutoGridLayoutManager } from '../layout-responsive-grid/ResponsiveGridLayoutManager';
import { LayoutRestorer } from '../layouts-shared/LayoutRestorer'; import { LayoutRestorer } from '../layouts-shared/LayoutRestorer';
import { clearClipboard } from '../layouts-shared/paste';
import { scrollCanvasElementIntoView } from '../layouts-shared/scrollCanvasElementIntoView'; import { scrollCanvasElementIntoView } from '../layouts-shared/scrollCanvasElementIntoView';
import { BulkActionElement } from '../types/BulkActionElement'; import { BulkActionElement } from '../types/BulkActionElement';
import { DashboardDropTarget } from '../types/DashboardDropTarget'; import { DashboardDropTarget } from '../types/DashboardDropTarget';
@ -116,6 +122,17 @@ export class RowItem
return this.clone({ key: undefined, layout: this.getLayout().duplicate() }); 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()) { public onAddPanel(panel = getDefaultVizPanel()) {
this.getLayout().addPanel(panel); this.getLayout().addPanel(panel);
} }

@ -10,10 +10,12 @@ import {
import { serializeRowsLayout } from '../../serialization/layoutSerializers/RowsLayoutSerializer'; import { serializeRowsLayout } from '../../serialization/layoutSerializers/RowsLayoutSerializer';
import { isClonedKey } from '../../utils/clone'; import { isClonedKey } from '../../utils/clone';
import { dashboardSceneGraph } from '../../utils/dashboardSceneGraph'; import { dashboardSceneGraph } from '../../utils/dashboardSceneGraph';
import { getDashboardSceneFor } from '../../utils/utils';
import { DashboardGridItem } from '../layout-default/DashboardGridItem'; import { DashboardGridItem } from '../layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from '../layout-default/DefaultGridLayoutManager'; import { DefaultGridLayoutManager } from '../layout-default/DefaultGridLayoutManager';
import { RowRepeaterBehavior } from '../layout-default/RowRepeaterBehavior'; import { RowRepeaterBehavior } from '../layout-default/RowRepeaterBehavior';
import { TabsLayoutManager } from '../layout-tabs/TabsLayoutManager'; import { TabsLayoutManager } from '../layout-tabs/TabsLayoutManager';
import { getRowFromClipboard } from '../layouts-shared/paste';
import { DashboardLayoutManager } from '../types/DashboardLayoutManager'; import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
import { LayoutRegistryItem } from '../types/LayoutRegistryItem'; import { LayoutRegistryItem } from '../types/LayoutRegistryItem';
@ -91,17 +93,23 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
this.publishEvent(new NewObjectAddedToCanvasEvent(newRow), true); this.publishEvent(new NewObjectAddedToCanvasEvent(newRow), true);
} }
public addNewRow(): RowItem { public addNewRow(row?: RowItem): RowItem {
const row = new RowItem({ isNew: true }); const newRow = row ?? new RowItem({ isNew: true });
this.setState({ rows: [...this.state.rows, row] }); this.setState({ rows: [...this.state.rows, newRow] });
this.publishEvent(new NewObjectAddedToCanvasEvent(row), true); this.publishEvent(new NewObjectAddedToCanvasEvent(newRow), true);
return row; return newRow;
} }
public editModeChanged(isEditing: boolean) { public editModeChanged(isEditing: boolean) {
this.state.rows.forEach((row) => row.getLayout().editModeChanged?.(isEditing)); this.state.rows.forEach((row) => row.getLayout().editModeChanged?.(isEditing));
} }
public pasteRow() {
const scene = getDashboardSceneFor(this);
const row = getRowFromClipboard(scene);
this.addNewRow(row);
}
public activateRepeaters() { public activateRepeaters() {
this.state.rows.forEach((row) => { this.state.rows.forEach((row) => {
if (!row.isActive) { if (!row.isActive) {

@ -6,6 +6,7 @@ import { Button, useStyles2 } from '@grafana/ui';
import { Trans } from 'app/core/internationalization'; import { Trans } from 'app/core/internationalization';
import { useDashboardState } from '../../utils/utils'; import { useDashboardState } from '../../utils/utils';
import { useClipboardState } from '../layouts-shared/useClipboardState';
import { RowsLayoutManager } from './RowsLayoutManager'; import { RowsLayoutManager } from './RowsLayoutManager';
@ -13,6 +14,7 @@ export function RowLayoutManagerRenderer({ model }: SceneComponentProps<RowsLayo
const { rows } = model.useState(); const { rows } = model.useState();
const { isEditing } = useDashboardState(model); const { isEditing } = useDashboardState(model);
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const { hasCopiedRow } = useClipboardState();
return ( return (
<div className={styles.wrapper}> <div className={styles.wrapper}>
@ -24,6 +26,11 @@ export function RowLayoutManagerRenderer({ model }: SceneComponentProps<RowsLayo
<Button icon="plus" variant="primary" fill="text" onClick={() => model.addNewRow()}> <Button icon="plus" variant="primary" fill="text" onClick={() => model.addNewRow()}>
<Trans i18nKey="dashboard.canvas-actions.new-row">New row</Trans> <Trans i18nKey="dashboard.canvas-actions.new-row">New row</Trans>
</Button> </Button>
{hasCopiedRow && (
<Button icon="plus" variant="primary" fill="text" onClick={() => model.pasteRow()}>
<Trans i18nKey="dashboard.canvas-actions.paste-row">Paste row</Trans>
</Button>
)}
</div> </div>
)} )}
</div> </div>

@ -8,13 +8,19 @@ import {
SceneObject, SceneObject,
VizPanel, VizPanel,
} from '@grafana/scenes'; } 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 { t } from 'app/core/internationalization';
import store from 'app/core/store';
import kbn from 'app/core/utils/kbn'; import kbn from 'app/core/utils/kbn';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; 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 { AutoGridLayoutManager } from '../layout-responsive-grid/ResponsiveGridLayoutManager';
import { LayoutRestorer } from '../layouts-shared/LayoutRestorer'; import { LayoutRestorer } from '../layouts-shared/LayoutRestorer';
import { clearClipboard } from '../layouts-shared/paste';
import { scrollCanvasElementIntoView } from '../layouts-shared/scrollCanvasElementIntoView'; import { scrollCanvasElementIntoView } from '../layouts-shared/scrollCanvasElementIntoView';
import { BulkActionElement } from '../types/BulkActionElement'; import { BulkActionElement } from '../types/BulkActionElement';
import { DashboardDropTarget } from '../types/DashboardDropTarget'; import { DashboardDropTarget } from '../types/DashboardDropTarget';
@ -90,6 +96,16 @@ export class TabItem
layout.removeTab(this); 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 { public createMultiSelectedElement(items: SceneObject[]): TabItems {
return new TabItems(items.filter((item) => item instanceof TabItem)); return new TabItems(items.filter((item) => item instanceof TabItem));
} }

@ -14,8 +14,10 @@ import {
ObjectsReorderedOnCanvasEvent, ObjectsReorderedOnCanvasEvent,
} from '../../edit-pane/shared'; } from '../../edit-pane/shared';
import { serializeTabsLayout } from '../../serialization/layoutSerializers/TabsLayoutSerializer'; import { serializeTabsLayout } from '../../serialization/layoutSerializers/TabsLayoutSerializer';
import { getDashboardSceneFor } from '../../utils/utils';
import { RowItem } from '../layout-rows/RowItem'; import { RowItem } from '../layout-rows/RowItem';
import { RowsLayoutManager } from '../layout-rows/RowsLayoutManager'; import { RowsLayoutManager } from '../layout-rows/RowsLayoutManager';
import { getTabFromClipboard } from '../layouts-shared/paste';
import { DashboardLayoutManager } from '../types/DashboardLayoutManager'; import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
import { LayoutRegistryItem } from '../types/LayoutRegistryItem'; import { LayoutRegistryItem } from '../types/LayoutRegistryItem';
@ -121,8 +123,8 @@ export class TabsLayoutManager extends SceneObjectBase<TabsLayoutManagerState> i
throw new Error('Method not implemented.'); throw new Error('Method not implemented.');
} }
public addNewTab() { public addNewTab(tab?: TabItem) {
const newTab = new TabItem({ isNew: true }); const newTab = tab ?? new TabItem({ isNew: true });
this.setState({ tabs: [...this.state.tabs, newTab], currentTabIndex: this.state.tabs.length }); this.setState({ tabs: [...this.state.tabs, newTab], currentTabIndex: this.state.tabs.length });
this.publishEvent(new NewObjectAddedToCanvasEvent(newTab), true); this.publishEvent(new NewObjectAddedToCanvasEvent(newTab), true);
return newTab; return newTab;
@ -132,6 +134,12 @@ export class TabsLayoutManager extends SceneObjectBase<TabsLayoutManagerState> i
this.state.tabs.forEach((tab) => tab.getLayout().editModeChanged?.(isEditing)); this.state.tabs.forEach((tab) => tab.getLayout().editModeChanged?.(isEditing));
} }
public pasteTab() {
const scene = getDashboardSceneFor(this);
const tab = getTabFromClipboard(scene);
this.addNewTab(tab);
}
public activateRepeaters() { public activateRepeaters() {
this.state.tabs.forEach((tab) => tab.getLayout().activateRepeaters?.()); this.state.tabs.forEach((tab) => tab.getLayout().activateRepeaters?.());
} }

@ -7,6 +7,7 @@ import { Button, TabContent, TabsBar, useStyles2 } from '@grafana/ui';
import { Trans } from 'app/core/internationalization'; import { Trans } from 'app/core/internationalization';
import { getDashboardSceneFor } from '../../utils/utils'; import { getDashboardSceneFor } from '../../utils/utils';
import { useClipboardState } from '../layouts-shared/useClipboardState';
import { TabsLayoutManager } from './TabsLayoutManager'; import { TabsLayoutManager } from './TabsLayoutManager';
@ -17,6 +18,7 @@ export function TabsLayoutManagerRenderer({ model }: SceneComponentProps<TabsLay
const { layout } = currentTab.useState(); const { layout } = currentTab.useState();
const dashboard = getDashboardSceneFor(model); const dashboard = getDashboardSceneFor(model);
const { isEditing } = dashboard.useState(); const { isEditing } = dashboard.useState();
const { hasCopiedTab } = useClipboardState();
return ( return (
<div className={styles.tabLayoutContainer}> <div className={styles.tabLayoutContainer}>
@ -34,6 +36,11 @@ export function TabsLayoutManagerRenderer({ model }: SceneComponentProps<TabsLay
<Button icon="plus" variant="primary" fill="text" onClick={() => model.addNewTab()}> <Button icon="plus" variant="primary" fill="text" onClick={() => model.addNewTab()}>
<Trans i18nKey="dashboard.canvas-actions.new-tab">New tab</Trans> <Trans i18nKey="dashboard.canvas-actions.new-tab">New tab</Trans>
</Button> </Button>
{hasCopiedTab && (
<Button icon="plus" variant="primary" fill="text" onClick={() => model.pasteTab()}>
<Trans i18nKey="dashboard.canvas-actions.paste-tab">Paste tab</Trans>
</Button>
)}
</div> </div>
)} )}
</div> </div>

@ -8,6 +8,7 @@ import { getDefaultVizPanel } from '../../utils/utils';
import { DashboardLayoutManager } from '../types/DashboardLayoutManager'; import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
import { addNewRowTo, addNewTabTo } from './addNew'; import { addNewRowTo, addNewTabTo } from './addNew';
import { useClipboardState } from './useClipboardState';
export interface Props { export interface Props {
layoutManager: DashboardLayoutManager; layoutManager: DashboardLayoutManager;
@ -15,6 +16,7 @@ export interface Props {
export function CanvasGridAddActions({ layoutManager }: Props) { export function CanvasGridAddActions({ layoutManager }: Props) {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const { hasCopiedPanel } = useClipboardState();
return ( return (
<div className={cx(styles.addAction, 'dashboard-canvas-add-button')}> <div className={cx(styles.addAction, 'dashboard-canvas-add-button')}>
@ -50,6 +52,18 @@ export function CanvasGridAddActions({ layoutManager }: Props) {
<Trans i18nKey="dashboard.canvas-actions.group-panels">Group panels</Trans> <Trans i18nKey="dashboard.canvas-actions.group-panels">Group panels</Trans>
</Button> </Button>
</Dropdown> </Dropdown>
{hasCopiedPanel && layoutManager.pastePanel && (
<Button
variant="primary"
fill="text"
icon="layers"
onClick={() => {
layoutManager.pastePanel?.();
}}
>
<Trans i18nKey="dashboard.canvas-actions.paste-panel">Paste panel</Trans>
</Button>
)}
</div> </div>
); );
} }

@ -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++;
}

@ -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,
};
}

@ -76,6 +76,11 @@ export interface DashboardLayoutManager<S = {}> extends SceneObject {
* Duplicate, like clone but with new keys * Duplicate, like clone but with new keys
*/ */
duplicate(): DashboardLayoutManager; duplicate(): DashboardLayoutManager;
/**
* Paste a panel from the clipboard
*/
pastePanel?(): void;
} }
export interface LayoutManagerSerializer { export interface LayoutManagerSerializer {

@ -38,7 +38,8 @@ export function serializeDefaultGridLayout(
export function deserializeDefaultGridLayout( export function deserializeDefaultGridLayout(
layout: DashboardV2Spec['layout'], layout: DashboardV2Spec['layout'],
elements: DashboardV2Spec['elements'], elements: DashboardV2Spec['elements'],
preload: boolean preload: boolean,
panelIdGenerator?: () => number
): DefaultGridLayoutManager { ): DefaultGridLayoutManager {
if (layout.kind !== 'GridLayout') { if (layout.kind !== 'GridLayout') {
throw new Error('Invalid layout kind'); throw new Error('Invalid layout kind');
@ -46,7 +47,7 @@ export function deserializeDefaultGridLayout(
return new DefaultGridLayoutManager({ return new DefaultGridLayoutManager({
grid: new SceneGridLayout({ grid: new SceneGridLayout({
isLazy: !(preload || contextSrv.user.authenticatedBy === 'render'), 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 elementGridItem: GridLayoutItemKind | undefined;
let x = 0, let x = 0,
y = 0, y = 0,
@ -223,34 +224,37 @@ function repeaterToLayoutItems(repeater: DashboardGridItem, isSnapshot = false):
} }
} }
function createSceneGridLayoutForItems(layout: GridLayoutKind, elements: Record<string, Element>): SceneGridItemLike[] { function createSceneGridLayoutForItems(
const gridElements = layout.spec.items; layout: GridLayoutKind,
elements: Record<string, Element>,
panelIdGenerator?: () => number
): SceneGridItemLike[] {
const gridItems = layout.spec.items;
return gridElements.map((element) => { return gridItems.map((item) => {
if (element.kind === 'GridLayoutItem') { if (item.kind === 'GridLayoutItem') {
const panel = elements[element.spec.element.name]; return deserializeGridItem(item, elements, panelIdGenerator);
} else if (item.kind === 'GridLayoutRow') {
if (!panel) { const children = item.spec.elements.map((gridElement) => {
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) => {
const panel = elements[getOriginalKey(gridElement.spec.element.name)]; const panel = elements[getOriginalKey(gridElement.spec.element.name)];
if (panel.kind === 'Panel' || panel.kind === 'LibraryPanel') { 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 { } else {
throw new Error(`Unknown element kind: ${gridElement.kind}`); throw new Error(`Unknown element kind: ${gridElement.kind}`);
} }
}); });
let behaviors: SceneObject[] | undefined; let behaviors: SceneObject[] | undefined;
if (element.spec.repeat) { if (item.spec.repeat) {
behaviors = [new RowRepeaterBehavior({ variableName: element.spec.repeat.value })]; behaviors = [new RowRepeaterBehavior({ variableName: item.spec.repeat.value })];
} }
return new SceneGridRow({ return new SceneGridRow({
y: element.spec.y, y: item.spec.y,
isCollapsed: element.spec.collapsed, isCollapsed: item.spec.collapsed,
title: element.spec.title, title: item.spec.title,
$behaviors: behaviors, $behaviors: behaviors,
actions: new RowActions({}), actions: new RowActions({}),
children, children,
@ -258,7 +262,7 @@ function createSceneGridLayoutForItems(layout: GridLayoutKind, elements: Record<
} else { } 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. // 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 //@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( function buildGridItem(
gridItem: GridLayoutItemSpec, gridItem: GridLayoutItemSpec,
panel: PanelKind | LibraryPanelKind, panel: PanelKind | LibraryPanelKind,
yOverride?: number yOverride?: number,
id?: number
): DashboardGridItem { ): DashboardGridItem {
let vizPanel: VizPanel; let vizPanel: VizPanel;
if (panel.kind === 'Panel') { if (panel.kind === 'Panel') {
vizPanel = buildVizPanel(panel); vizPanel = buildVizPanel(panel, id);
} else { } else {
vizPanel = buildLibraryPanel(panel); vizPanel = buildLibraryPanel(panel, id);
} }
return new DashboardGridItem({ return new DashboardGridItem({
key: `grid-item-${panel.spec.id}`, key: `grid-item-${id ?? panel.spec.id}`,
x: gridItem.x, x: gridItem.x,
y: yOverride ?? gridItem.y, y: yOverride ?? gridItem.y,
width: gridItem.repeat?.direction === 'h' ? 24 : gridItem.width, width: gridItem.repeat?.direction === 'h' ? 24 : gridItem.width,
@ -287,3 +292,21 @@ function buildGridItem(
maxPerRow: gridItem.repeat?.maxPerRow, 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);
}

@ -31,45 +31,46 @@ export function serializeAutoGridLayout(layoutManager: AutoGridLayoutManager): D
fillScreen: fillScreen === defaults.fillScreen ? undefined : fillScreen, fillScreen: fillScreen === defaults.fillScreen ? undefined : fillScreen,
...serializeAutoGridColumnWidth(columnWidth), ...serializeAutoGridColumnWidth(columnWidth),
...serializeAutoGridRowHeight(rowHeight), ...serializeAutoGridRowHeight(rowHeight),
items: layout.state.children.map((child) => { items: layout.state.children.map(serializeAutoGridItem),
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;
}),
}, },
}; };
} }
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( export function deserializeAutoGridLayout(
layout: DashboardV2Spec['layout'], layout: DashboardV2Spec['layout'],
elements: DashboardV2Spec['elements'] elements: DashboardV2Spec['elements'],
preload: boolean,
panelIdGenerator?: () => number
): AutoGridLayoutManager { ): AutoGridLayoutManager {
if (layout.kind !== 'AutoGridLayout') { if (layout.kind !== 'AutoGridLayout') {
throw new Error('Invalid layout kind'); throw new Error('Invalid layout kind');
@ -78,18 +79,7 @@ export function deserializeAutoGridLayout(
const defaults = defaultAutoGridLayoutSpec(); const defaults = defaultAutoGridLayoutSpec();
const { maxColumnCount, columnWidthMode, columnWidth, rowHeightMode, rowHeight, fillScreen } = layout.spec; const { maxColumnCount, columnWidthMode, columnWidth, rowHeightMode, rowHeight, fillScreen } = layout.spec;
const children = layout.spec.items.map((item) => { const children = layout.spec.items.map((item) => deserializeAutoGridItem(item, elements, panelIdGenerator));
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 columnWidthCombined = columnWidthMode === 'custom' ? columnWidth : columnWidthMode; const columnWidthCombined = columnWidthMode === 'custom' ? columnWidth : columnWidthMode;
const rowHeightCombined = rowHeightMode === 'custom' ? rowHeight : rowHeightMode; const rowHeightCombined = rowHeightMode === 'custom' ? rowHeight : rowHeightMode;
@ -123,3 +113,24 @@ function serializeAutoGridRowHeight(rowHeight: AutoGridRowHeight) {
rowHeight: typeof rowHeight === 'number' ? rowHeight : undefined, 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),
});
}

@ -12,65 +12,75 @@ export function serializeRowsLayout(layoutManager: RowsLayoutManager): Dashboard
return { return {
kind: 'RowsLayout', kind: 'RowsLayout',
spec: { spec: {
rows: layoutManager.state.rows.map((row) => { rows: layoutManager.state.rows.map(serializeRow),
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) { export function serializeRow(row: RowItem): RowsLayoutRowKind {
for (const behavior of row.state.$behaviors) { const layout = row.state.layout.serialize();
if (behavior instanceof RowItemRepeaterBehavior) { const rowKind: RowsLayoutRowKind = {
if (rowKind.spec.repeat) { kind: 'RowsLayoutRow',
throw new Error('Multiple repeaters are not supported'); spec: {
} title: row.state.title,
rowKind.spec.repeat = { value: behavior.state.variableName, mode: 'variable' }; collapse: row.state.collapse,
} layout: layout,
} fillScreen: row.state.fillScreen,
} hideHeader: row.state.hideHeader,
return rowKind;
}),
}, },
}; };
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( export function deserializeRowsLayout(
layout: DashboardV2Spec['layout'], layout: DashboardV2Spec['layout'],
elements: DashboardV2Spec['elements'], elements: DashboardV2Spec['elements'],
preload: boolean preload: boolean,
panelIdGenerator?: () => number
): RowsLayoutManager { ): RowsLayoutManager {
if (layout.kind !== 'RowsLayout') { if (layout.kind !== 'RowsLayout') {
throw new Error('Invalid layout kind'); throw new Error('Invalid layout kind');
} }
const rows = layout.spec.rows.map((row) => { const rows = layout.spec.rows.map((row) => deserializeRow(row, elements, preload, panelIdGenerator));
const layout = row.spec.layout; return new RowsLayoutManager({ rows });
const behaviors: SceneObject[] = []; }
if (row.spec.repeat) {
behaviors.push(new RowItemRepeaterBehavior({ variableName: row.spec.repeat.value }));
}
return new RowItem({ export function deserializeRow(
title: row.spec.title, row: RowsLayoutRowKind,
collapse: row.spec.collapse, elements: DashboardV2Spec['elements'],
hideHeader: row.spec.hideHeader, preload: boolean,
fillScreen: row.spec.fillScreen, panelIdGenerator?: () => number
$behaviors: behaviors, ): RowItem {
layout: layoutDeserializerRegistry.get(layout.kind).deserialize(layout, elements, preload), const layout = row.spec.layout;
conditionalRendering: getConditionalRendering(row), 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),
}); });
return new RowsLayoutManager({ rows });
} }

@ -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 { TabItem } from '../../scene/layout-tabs/TabItem';
import { TabsLayoutManager } from '../../scene/layout-tabs/TabsLayoutManager'; import { TabsLayoutManager } from '../../scene/layout-tabs/TabsLayoutManager';
@ -9,16 +9,18 @@ export function serializeTabsLayout(layoutManager: TabsLayoutManager): Dashboard
return { return {
kind: 'TabsLayout', kind: 'TabsLayout',
spec: { spec: {
tabs: layoutManager.state.tabs.map((tab) => { tabs: layoutManager.state.tabs.map(serializeTab),
const layout = tab.state.layout.serialize(); },
return { };
kind: 'TabsLayoutTab', }
spec: {
title: tab.state.title, export function serializeTab(tab: TabItem): TabsLayoutTabKind {
layout: layout, 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( export function deserializeTabsLayout(
layout: DashboardV2Spec['layout'], layout: DashboardV2Spec['layout'],
elements: DashboardV2Spec['elements'], elements: DashboardV2Spec['elements'],
preload: boolean preload: boolean,
panelIdGenerator?: () => number
): TabsLayoutManager { ): TabsLayoutManager {
if (layout.kind !== 'TabsLayout') { if (layout.kind !== 'TabsLayout') {
throw new Error('Invalid layout kind'); throw new Error('Invalid layout kind');
} }
const tabs = layout.spec.tabs.map((tab) => { const tabs = layout.spec.tabs.map((tab) => {
const layout = tab.spec.layout; return deserializeTab(tab, elements, preload, panelIdGenerator);
return new TabItem({
title: tab.spec.title,
layout: layoutDeserializerRegistry.get(layout.kind).deserialize(layout, elements, preload),
});
}); });
return new TabsLayoutManager({ tabs }); 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),
});
}

@ -12,7 +12,8 @@ interface LayoutSerializerRegistryItem extends RegistryItem {
deserialize: ( deserialize: (
layout: DashboardV2Spec['layout'], layout: DashboardV2Spec['layout'],
elements: DashboardV2Spec['elements'], elements: DashboardV2Spec['elements'],
preload: boolean preload: boolean,
panelIdGenerator?: () => number
) => DashboardLayoutManager; ) => DashboardLayoutManager;
} }

@ -25,18 +25,22 @@ import { ConditionalRendering } from '../../conditional-rendering/ConditionalRen
import { ConditionalRenderingGroup } from '../../conditional-rendering/ConditionalRenderingGroup'; import { ConditionalRenderingGroup } from '../../conditional-rendering/ConditionalRenderingGroup';
import { conditionalRenderingSerializerRegistry } from '../../conditional-rendering/serializers'; import { conditionalRenderingSerializerRegistry } from '../../conditional-rendering/serializers';
import { DashboardDatasourceBehaviour } from '../../scene/DashboardDatasourceBehaviour'; import { DashboardDatasourceBehaviour } from '../../scene/DashboardDatasourceBehaviour';
import { DashboardScene } from '../../scene/DashboardScene';
import { LibraryPanelBehavior } from '../../scene/LibraryPanelBehavior'; import { LibraryPanelBehavior } from '../../scene/LibraryPanelBehavior';
import { VizPanelLinks, VizPanelLinksMenu } from '../../scene/PanelLinks'; import { VizPanelLinks, VizPanelLinksMenu } from '../../scene/PanelLinks';
import { panelLinksBehavior, panelMenuBehavior } from '../../scene/PanelMenuBehavior'; import { panelLinksBehavior, panelMenuBehavior } from '../../scene/PanelMenuBehavior';
import { PanelNotices } from '../../scene/PanelNotices'; import { PanelNotices } from '../../scene/PanelNotices';
import { PanelTimeRange } from '../../scene/PanelTimeRange'; import { PanelTimeRange } from '../../scene/PanelTimeRange';
import { AngularDeprecation } from '../../scene/angular/AngularDeprecation'; 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 { setDashboardPanelContext } from '../../scene/setDashboardPanelContext';
import { DashboardLayoutManager } from '../../scene/types/DashboardLayoutManager'; import { DashboardLayoutManager } from '../../scene/types/DashboardLayoutManager';
import { getVizPanelKeyForPanelId } from '../../utils/utils'; import { getVizPanelKeyForPanelId } from '../../utils/utils';
import { createElements, vizPanelToSchemaV2 } from '../transformSceneToSaveModelSchemaV2';
import { transformMappingsToV1 } from '../transformToV1TypesUtils'; import { transformMappingsToV1 } from '../transformToV1TypesUtils';
export function buildVizPanel(panel: PanelKind): VizPanel { export function buildVizPanel(panel: PanelKind, id?: number): VizPanel {
const titleItems: SceneObject[] = []; const titleItems: SceneObject[] = [];
if (config.featureToggles.angularDeprecationUI) { if (config.featureToggles.angularDeprecationUI) {
@ -56,7 +60,7 @@ export function buildVizPanel(panel: PanelKind): VizPanel {
const timeOverrideShown = (queryOptions.timeFrom || queryOptions.timeShift) && !queryOptions.hideTimeOverride; const timeOverrideShown = (queryOptions.timeFrom || queryOptions.timeShift) && !queryOptions.hideTimeOverride;
const vizPanelState: VizPanelState = { const vizPanelState: VizPanelState = {
key: getVizPanelKeyForPanelId(panel.spec.id), key: getVizPanelKeyForPanelId(id ?? panel.spec.id),
title: panel.spec.title?.substring(0, 5000), title: panel.spec.title?.substring(0, 5000),
description: panel.spec.description, description: panel.spec.description,
pluginId: panel.spec.vizConfig.kind, pluginId: panel.spec.vizConfig.kind,
@ -90,7 +94,7 @@ export function buildVizPanel(panel: PanelKind): VizPanel {
return new VizPanel(vizPanelState); return new VizPanel(vizPanelState);
} }
export function buildLibraryPanel(panel: LibraryPanelKind): VizPanel { export function buildLibraryPanel(panel: LibraryPanelKind, id?: number): VizPanel {
const titleItems: SceneObject[] = []; const titleItems: SceneObject[] = [];
if (config.featureToggles.angularDeprecationUI) { if (config.featureToggles.angularDeprecationUI) {
@ -107,7 +111,7 @@ export function buildLibraryPanel(panel: LibraryPanelKind): VizPanel {
titleItems.push(new PanelNotices()); titleItems.push(new PanelNotices());
const vizPanelState: VizPanelState = { const vizPanelState: VizPanelState = {
key: getVizPanelKeyForPanelId(panel.spec.id), key: getVizPanelKeyForPanelId(id ?? panel.spec.id),
titleItems, titleItems,
$behaviors: [ $behaviors: [
new LibraryPanelBehavior({ new LibraryPanelBehavior({
@ -242,3 +246,19 @@ export function getConditionalRendering(item: RowsLayoutRowKind | AutoGridLayout
return new ConditionalRendering({ rootGroup: rootGroup }); 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);
}

@ -348,7 +348,7 @@ function getVizPanelQueryOptions(vizPanel: VizPanel): QueryOptionsSpec {
return queryOptions; return queryOptions;
} }
function createElements(panels: Element[], scene: DashboardScene): Record<string, Element> { export function createElements(panels: Element[], scene: DashboardScene): Record<string, Element> {
return panels.reduce<Record<string, Element>>((elements, panel) => { return panels.reduce<Record<string, Element>>((elements, panel) => {
let elementKey = scene.serializer.getElementIdForPanel(panel.spec.id); let elementKey = scene.serializer.getElementIdForPanel(panel.spec.id);
elements[elementKey!] = panel; elements[elementKey!] = panel;

@ -1417,7 +1417,10 @@
"group-into-tab": "Group into tab", "group-into-tab": "Group into tab",
"group-panels": "Group panels", "group-panels": "Group panels",
"new-row": "New row", "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": { "conditional-rendering": {
"data": { "data": {
@ -1523,10 +1526,6 @@
"heading": "Panel", "heading": "Panel",
"title": "A container for visualizations and other content" "title": "A container for visualizations and other content"
}, },
"paste-panel": {
"heading": "Paste panel",
"title": "Paste a panel from the clipboard"
},
"row": { "row": {
"heading": "Row", "heading": "Row",
"title": "A group of panels with an optional header" "title": "A group of panels with an optional header"

Loading…
Cancel
Save