From 7273e4ca1cde21b09369c7480cc8e06b6c3914f0 Mon Sep 17 00:00:00 2001 From: kay delaney <45561153+kaydelaney@users.noreply.github.com> Date: Mon, 16 Jun 2025 12:56:05 +0100 Subject: [PATCH] Dashboards: Add undo/redo support for dashboard description (#106718) * Dashboards: Add undo/redo actions for changing dashboard title * Run make i18n-extract * Dashboards: Add undo/redo support for dashboard description * fix typo * Add tests for DashboardTitleInput & DashboardDescriptionInput --- .../DashboardEditableElement.test.tsx | 149 ++++++++++++++++++ .../edit-pane/DashboardEditableElement.tsx | 28 +++- .../dashboard-scene/edit-pane/shared.ts | 26 ++- public/locales/en-US/grafana.json | 3 + 4 files changed, 198 insertions(+), 8 deletions(-) create mode 100644 public/app/features/dashboard-scene/edit-pane/DashboardEditableElement.test.tsx diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardEditableElement.test.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardEditableElement.test.tsx new file mode 100644 index 00000000000..9ef41253a50 --- /dev/null +++ b/public/app/features/dashboard-scene/edit-pane/DashboardEditableElement.test.tsx @@ -0,0 +1,149 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { TestProvider } from 'test/helpers/TestProvider'; + +import { config } from '@grafana/runtime'; + +import { DashboardScene, DashboardSceneState } from '../scene/DashboardScene'; +import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; +import { activateFullSceneTree } from '../utils/test-utils'; + +import { DashboardEditPane } from './DashboardEditPane'; +import { DashboardDescriptionInput, DashboardTitleInput } from './DashboardEditableElement'; + +jest.mock('@grafana/scenes', () => ({ + ...jest.requireActual('@grafana/scenes'), + sceneUtils: { + ...jest.requireActual('@grafana/scenes').sceneUtils, + registerVariableMacro: jest.fn(), + }, +})); + +describe('DashboardEditableElement', () => { + describe('DashboardTitleInput', () => { + it('Supports undo/redo', async () => { + const { renderTitleInput, dashboard } = setup(); + + renderTitleInput(); + const titleInput = screen.getByRole('textbox'); + await testDashboardEditableElement(dashboard, titleInput); + }); + }); + + describe('DashboardDescriptionInput', () => { + it('Supports undo/redo', async () => { + const { renderDescriptionInput, dashboard } = setup(); + + renderDescriptionInput(); + const descriptionTextarea = screen.getByRole('textbox'); + await testDashboardEditableElement(dashboard, descriptionTextarea); + }); + }); +}); + +async function testDashboardEditableElement(dashboard: DashboardScene, inputElement: HTMLElement) { + const updateInput = async (newValue: string) => { + fireEvent.focus(inputElement); + await userEvent.clear(inputElement); + await userEvent.type(inputElement, newValue); + fireEvent.blur(inputElement); + }; + + const editPane = dashboard.state.editPane; + expect(editPane.state.undoStack).toHaveLength(0); + expect(editPane.state.redoStack).toHaveLength(0); + expect(inputElement).toHaveValue('initial'); + + await updateInput('first'); + expect(inputElement).toHaveValue('first'); + expect(editPane.state.undoStack).toHaveLength(1); + expect(editPane.state.redoStack).toHaveLength(0); + + undo(editPane); + expect(inputElement).toHaveValue('initial'); + expect(editPane.state.undoStack).toHaveLength(0); + expect(editPane.state.redoStack).toHaveLength(1); + + await updateInput('second'); + expect(inputElement).toHaveValue('second'); + expect(editPane.state.redoStack).toHaveLength(0); + expect(editPane.state.undoStack).toHaveLength(1); + + await updateInput('third'); + expect(inputElement).toHaveValue('third'); + expect(editPane.state.redoStack).toHaveLength(0); + expect(editPane.state.undoStack).toHaveLength(2); + + await updateInput('fourth'); + expect(inputElement).toHaveValue('fourth'); + expect(editPane.state.redoStack).toHaveLength(0); + expect(editPane.state.undoStack).toHaveLength(3); + + undo(editPane); + expect(inputElement).toHaveValue('third'); + expect(editPane.state.redoStack).toHaveLength(1); + expect(editPane.state.undoStack).toHaveLength(2); + + undo(editPane); + expect(inputElement).toHaveValue('second'); + expect(editPane.state.redoStack).toHaveLength(2); + expect(editPane.state.undoStack).toHaveLength(1); + + redo(editPane); + expect(inputElement).toHaveValue('third'); + expect(editPane.state.redoStack).toHaveLength(1); + expect(editPane.state.undoStack).toHaveLength(2); +} + +function setup(overrides?: Partial) { + const dashboard = transformSaveModelToScene({ + dashboard: { + title: 'initial', + description: 'initial', + uid: 'my-uid', + schemaVersion: 30, + panels: [], + version: 10, + }, + meta: {}, + ...overrides, + }); + + // Clear any data layers + dashboard.setState({ $data: undefined }); + + config.featureToggles.dashboardNewLayouts = true; + activateFullSceneTree(dashboard); + + dashboard.onEnterEditMode(); + + const renderTitleInput = () => { + render( + + + + ); + }; + + const renderDescriptionInput = () => { + render( + + + + ); + }; + + return { dashboard, renderTitleInput, renderDescriptionInput }; +} + +function undo(editPane: DashboardEditPane) { + act(() => { + editPane.undoAction(); + }); +} + +function redo(editPane: DashboardEditPane) { + act(() => { + editPane.redoAction(); + }); +} diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardEditableElement.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardEditableElement.tsx index 7e392b04eb8..d63bc7345db 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardEditableElement.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardEditableElement.tsx @@ -7,11 +7,10 @@ import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/Pan import { DashboardScene } from '../scene/DashboardScene'; import { useLayoutCategory } from '../scene/layouts-shared/DashboardLayoutSelector'; -import { redoButtonId, undoButtonID } from '../scene/new-toolbar/RightActions'; import { EditSchemaV2Button } from '../scene/new-toolbar/actions/EditSchemaV2Button'; import { EditableDashboardElement, EditableDashboardElementInfo } from '../scene/types/EditableDashboardElement'; -import { dashboardEditActions } from './shared'; +import { dashboardEditActions, undoRedoWasClicked } from './shared'; export class DashboardEditableElement implements EditableDashboardElement { public readonly isEditableDashboardElement = true; @@ -96,12 +95,8 @@ export function DashboardTitleInput({ dashboard, id }: { dashboard: DashboardSce valueBeforeEdit.current = e.currentTarget.value; }} onBlur={(e) => { - // If the title input is currently focused and we click undo/redo - // we don't want to mess with the stack - const clickedUndoRedo = - e.relatedTarget && (e.relatedTarget.id === undoButtonID || e.relatedTarget.id === redoButtonId); const titleUnchanged = valueBeforeEdit.current === e.currentTarget.value; - const shouldSkip = titleUnchanged || clickedUndoRedo; + const shouldSkip = titleUnchanged || undoRedoWasClicked(e); if (shouldSkip) { return; } @@ -119,11 +114,30 @@ export function DashboardTitleInput({ dashboard, id }: { dashboard: DashboardSce export function DashboardDescriptionInput({ dashboard, id }: { dashboard: DashboardScene; id?: string }) { const { description } = dashboard.useState(); + // We want to save the unchanged value for the 'undo' action + const valueBeforeEdit = useRef(''); + return (