Dashboard: Switch back to prev layout restores state (#102304)

This commit is contained in:
Torkel Ödegaard
2025-03-19 10:41:32 +01:00
committed by GitHub
parent ac4b2a3200
commit a9d81ba605
7 changed files with 155 additions and 118 deletions
@@ -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<DashboardSceneState> impleme
DashboardMeta | DashboardWithAccessInfo<DashboardV2Spec>['metadata']
>;
private _layoutRestorer = new LayoutRestorer();
public constructor(state: Partial<DashboardSceneState>, serializerVersion: 'v1' | 'v2' = 'v1') {
super({
title: 'Dashboard',
@@ -613,8 +616,8 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> 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 {
@@ -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;
}
@@ -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<RowItemState>) {
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[] {
@@ -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<TabItemState>) {
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[] {
@@ -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 (
<div role="radiogroup" className={styles.radioGroup}>
{options.map((opt) => {
@@ -30,11 +40,10 @@ export function DashboardLayoutSelector({ layoutManager }: Props) {
case 'rows-layout':
return (
<LayoutRadioButton
label={opt.name}
id={opt.id}
description={opt.description!}
item={opt}
isSelected={layoutManager.descriptor.id === opt.id}
onSelect={() => changeLayoutTo(layoutManager, opt)}
onSelect={onChangeLayout}
key={opt.id}
>
<div className={styles.rowsLayoutViz}>
{/* eslint-disable-next-line @grafana/no-untranslated-strings */}
@@ -53,11 +62,10 @@ export function DashboardLayoutSelector({ layoutManager }: Props) {
case 'tabs-layout':
return (
<LayoutRadioButton
label={opt.name}
id={opt.id}
description={opt.description!}
item={opt}
isSelected={layoutManager.descriptor.id === opt.id}
onSelect={() => changeLayoutTo(layoutManager, opt)}
onSelect={onChangeLayout}
key={opt.id}
>
<Stack direction="column" gap={0.5} height={'100%'}>
<div className={styles.tabsBar}>
@@ -78,11 +86,10 @@ export function DashboardLayoutSelector({ layoutManager }: Props) {
case 'responsive-grid':
return (
<LayoutRadioButton
label={opt.name}
id={opt.id}
description={opt.description!}
item={opt}
isSelected={layoutManager.descriptor.id === opt.id}
onSelect={() => changeLayoutTo(layoutManager, opt)}
onSelect={onChangeLayout}
key={opt.id}
>
<div className={styles.autoGridViz}>
<GridCell />
@@ -96,11 +103,10 @@ export function DashboardLayoutSelector({ layoutManager }: Props) {
default:
return (
<LayoutRadioButton
label={opt.name}
id={opt.id}
description={opt.description!}
item={opt}
isSelected={layoutManager.descriptor.id === opt.id}
onSelect={() => changeLayoutTo(layoutManager, opt)}
onSelect={onChangeLayout}
key={opt.id}
>
<div className={styles.customGridViz}>
<GridCell colSpan={2} />
@@ -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)
<div className={styles.radioButtonOuter}>
<label
htmlFor={`layout-${id}`}
htmlFor={`layout-${item.id}`}
tabIndex={0}
className={cx(styles.radioButton, isSelected && styles.radioButtonActive)}
>
{children}
<Stack direction="column" gap={1} justifyContent="space-between" grow={1}>
<Text weight="medium">{label}</Text>
<Text weight="medium">{item.name}</Text>
<Text variant="bodySmall" color="secondary">
{description}
{item.description!}
</Text>
</Stack>
</label>
<div className={styles.radioDot}>
<RadioButtonDot id={`layout-${id}`} name={'layout'} label={<></>} onChange={onSelect} checked={isSelected} />
<RadioButtonDot
id={`layout-${item.id}`}
name={'layout'}
label={<></>}
onChange={() => onSelect(item)}
checked={isSelected}
/>
</div>
</div>
);
@@ -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({
@@ -0,0 +1,25 @@
import { vizPanelToSchemaV2 } from '../../serialization/transformSceneToSaveModelSchemaV2';
import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
export class LayoutRestorer {
private layoutMap: Record<string, DashboardLayoutManager> = {};
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;
}
}
@@ -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) {