mirror of
https://github.com/grafana/grafana.git
synced 2025-08-06 02:15:54 +08:00
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:
@ -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();
|
||||
});
|
||||
}
|
@ -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,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -4045,6 +4045,9 @@
|
||||
"title": "Row options"
|
||||
}
|
||||
},
|
||||
"description": {
|
||||
"action": "Change dashboard description"
|
||||
},
|
||||
"dynamic-config-value-editor": {
|
||||
"render-label": {
|
||||
"tooltip-remove-property": "Remove property"
|
||||
|
Reference in New Issue
Block a user