Dashboard: Panel edit undo/redo and other edit actions should trigger repeat of grid/auto items (#110606)

* Panel edit undo-redo

* Update

* Update

* fix lint & test

* Try switching to source tab

* Added temp fix for old editing ux

* Update
This commit is contained in:
Torkel Ödegaard
2025-09-08 14:06:37 +02:00
committed by GitHub
parent 3c5c6b8185
commit 958f5a7c52
7 changed files with 98 additions and 124 deletions
@@ -1,11 +1,11 @@
import { SceneObjectState, SceneObjectBase, SceneObject, sceneGraph, VizPanel } from '@grafana/scenes';
import { SceneObjectState, SceneObjectBase, SceneObject, sceneGraph } from '@grafana/scenes';
import {
ElementSelectionContextItem,
ElementSelectionContextState,
ElementSelectionOnSelectOptions,
} from '@grafana/ui';
import { isDashboardLayoutItem } from '../scene/types/DashboardLayoutItem';
import { TabItem } from '../scene/layout-tabs/TabItem';
import { isRepeatCloneOrChildOf } from '../utils/clone';
import { getDashboardSceneFor } from '../utils/utils';
@@ -44,6 +44,12 @@ export class DashboardEditPane extends SceneObjectBase<DashboardEditPaneState> {
this.addActivationHandler(this.onActivate.bind(this));
}
private panelEditAction?: DashboardEditActionEvent;
public setPanelEditAction(editAction: DashboardEditActionEvent) {
this.panelEditAction = editAction;
}
private onActivate() {
const dashboard = getDashboardSceneFor(this);
@@ -76,6 +82,22 @@ export class DashboardEditPane extends SceneObjectBase<DashboardEditPaneState> {
this.forceRender();
})
);
if (this.panelEditAction) {
this.performPanelEditAction(this.panelEditAction);
this.panelEditAction = undefined;
}
}
private performPanelEditAction(action: DashboardEditActionEvent) {
// Some layout items are not yet active when leaving panel edit, let's wait for them to activate
if (!action.payload.source.isActive) {
trySwitchingToSourceTab(action.payload.source);
setTimeout(() => this.performPanelEditAction(action));
return;
}
action.payload.source.publishEvent(action, true);
}
/**
@@ -93,15 +115,6 @@ export class DashboardEditPane extends SceneObjectBase<DashboardEditPaneState> {
this.performAction(action);
this.setState({ undoStack: [...this.state.undoStack, action] });
// Notify repeaters that something changed
if (action.source instanceof VizPanel) {
const layoutElement = action.source.parent!;
if (isDashboardLayoutItem(layoutElement) && layoutElement.editingCompleted) {
layoutElement.editingCompleted(true);
}
}
}
/**
@@ -258,3 +271,19 @@ export class DashboardEditPane extends SceneObjectBase<DashboardEditPaneState> {
this.state.selection?.markAsNewElement();
}
}
function trySwitchingToSourceTab(source: SceneObject) {
if (source.parent === undefined) {
return;
}
if (source.parent instanceof TabItem) {
const tab = source.parent;
const tabsLayout = source.parent.getParentLayout();
if (tabsLayout.state.currentTabSlug !== tab.getSlug()) {
tabsLayout.switchToTab(tab);
}
} else {
trySwitchingToSourceTab(source.parent);
}
}
@@ -24,6 +24,7 @@ import { DashboardEditActionEvent } from '../edit-pane/shared';
import { DashboardSceneChangeTracker } from '../saving/DashboardSceneChangeTracker';
import { getPanelChanges } from '../saving/getDashboardChanges';
import { UNCONFIGURED_PANEL_PLUGIN_ID } from '../scene/UnconfiguredPanel';
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
import { DashboardLayoutItem, isDashboardLayoutItem } from '../scene/types/DashboardLayoutItem';
import { vizPanelToPanel } from '../serialization/transformSceneToSaveModel';
import {
@@ -96,13 +97,50 @@ export class PanelEditor extends SceneObjectBase<PanelEditorState> {
this.waitForPlugin();
return () => {
this._layoutItem.editingCompleted?.(this.state.isDirty || this._changesHaveBeenMade);
this.commitChanges();
if (deactivateParents) {
deactivateParents();
}
};
}
private commitChanges() {
if (!this.state.isDirty && !this._changesHaveBeenMade) {
// Nothing to commit
return;
}
const layoutItem = this._layoutItem;
const changedState = layoutItem.state;
const originalState = this._layoutItemState!;
// Temp fix for old edit mode
if (this._layoutItem instanceof DashboardGridItem && !config.featureToggles.dashboardNewLayouts) {
this._layoutItem.handleEditChange();
return;
}
const editAction = new DashboardEditActionEvent({
description: t('dashboard.edit-actions.panel-edit', 'Panel changes'),
source: this._layoutItem,
perform: () => {
// Because panel edit makes changes directly to layout item & panel
// we only need to do this in case we want to re-perform after undo
if (layoutItem.state !== changedState) {
layoutItem.setState(changedState);
}
},
undo: () => layoutItem!.setState(originalState),
});
// sadly we cannot publish this event directly here as the main dashboard edit / undo system
// is not active while panel edit is active so we have to let the edit pane (which owns undo/redo)
// publish this event when it activates
const dashboard = getDashboardSceneFor(this);
dashboard.state.editPane.setPanelEditAction(editAction);
}
private waitForPlugin(retry = 0) {
const panel = this.getPanel();
const plugin = panel.getPlugin();
@@ -166,8 +204,6 @@ export class PanelEditor extends SceneObjectBase<PanelEditorState> {
if (this.state.isInitializing) {
this.setOriginalState(this.state.panelRef);
this._layoutItem.editingStarted?.();
this._setupChangeDetection();
this._updateDataPane(plugin);
@@ -14,6 +14,7 @@ import {
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { ConditionalRenderingGroup } from '../../conditional-rendering/group/ConditionalRenderingGroup';
import { DashboardStateChangedEvent } from '../../edit-pane/shared';
import { getCloneKey, getLocalVariableValueSet } from '../../utils/clone';
import { getMultiVariableValues } from '../../utils/utils';
import { scrollCanvasElementIntoView } from '../layouts-shared/scrollCanvasElementIntoView';
@@ -54,6 +55,8 @@ export class AutoGridItem extends SceneObjectBase<AutoGridItemState> implements
this.performRepeat();
}
this._subs.add(this.subscribeToEvent(DashboardStateChangedEvent, () => this.handleEditChange()));
const deactivate = this.state.conditionalRendering?.activate();
return () => {
@@ -167,16 +170,8 @@ export class AutoGridItem extends SceneObjectBase<AutoGridItemState> implements
};
}
public editingStarted() {
if (!this.state.variableName) {
return;
}
}
public editingCompleted(withChanges: boolean) {
if (withChanges) {
this._prevRepeatValues = undefined;
}
public handleEditChange() {
this._prevRepeatValues = undefined;
this.performRepeat();
}
@@ -3,6 +3,7 @@ import { setPluginImportUtils } from '@grafana/runtime';
import { SceneGridLayout, SceneVariableSet, TestVariable, VizPanel } from '@grafana/scenes';
import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from 'app/features/variables/constants';
import { DashboardEditActionEvent } from '../../edit-pane/shared';
import { activateFullSceneTree, buildPanelRepeaterScene } from '../../utils/test-utils';
import { DashboardScene } from '../DashboardScene';
@@ -130,89 +131,13 @@ describe('PanelRepeaterGridItem', () => {
vizPanel.setState({ title: 'Changed' });
panel.editingCompleted(true);
// mimic returning to dashboard
activateFullSceneTree(scene);
await new Promise((r) => setTimeout(r, 10));
expect(panel.state.repeatedPanels?.length).toBe(4);
expect((panel.state.repeatedPanels![0] as VizPanel).state.title).toBe('Changed');
});
it('Should only redo the repeat of an edited panel, not all panels in dashboard', async () => {
const panel = new DashboardGridItem({
variableName: 'server',
repeatedPanels: [],
body: new VizPanel({
title: 'Panel $server',
}),
});
const panel2 = new DashboardGridItem({
variableName: 'server',
repeatedPanels: [],
body: new VizPanel({
title: 'Panel $server 2',
}),
});
const variable = new TestVariable({
name: 'server',
query: 'A.*',
value: ALL_VARIABLE_VALUE,
text: ALL_VARIABLE_TEXT,
isMulti: true,
includeAll: true,
delayMs: 0,
optionsToReturn: [
{ label: 'A', value: '1' },
{ label: 'B', value: '2' },
{ label: 'C', value: '3' },
{ label: 'D', value: '4' },
{ label: 'E', value: '5' },
],
});
const scene = new DashboardScene({
$variables: new SceneVariableSet({
variables: [variable],
}),
body: new DefaultGridLayoutManager({
grid: new SceneGridLayout({
children: [panel, panel2],
}),
}),
});
const deactivate = activateFullSceneTree(scene);
panel.publishEvent(new DashboardEditActionEvent({ source: panel, perform: () => {}, undo: () => {} }), true);
await new Promise((r) => setTimeout(r, 10));
expect(panel.state.repeatedPanels?.length).toBe(4);
const vizPanel = panel.state.body as VizPanel;
expect(vizPanel.state.title).toBe('Panel $server');
// mimic going to panel edit
deactivate();
await new Promise((r) => setTimeout(r, 10));
vizPanel.setState({ title: 'Changed' });
panel.editingCompleted(true);
const performRepeatMock = jest.spyOn(panel, 'performRepeat');
// mimic returning to dashboard
activateFullSceneTree(scene);
await new Promise((r) => setTimeout(r, 10));
expect(performRepeatMock).toHaveBeenCalledTimes(1); // only for the edited panel
expect(panel.state.repeatedPanels?.length).toBe(4);
expect((panel.state.repeatedPanels![0] as VizPanel).state.title).toBe('Changed');
});
@@ -17,6 +17,7 @@ import {
import { GRID_COLUMN_COUNT } from 'app/core/constants';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { DashboardStateChangedEvent } from '../../edit-pane/shared';
import { getCloneKey, getLocalVariableValueSet } from '../../utils/clone';
import { getMultiVariableValues } from '../../utils/utils';
import { scrollCanvasElementIntoView, scrollIntoView } from '../layouts-shared/scrollCanvasElementIntoView';
@@ -61,6 +62,8 @@ export class DashboardGridItem
private _activationHandler() {
this.handleVariableName();
this._subs.add(this.subscribeToEvent(DashboardStateChangedEvent, () => this.handleEditChange()));
return () => {
this._handleGridSizeUnsubscribe();
};
@@ -114,26 +117,21 @@ export class DashboardGridItem
this.setState({ body });
}
public editingStarted() {
if (!this.state.variableName) {
return;
}
}
public handleEditChange() {
this._prevRepeatValues = undefined;
public editingCompleted(withChanges: boolean) {
if (withChanges) {
this._prevRepeatValues = undefined;
if (this.parent instanceof SceneGridRow) {
const repeater = this.parent.state.$behaviors?.find((b) => b instanceof RowRepeaterBehavior);
if (repeater) {
repeater.resetPrevRepeatValues();
}
if (this.parent instanceof SceneGridRow) {
const repeater = this.parent.state.$behaviors?.find((b) => b instanceof RowRepeaterBehavior);
if (repeater) {
repeater.resetPrevRepeatValues();
}
}
if (this.state.variableName && this.state.repeatDirection === 'h' && this.state.width !== GRID_COLUMN_COUNT) {
this.setState({ width: GRID_COLUMN_COUNT });
}
this.performRepeat();
}
public performRepeat() {
@@ -15,16 +15,6 @@ export interface DashboardLayoutItem extends SceneObject {
*/
getOptions?(): OptionsPaneCategoryDescriptor[];
/**
* When going into panel edit
**/
editingStarted?(): void;
/**
* When coming out of panel edit
*/
editingCompleted?(withChanges: boolean): void;
/**
* Change inner body / viz panel
*/
+1
View File
@@ -4681,6 +4681,7 @@
"move": "Move {{typeName}}",
"panel-background": "Change panel background",
"panel-description": "Change panel description",
"panel-edit": "Panel changes",
"panel-max-repeats-per-row": "Max repeats per row",
"panel-repeat-direction": "Repeat direction",
"panel-repeat-variable": "Panel repeat by",