Dashboards: Add undo/redo actions for several template variable options (#106818)

* Dashboards: Add undo/redo actions for several template variable options

Also refactors some existing undo/redo code

* Run `make i18n-extract`

* formatting
This commit is contained in:
kay delaney 2025-07-29 11:05:32 +01:00 committed by GitHub
parent 39b9700048
commit 51a5b0ab65
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 142 additions and 45 deletions

View File

@ -108,8 +108,8 @@ export function DashboardTitleInput({ dashboard, id }: { dashboard: DashboardSce
dashboardEditActions.changeTitle({
source: dashboard,
oldTitle: valueBeforeEdit.current,
newTitle: e.currentTarget.value,
oldValue: valueBeforeEdit.current,
newValue: e.currentTarget.value,
});
}}
/>
@ -139,8 +139,8 @@ export function DashboardDescriptionInput({ dashboard, id }: { dashboard: Dashbo
dashboardEditActions.changeDescription({
source: dashboard,
oldDescription: valueBeforeEdit.current,
newDescription: e.currentTarget.value,
oldValue: valueBeforeEdit.current,
newValue: e.currentTarget.value,
});
}}
/>

View File

@ -1,3 +1,4 @@
/* eslint-disable @grafana/i18n/no-translation-top-level */
import { useSessionStorage } from 'react-use';
import { BusEventWithPayload } from '@grafana/data';
@ -191,6 +192,15 @@ export const dashboardEditActions = {
});
},
changeTitle: makeEditAction<DashboardScene, 'title'>({
description: t('dashboard.title.action', 'Change dashboard title'),
prop: 'title',
}),
changeDescription: makeEditAction<DashboardScene, 'description'>({
description: t('dashboard.description.action', 'Change dashboard description'),
prop: 'description',
}),
addVariable({ source, addedObject }: AddVariableActionHelperProps) {
const varsBeforeAddition = [...source.state.variables];
@ -219,32 +229,22 @@ export const dashboardEditActions = {
},
});
},
changeTitle({ source, oldTitle, newTitle }: ChangeTitleActionHelperProps) {
dashboardEditActions.edit({
description: t('dashboard.title.action', 'Change dashboard title'),
source,
perform: () => {
source.setState({ title: newTitle });
},
undo: () => {
source.setState({ title: oldTitle });
},
});
},
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 });
},
});
},
changeVariableName: makeEditAction<SceneVariable, 'name'>({
description: t('dashboard.variable.name.action', 'Change variable name'),
prop: 'name',
}),
changeVariableLabel: makeEditAction<SceneVariable, 'label'>({
description: t('dashboard.variable.label.action', 'Change variable label'),
prop: 'label',
}),
changeVariableDescription: makeEditAction<SceneVariable, 'description'>({
description: t('dashboard.variable.description.action', 'Change variable description'),
prop: 'description',
}),
changeVariableHideValue: makeEditAction<SceneVariable, 'hide'>({
description: t('dashboard.variable.hide.action', 'Change variable hide option'),
prop: 'hide',
}),
moveElement(props: MoveElementActionHelperProps) {
const { movedObject, source, perform, undo } = props;
@ -266,6 +266,35 @@ export const dashboardEditActions = {
},
};
interface MakeEditActionProps<Source extends SceneObject, T extends keyof Source['state']> {
description: string;
prop: T;
}
interface EditActionProps<Source extends SceneObject, T extends keyof Source['state']> {
source: Source;
oldValue: Source['state'][T];
newValue: Source['state'][T];
}
function makeEditAction<Source extends SceneObject, T extends keyof Source['state']>({
description,
prop,
}: MakeEditActionProps<Source, T>) {
return ({ source, oldValue, newValue }: EditActionProps<Source, T>) => {
dashboardEditActions.edit({
description,
source,
perform: () => {
source.setState({ [prop]: newValue });
},
undo: () => {
source.setState({ [prop]: oldValue });
},
});
};
}
export function undoRedoWasClicked(e: React.FocusEvent) {
return e.relatedTarget && (e.relatedTarget.id === undoButtonID || e.relatedTarget.id === redoButtonId);
}

View File

