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
This commit is contained in:
kay delaney
2025-06-16 12:56:05 +01:00
committed by GitHub
parent 0ae635974e
commit 7273e4ca1c
4 changed files with 198 additions and 8 deletions

View File

@ -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<DashboardSceneState>) {
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(
<TestProvider>
<DashboardTitleInput dashboard={dashboard} />
</TestProvider>
);
};
const renderDescriptionInput = () => {
render(
<TestProvider>
<DashboardDescriptionInput dashboard={dashboard} />
</TestProvider>
);
};
return { dashboard, renderTitleInput, renderDescriptionInput };
}
function undo(editPane: DashboardEditPane) {
act(() => {
editPane.undoAction();
});
}
function redo(editPane: DashboardEditPane) {
act(() => {
editPane.redoAction();
});
}

View File

@ -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 (
<TextArea
id={id}
value={description}
onChange={(e) => dashboard.setState({ description: e.currentTarget.value })}
onFocus={(e) => {
valueBeforeEdit.current = e.currentTarget.value;
}}
onBlur={(e) => {
const descriptionUnchanged = valueBeforeEdit.current === e.currentTarget.value;
const shouldSkip = descriptionUnchanged || undoRedoWasClicked(e);
if (shouldSkip) {
return;
}
dashboardEditActions.changeDescription({
source: dashboard,
oldDescription: valueBeforeEdit.current,
newDescription: e.currentTarget.value,
});
}}
/>
);
}

View File

@ -6,6 +6,7 @@ import { LocalValueVariable, SceneGridRow, SceneObject, SceneVariableSet, VizPan
import { DashboardScene } from '../scene/DashboardScene';
import { SceneGridRowEditableElement } from '../scene/layout-default/SceneGridRowEditableElement';
import { redoButtonId, undoButtonID } from '../scene/new-toolbar/RightActions';
import { EditableDashboardElement, isEditableDashboardElement } from '../scene/types/EditableDashboardElement';
import { LocalVariableEditableElement } from '../settings/variables/LocalVariableEditableElement';
import { VariableEditableElement } from '../settings/variables/VariableEditableElement';
@ -104,6 +105,12 @@ export interface ChangeTitleActionHelperProps {
source: DashboardScene;
}
export interface ChangeDescriptionActionHelperProps {
oldDescription: string;
newDescription: string;
source: DashboardScene;
}
export const dashboardEditActions = {
/**
* Registers and peforms an edit action
@ -155,7 +162,7 @@ export const dashboardEditActions = {
changeTitle({ source, oldTitle, newTitle }: ChangeTitleActionHelperProps) {
dashboardEditActions.edit({
description: t('dashboard.title.action', 'Change dashboard title'),
source: source,
source,
perform: () => {
source.setState({ title: newTitle });
},
@ -164,4 +171,21 @@ export const dashboardEditActions = {
},
});
},
changeDescription({ source, oldDescription, newDescription }: ChangeDescriptionActionHelperProps) {
dashboardEditActions.edit({
description: t('dashboard.description.action', 'Change dashboard description'),
source,
perform: () => {
source.setState({ description: newDescription });
},
undo: () => {
source.setState({ description: oldDescription });
},
});
},
};
export function undoRedoWasClicked(e: React.FocusEvent) {
return e.relatedTarget && (e.relatedTarget.id === undoButtonID || e.relatedTarget.id === redoButtonId);
}

View File

@ -4045,6 +4045,9 @@
"title": "Row options"
}
},
"description": {
"action": "Change dashboard description"
},
"dynamic-config-value-editor": {
"render-label": {
"tooltip-remove-property": "Remove property"