diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index cfbe94e0f7f..36a8572a65b 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -70,6 +70,7 @@ import { isUsingAngularDatasourcePlugin, isUsingAngularPanelPlugin } from './ang import { setupKeyboardShortcuts } from './keyboardShortcuts'; import { DashboardGridItem } from './layout-default/DashboardGridItem'; import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager'; +import { LayoutRestorer } from './layouts-shared/LayoutRestorer'; import { addNewRowTo, addNewTabTo } from './layouts-shared/addNew'; import { DashboardLayoutManager } from './types/DashboardLayoutManager'; import { LayoutParent } from './types/LayoutParent'; @@ -174,6 +175,8 @@ export class DashboardScene extends SceneObjectBase impleme DashboardMeta | DashboardWithAccessInfo['metadata'] >; + private _layoutRestorer = new LayoutRestorer(); + public constructor(state: Partial, serializerVersion: 'v1' | 'v2' = 'v1') { super({ title: 'Dashboard', @@ -613,8 +616,8 @@ export class DashboardScene extends SceneObjectBase impleme } public switchLayout(layout: DashboardLayoutManager) { - this.setState({ body: layout }); - layout.activateRepeaters?.(); + this.setState({ body: this._layoutRestorer.getLayout(layout, this.state.body) }); + this.state.body.activateRepeaters?.(); } public getLayout(): DashboardLayoutManager { 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 13a4a3c160c..d9749c79f17 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx @@ -426,7 +426,7 @@ export class DefaultGridLayoutManager currentX += panelWidth; - if (currentX + panelWidth >= GRID_COLUMN_COUNT) { + if (currentX + panelWidth > GRID_COLUMN_COUNT) { currentX = 0; currentY += panelHeight; } 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 a518ac891f4..d85eb832757 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx @@ -5,6 +5,7 @@ import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components import { ConditionalRendering } from '../../conditional-rendering/ConditionalRendering'; import { getDefaultVizPanel } from '../../utils/utils'; import { ResponsiveGridLayoutManager } from '../layout-responsive-grid/ResponsiveGridLayoutManager'; +import { LayoutRestorer } from '../layouts-shared/LayoutRestorer'; import { BulkActionElement } from '../types/BulkActionElement'; import { DashboardLayoutManager } from '../types/DashboardLayoutManager'; import { EditableDashboardElement, EditableDashboardElementInfo } from '../types/EditableDashboardElement'; @@ -36,6 +37,7 @@ export class RowItem }); public readonly isEditableDashboardElement = true; + private _layoutRestorer = new LayoutRestorer(); public constructor(state?: Partial) { super({ @@ -71,7 +73,7 @@ export class RowItem } public switchLayout(layout: DashboardLayoutManager) { - this.setState({ layout }); + this.setState({ layout: this._layoutRestorer.getLayout(layout, this.state.layout) }); } public useEditPaneOptions(): OptionsPaneCategoryDescriptor[] { 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 493ed0c18b6..e32383f9eaa 100644 --- a/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx @@ -4,6 +4,7 @@ import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components import { getDefaultVizPanel } from '../../utils/utils'; import { ResponsiveGridLayoutManager } from '../layout-responsive-grid/ResponsiveGridLayoutManager'; +import { LayoutRestorer } from '../layouts-shared/LayoutRestorer'; import { BulkActionElement } from '../types/BulkActionElement'; import { DashboardLayoutManager } from '../types/DashboardLayoutManager'; import { EditableDashboardElement, EditableDashboardElementInfo } from '../types/EditableDashboardElement'; @@ -30,6 +31,7 @@ export class TabItem }); public readonly isEditableDashboardElement = true; + private _layoutRestorer = new LayoutRestorer(); constructor(state?: Partial) { super({ @@ -52,7 +54,7 @@ export class TabItem } public switchLayout(layout: DashboardLayoutManager) { - this.setState({ layout }); + this.setState({ layout: this._layoutRestorer.getLayout(layout, this.state.layout) }); } public useEditPaneOptions(): OptionsPaneCategoryDescriptor[] { diff --git a/public/app/features/dashboard-scene/scene/layouts-shared/DashboardLayoutSelector.tsx b/public/app/features/dashboard-scene/scene/layouts-shared/DashboardLayoutSelector.tsx index ee6fc32af86..08102bc1648 100644 --- a/public/app/features/dashboard-scene/scene/layouts-shared/DashboardLayoutSelector.tsx +++ b/public/app/features/dashboard-scene/scene/layouts-shared/DashboardLayoutSelector.tsx @@ -1,5 +1,5 @@ import { css, cx } from '@emotion/css'; -import { useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { RadioButtonDot, Stack, useStyles2, Text } from '@grafana/ui'; @@ -20,9 +20,19 @@ export interface Props { export function DashboardLayoutSelector({ layoutManager }: Props) { const isGridLayout = layoutManager.descriptor.isGridLayout; const options = layoutRegistry.list().filter((layout) => layout.isGridLayout === isGridLayout); - const styles = useStyles2(getStyles); + const onChangeLayout = useCallback( + (newLayout: LayoutRegistryItem) => { + const layoutParent = layoutManager.parent; + + if (layoutParent && isLayoutParent(layoutParent)) { + layoutParent.switchLayout(newLayout.createFromLayout(layoutManager)); + } + }, + [layoutManager] + ); + return (
{options.map((opt) => { @@ -30,11 +40,10 @@ export function DashboardLayoutSelector({ layoutManager }: Props) { case 'rows-layout': return ( changeLayoutTo(layoutManager, opt)} + onSelect={onChangeLayout} + key={opt.id} >
{/* eslint-disable-next-line @grafana/no-untranslated-strings */} @@ -53,11 +62,10 @@ export function DashboardLayoutSelector({ layoutManager }: Props) { case 'tabs-layout': return ( changeLayoutTo(layoutManager, opt)} + onSelect={onChangeLayout} + key={opt.id} >
@@ -78,11 +86,10 @@ export function DashboardLayoutSelector({ layoutManager }: Props) { case 'responsive-grid': return ( changeLayoutTo(layoutManager, opt)} + onSelect={onChangeLayout} + key={opt.id} >
@@ -96,11 +103,10 @@ export function DashboardLayoutSelector({ layoutManager }: Props) { default: return ( changeLayoutTo(layoutManager, opt)} + onSelect={onChangeLayout} + key={opt.id} >
@@ -120,15 +126,13 @@ export function DashboardLayoutSelector({ layoutManager }: Props) { } interface LayoutRadioButtonProps { - label: string; - id: string; - description: string; + item: LayoutRegistryItem; isSelected: boolean; - onSelect: () => void; + onSelect: (item: LayoutRegistryItem) => void; children: React.ReactNode; } -function LayoutRadioButton({ label, id, description, isSelected, children, onSelect }: LayoutRadioButtonProps) { +function LayoutRadioButton({ item, isSelected, children, onSelect }: LayoutRadioButtonProps) { const styles = useStyles2(getStyles); return ( @@ -136,20 +140,26 @@ function LayoutRadioButton({ label, id, description, isSelected, children, onSel // label (as the RadioButtonDot has a label element and they can't nest)
- } onChange={onSelect} checked={isSelected} /> + } + onChange={() => onSelect(item)} + checked={isSelected} + />
); @@ -191,13 +201,6 @@ export function useLayoutCategory(layoutManager: DashboardLayoutManager) { }, [layoutManager]); } -function changeLayoutTo(currentLayout: DashboardLayoutManager, newLayoutDescriptor: LayoutRegistryItem) { - const layoutParent = currentLayout.parent; - if (layoutParent && isLayoutParent(layoutParent)) { - layoutParent.switchLayout(newLayoutDescriptor.createFromLayout(currentLayout)); - } -} - const getStyles = (theme: GrafanaTheme2) => { return { radioButtonOuter: css({ diff --git a/public/app/features/dashboard-scene/scene/layouts-shared/LayoutRestorer.ts b/public/app/features/dashboard-scene/scene/layouts-shared/LayoutRestorer.ts new file mode 100644 index 00000000000..9abd16994ef --- /dev/null +++ b/public/app/features/dashboard-scene/scene/layouts-shared/LayoutRestorer.ts @@ -0,0 +1,25 @@ +import { vizPanelToSchemaV2 } from '../../serialization/transformSceneToSaveModelSchemaV2'; +import { DashboardLayoutManager } from '../types/DashboardLayoutManager'; + +export class LayoutRestorer { + private layoutMap: Record = {}; + + public getLayout( + newLayout: DashboardLayoutManager, + currentLayout: DashboardLayoutManager + ): DashboardLayoutManager | undefined { + // If we have an old version of this layout and panels are the same we can reuse it + const prevLayout = this.layoutMap[newLayout.descriptor.id]; + if (prevLayout) { + const oldPanelSchema = prevLayout.getVizPanels().map(vizPanelToSchemaV2); + const newPanelSchema = newLayout.getVizPanels().map(vizPanelToSchemaV2); + if (JSON.stringify(oldPanelSchema) === JSON.stringify(newPanelSchema)) { + return prevLayout; + } + } + + this.layoutMap[currentLayout.descriptor.id] = currentLayout; + + return newLayout; + } +} diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts index 77299318149..605848c5b53 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts @@ -143,88 +143,90 @@ function getLiveNow(state: DashboardSceneState) { function getElements(scene: DashboardScene) { const panels = scene.state.body.getVizPanels() ?? []; - const panelsArray = panels.map((vizPanel: VizPanel) => { - if (isLibraryPanel(vizPanel)) { - const behavior = getLibraryPanelBehavior(vizPanel)!; - const elementSpec: LibraryPanelKind = { - kind: 'LibraryPanel', - spec: { - id: getPanelIdForVizPanel(vizPanel), - title: vizPanel.state.title, - libraryPanel: { - uid: behavior.state.uid, - name: behavior.state.name, - }, - }, - }; - return elementSpec; - } else { - // Handle type conversion for color mode - const rawColor = vizPanel.state.fieldConfig.defaults.color; - let color: FieldColor | undefined; - - if (rawColor) { - const convertedMode = colorIdEnumToColorIdV2(rawColor.mode); - - if (convertedMode) { - color = { - ...rawColor, - mode: convertedMode, - }; - } - } - - // Remove null from the defaults because schema V2 doesn't support null for these fields - const decimals = vizPanel.state.fieldConfig.defaults.decimals ?? undefined; - const min = vizPanel.state.fieldConfig.defaults.min ?? undefined; - const max = vizPanel.state.fieldConfig.defaults.max ?? undefined; - - const defaults: FieldConfig = Object.fromEntries( - Object.entries({ - ...vizPanel.state.fieldConfig.defaults, - decimals, - min, - max, - color, - }).filter(([_, value]) => value !== undefined) - ); - - const vizFieldConfig: FieldConfigSource = { - ...vizPanel.state.fieldConfig, - defaults, - }; - - const elementSpec: PanelKind = { - kind: 'Panel', - spec: { - id: getPanelIdForVizPanel(vizPanel), - title: vizPanel.state.title, - description: vizPanel.state.description ?? '', - links: getPanelLinks(vizPanel), - data: { - kind: 'QueryGroup', - spec: { - queries: getVizPanelQueries(vizPanel), - transformations: getVizPanelTransformations(vizPanel), - queryOptions: getVizPanelQueryOptions(vizPanel), - }, - }, - vizConfig: { - kind: vizPanel.state.pluginId, - spec: { - pluginVersion: vizPanel.state.pluginVersion ?? '', - options: vizPanel.state.options, - fieldConfig: vizFieldConfig ?? defaultFieldConfigSource(), - }, - }, - }, - }; - return elementSpec; - } - }); + const panelsArray = panels.map(vizPanelToSchemaV2); return createElements(panelsArray, scene); } +export function vizPanelToSchemaV2(vizPanel: VizPanel): PanelKind | LibraryPanelKind { + if (isLibraryPanel(vizPanel)) { + const behavior = getLibraryPanelBehavior(vizPanel)!; + const elementSpec: LibraryPanelKind = { + kind: 'LibraryPanel', + spec: { + id: getPanelIdForVizPanel(vizPanel), + title: vizPanel.state.title, + libraryPanel: { + uid: behavior.state.uid, + name: behavior.state.name, + }, + }, + }; + return elementSpec; + } + + // Handle type conversion for color mode + const rawColor = vizPanel.state.fieldConfig.defaults.color; + let color: FieldColor | undefined; + + if (rawColor) { + const convertedMode = colorIdEnumToColorIdV2(rawColor.mode); + + if (convertedMode) { + color = { + ...rawColor, + mode: convertedMode, + }; + } + } + + // Remove null from the defaults because schema V2 doesn't support null for these fields + const decimals = vizPanel.state.fieldConfig.defaults.decimals ?? undefined; + const min = vizPanel.state.fieldConfig.defaults.min ?? undefined; + const max = vizPanel.state.fieldConfig.defaults.max ?? undefined; + + const defaults: FieldConfig = Object.fromEntries( + Object.entries({ + ...vizPanel.state.fieldConfig.defaults, + decimals, + min, + max, + color, + }).filter(([_, value]) => value !== undefined) + ); + + const vizFieldConfig: FieldConfigSource = { + ...vizPanel.state.fieldConfig, + defaults, + }; + + const elementSpec: PanelKind = { + kind: 'Panel', + spec: { + id: getPanelIdForVizPanel(vizPanel), + title: vizPanel.state.title, + description: vizPanel.state.description ?? '', + links: getPanelLinks(vizPanel), + data: { + kind: 'QueryGroup', + spec: { + queries: getVizPanelQueries(vizPanel), + transformations: getVizPanelTransformations(vizPanel), + queryOptions: getVizPanelQueryOptions(vizPanel), + }, + }, + vizConfig: { + kind: vizPanel.state.pluginId, + spec: { + pluginVersion: vizPanel.state.pluginVersion ?? '', + options: vizPanel.state.options, + fieldConfig: vizFieldConfig ?? defaultFieldConfigSource(), + }, + }, + }, + }; + return elementSpec; +} + function getPanelLinks(panel: VizPanel): DataLink[] { const vizLinks = dashboardSceneGraph.getPanelLinks(panel); if (vizLinks) {