From 958f5a7c52c418b8b11828d39aa7c575864866be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Mon, 8 Sep 2025 14:06:37 +0200 Subject: [PATCH] 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 --- .../edit-pane/DashboardEditPane.tsx | 51 +++++++++--- .../panel-edit/PanelEditor.tsx | 42 +++++++++- .../scene/layout-auto-grid/AutoGridItem.tsx | 15 ++-- .../layout-default/DashboardGridItem.test.tsx | 79 +------------------ .../layout-default/DashboardGridItem.tsx | 24 +++--- .../scene/types/DashboardLayoutItem.ts | 10 --- public/locales/en-US/grafana.json | 1 + 7 files changed, 98 insertions(+), 124 deletions(-) diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx index 70dee7c9c67..5a1b3624a2b 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx @@ -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 { 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 { 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 { 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 { 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); + } +} diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx b/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx index 1711697d937..72f0d6c97da 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx @@ -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 { 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 { if (this.state.isInitializing) { this.setOriginalState(this.state.panelRef); - this._layoutItem.editingStarted?.(); - this._setupChangeDetection(); this._updateDataPane(plugin); diff --git a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItem.tsx b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItem.tsx index f98461ca936..953f7b7dbb8 100644 --- a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItem.tsx +++ b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItem.tsx @@ -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 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 implements }; } - public editingStarted() { - if (!this.state.variableName) { - return; - } - } - - public editingCompleted(withChanges: boolean) { - if (withChanges) { - this._prevRepeatValues = undefined; - } + public handleEditChange() { + this._prevRepeatValues = undefined; this.performRepeat(); } diff --git a/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.test.tsx b/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.test.tsx index febb8bbaee5..0951b078cf8 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.test.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.test.tsx @@ -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'); }); diff --git a/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.tsx b/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.tsx index 13f3cec389e..5365993335c 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.tsx @@ -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() { diff --git a/public/app/features/dashboard-scene/scene/types/DashboardLayoutItem.ts b/public/app/features/dashboard-scene/scene/types/DashboardLayoutItem.ts index 44391c27c33..c7bac407208 100644 --- a/public/app/features/dashboard-scene/scene/types/DashboardLayoutItem.ts +++ b/public/app/features/dashboard-scene/scene/types/DashboardLayoutItem.ts @@ -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 */ diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 8573bb03880..de6aeba30cc 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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",