@ -1,4 +1,4 @@
import { FormEvent, useMemo, useState } from 'react';
import { FormEvent, useMemo, useRef, useState } from 'react';
import { VariableHide } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
@ -9,7 +9,7 @@ import { Input, TextArea, Button, Field, Box, Stack } from '@grafana/ui';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
import { dashboardEditActions } from '../../edit-pane/shared';
import { dashboardEditActions, undoRedoWasClicked } from '../../edit-pane/shared';
import { useEditPaneInputAutoFocus } from '../../scene/layouts-shared/utils';
import { BulkActionElement } from '../../scene/types/BulkActionElement';
import { EditableDashboardElement, EditableDashboardElementInfo } from '../../scene/types/EditableDashboardElement';
@ -123,36 +123,49 @@ function VariableNameInput({ variable, isNewElement }: { variable: SceneVariable
const { name } = variable.useState();
const ref = useEditPaneInputAutoFocus({ autoFocus: isNewElement });
const [nameError, setNameError] = useState<string>();
const [validName, setValidName] = useState<string>(variable.state.name);
const onChange = (e: FormEvent<HTMLInputElement>) => {
const result = validateVariableName(variable, e.currentTarget.value);
if (result.errorMessage !== nameError) {
setNameError(result.errorMessage);
} else {
setValidName(variable.state.name);
}
variable.setState({ name: e.currentTarget.value });
};
// Restore valid name if bluring while invalid
const onBlur = () => {
if (nameError) {
variable.setState({ name: validName });
setNameError(undefined);
}
};
const oldName = useRef(name);
return (
<Field label={t('dashboard.edit-pane.variable.name', 'Name')} invalid={!!nameError} error={nameError}>
<Input
ref={ref}
value={name}
onFocus={() => {
oldName.current = name;
}}
onChange={onChange}
required
onBlur={onBlur}
onBlur={(e) => {
const labelUnchanged = oldName.current === name;
const shouldSkip = labelUnchanged || undoRedoWasClicked(e);
if (nameError) {
setNameError(undefined);
variable.setState({ name: oldName.current });
return;
}
if (shouldSkip) {
return;
}
dashboardEditActions.changeVariableName({
source: variable,
oldValue: oldName.current,
newValue: name,
});
}}
data-testid={selectors.components.PanelEditor.ElementEditPane.variableNameInput}
required
/>
</Field>
);
@ -160,10 +173,29 @@ function VariableNameInput({ variable, isNewElement }: { variable: SceneVariable
function VariableLabelInput({ variable }: VariableInputProps) {
const { label } = variable.useState();
const oldLabel = useRef(label ?? '');
return (
<Input
value={label}
onFocus={() => {
oldLabel.current = label ?? '';
}}
onChange={(e) => variable.setState({ label: e.currentTarget.value })}
onBlur={(e) => {
const labelUnchanged = oldLabel.current === e.currentTarget.value;
const shouldSkip = labelUnchanged || undoRedoWasClicked(e);
if (shouldSkip) {
return;
}
dashboardEditActions.changeVariableLabel({
source: variable,
oldValue: oldLabel.current,
newValue: e.currentTarget.value,
});
}}
data-testid={selectors.components.PanelEditor.ElementEditPane.variableLabelInput}
/>
);
@ -171,13 +203,31 @@ function VariableLabelInput({ variable }: VariableInputProps) {
function VariableDescriptionTextArea({ variable }: VariableInputProps) {
const { description } = variable.useState();
const oldDescription = useRef(description ?? '');
return (
<TextArea
id="description-text-area"
value={description ?? ''}
placeholder={t('dashboard.edit-pane.variable.description-placeholder', 'Descriptive text')}
onFocus={() => {
oldDescription.current = description ?? '';
}}
onChange={(e) => variable.setState({ description: e.currentTarget.value })}
onBlur={(e) => {
const labelUnchanged = oldDescription.current === e.currentTarget.value;
const shouldSkip = labelUnchanged || undoRedoWasClicked(e);
if (shouldSkip) {
return;
}
dashboardEditActions.changeVariableDescription({
source: variable,
oldValue: oldDescription.current,
newValue: e.currentTarget.value,
});
}}
/>
);
}
@ -186,7 +236,11 @@ function VariableHideInput({ variable }: VariableInputProps) {
const { hide = VariableHide.dontHide } = variable.useState();
const onChange = (option: VariableHide) => {
variable.setState({ hide: option });
dashboardEditActions.changeVariableHideValue({
source: variable,
oldValue: hide,
newValue: option,
});
};
return <VariableHideSelect hide={hide} type={variable.state.type} onChange={onChange} />;

View File

@ -5355,6 +5355,20 @@
"tags-expected-array": "tags expected array",
"tags-expected-strings": "tags expected array of strings"
},
"variable": {
"description": {
"action": "Change variable description"
},
"hide": {
"action": "Change variable hide option"
},
"label": {
"action": "Change variable label"
},
"name": {
"action": "Change variable name"
}
},
"version-history-comparison": {
"button-restore": "Restore to version {{version}}",
"label-view-json-diff": "View JSON diff",