DashboardEdit: Undo/redo for conditional rendering (#107235)

* add undo/redo for group and base actions

* add undo/redo for rules

* adjust circular dependency issue

* add itemType translations

* adjust how we get rendering group
This commit is contained in:
Sergej-Vlasov
2025-07-25 13:59:53 +01:00
committed by GitHub
parent fb08352300
commit 6af2e81cda
10 changed files with 184 additions and 23 deletions
@@ -7,7 +7,7 @@ import { ConditionalRenderingChangedEvent } from '../edit-pane/shared';
import { ConditionalRenderingBase } from './ConditionalRenderingBase';
import { ConditionalRenderingGroup } from './ConditionalRenderingGroup';
import { ItemsWithConditionalRendering } from './types';
import { getItemType } from './utils';
import { getItemType, translatedItemType } from './utils';
export interface ConditionalRenderingState extends SceneObjectState {
rootGroup: ConditionalRenderingGroup;
@@ -20,7 +20,7 @@ export class ConditionalRendering extends SceneObjectBase<ConditionalRenderingSt
return t(
'dashboard.conditional-rendering.root.info',
'Set rules to control {{type}} visibility by matching any or all rules.',
{ type: this.getItemType() }
{ type: translatedItemType(this.getItemType()) }
);
}
@@ -4,8 +4,16 @@ import { Trans, t } from '@grafana/i18n';
import { SceneComponentProps, sceneGraph, SceneObject, SceneObjectBase, SceneObjectState } from '@grafana/scenes';
import { Alert, Icon, IconButton, Stack, Text, Tooltip } from '@grafana/ui';
import { dashboardEditActions } from '../edit-pane/shared';
import { ConditionalRendering } from './ConditionalRendering';
import { ConditionalRenderingKindTypes, ConditionValues, ItemsWithConditionalRendering } from './types';
import { ConditionalRenderingGroup } from './ConditionalRenderingGroup';
import {
ConditionalRenderingConditions,
ConditionalRenderingKindTypes,
ConditionValues,
ItemsWithConditionalRendering,
} from './types';
export interface ConditionalRenderingBaseState<V = ConditionValues> extends SceneObjectState {
value: V;
@@ -74,6 +82,22 @@ export abstract class ConditionalRenderingBase<
this.notifyChange();
}
public findRule() {
return this.getRenderingGroup().getRule(this.state.key!);
}
public undoDeleted(index: number, rule: ConditionalRenderingConditions) {
const group = this.getRenderingGroup();
const restoredState = [...group.state.value];
restoredState.splice(index, 0, rule);
group.setStateAndNotify({ value: restoredState });
}
private getRenderingGroup(): ConditionalRenderingGroup {
// TODO: Adjust once nested rules are introduced to get relevant ConditionalRenderingGroup
return this._getConditionalLogicRoot().state.rootGroup;
}
private _getConditionalLogicRoot(): ConditionalRendering {
return sceneGraph.getAncestor(this, ConditionalRendering);
}
@@ -115,7 +139,20 @@ function ConditionalRenderingBaseRenderer<T extends ConditionalRenderingBase>({
<IconButton
aria-label={t('dashboard.conditional-rendering.conditions.shared.delete-condition', 'Delete Condition')}
name="trash-alt"
onClick={() => model.onDelete()}
onClick={() => {
const { rule, ruleIndex } = model.findRule();
dashboardEditActions.edit({
description: t('dashboard.conditional-rendering.conditions.shared.delete-condition', 'Delete Condition'),
source: model,
perform: () => model.onDelete(),
undo: () => {
if (rule) {
model.undoDeleted(ruleIndex, rule);
}
},
});
}}
/>
</Stack>
</Stack>
@@ -6,10 +6,12 @@ import { SceneComponentProps, sceneGraph } from '@grafana/scenes';
import { ConditionalRenderingDataKind } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen';
import { Combobox, ComboboxOption } from '@grafana/ui';
import { dashboardEditActions } from '../edit-pane/shared';
import { AutoGridItem } from '../scene/layout-auto-grid/AutoGridItem';
import { ConditionalRenderingBase, ConditionalRenderingBaseState } from './ConditionalRenderingBase';
import { ConditionalRenderingSerializerRegistryItem, DataConditionValue, ItemsWithConditionalRendering } from './types';
import { translatedItemType } from './utils';
type ConditionalRenderingDataState = ConditionalRenderingBaseState<DataConditionValue>;
@@ -32,7 +34,7 @@ export class ConditionalRenderingData extends ConditionalRenderingBase<Condition
return t(
'dashboard.conditional-rendering.conditions.data.info',
'Show or hide the {{type}} based on query results.',
{ type: this.getItemType() }
{ type: translatedItemType(this.getItemType()) }
);
}
@@ -126,7 +128,14 @@ function ConditionalRenderingDataRenderer({ model }: SceneComponentProps<Conditi
<Combobox
options={enableConditionOptions}
value={enableConditionOption}
onChange={({ value }) => model.setStateAndNotify({ value: Boolean(value) })}
onChange={({ value: val }) => {
dashboardEditActions.edit({
description: t('dashboard.edit-actions.edit-query-result-rule', 'Change query result rule'),
source: model,
perform: () => model.setStateAndNotify({ value: Boolean(val) }),
undo: () => model.setStateAndNotify({ value }),
});
}}
/>
);
}
@@ -1,8 +1,13 @@
import { capitalize, lowerCase } from 'lodash';
import { useMemo } from 'react';
import { t } from '@grafana/i18n';
import { SceneComponentProps, sceneGraph } from '@grafana/scenes';
import { ConditionalRenderingGroupKind } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen';
import { Stack } from '@grafana/ui';
import { dashboardEditActions } from '../edit-pane/shared';
import { ConditionalRenderingBase, ConditionalRenderingBaseState } from './ConditionalRenderingBase';
import { ConditionalRenderingData } from './ConditionalRenderingData';
import { ConditionalRenderingGroupAdd } from './ConditionalRenderingGroupAdd';
@@ -18,7 +23,9 @@ import {
GroupConditionItemType,
GroupConditionVisibility,
GroupConditionValue,
ConditionalRenderingConditions,
} from './types';
import { translatedItemType } from './utils';
export interface ConditionalRenderingGroupState extends ConditionalRenderingBaseState<GroupConditionValue> {
visibility: GroupConditionVisibility;
@@ -63,7 +70,7 @@ export class ConditionalRenderingGroup extends ConditionalRenderingBase<Conditio
this.setStateAndNotify({ condition });
}
public addItem(itemType: GroupConditionItemType) {
public createItem(itemType: GroupConditionItemType) {
const item =
itemType === 'data'
? ConditionalRenderingData.createEmpty()
@@ -71,6 +78,10 @@ export class ConditionalRenderingGroup extends ConditionalRenderingBase<Conditio
? ConditionalRenderingVariable.createEmpty(sceneGraph.getVariables(this).state.variables[0].state.name)
: ConditionalRenderingTimeRangeSize.createEmpty();
return item;
}
public addItem(item: ConditionalRenderingConditions) {
// We don't use `setStateAndNotify` here because
// We need to set a parent and activate the new condition before notifying the root
this.setState({ value: [...this.state.value, item] });
@@ -86,6 +97,18 @@ export class ConditionalRenderingGroup extends ConditionalRenderingBase<Conditio
this.setStateAndNotify({ value: this.state.value.filter((condition) => condition.state.key !== key) });
}
public removeLastItem() {
const newValues = [...this.state.value];
newValues.pop();
this.setStateAndNotify({ value: newValues });
}
public getRule(key: string) {
const ruleIndex = this.state.value.findIndex((rule) => rule.state.key === key);
const rule = this.state.value[ruleIndex];
return { rule, ruleIndex };
}
public serialize(): ConditionalRenderingGroupKind {
if (this.state.value.some((item) => item instanceof ConditionalRenderingGroup)) {
throw new Error('ConditionalRenderingGroup cannot contain nested ConditionalRenderingGroups');
@@ -127,22 +150,52 @@ export class ConditionalRenderingGroup extends ConditionalRenderingBase<Conditio
function ConditionalRenderingGroupRenderer({ model }: SceneComponentProps<ConditionalRenderingGroup>) {
const { condition, visibility, value } = model.useState();
const { variables } = sceneGraph.getVariables(model).useState();
const itemType = useMemo(() => model.getItemType(), [model]);
return (
<Stack direction="column" gap={2}>
<ConditionalRenderingGroupVisibility
itemType={model.getItemType()}
itemType={itemType}
value={visibility}
onChange={(value) => model.changeVisibility(value)}
onChange={(value) => {
dashboardEditActions.edit({
description: t('dashboard.conditional-rendering.conditions.group.visibility.label', '{{type}} visibility', {
type: capitalize(translatedItemType(itemType)),
}),
source: model,
perform: () => model.changeVisibility(value),
undo: () => model.changeVisibility(visibility),
});
}}
/>
{value.length > 1 && (
<ConditionalRenderingGroupCondition value={condition} onChange={(value) => model.changeCondition(value)} />
<ConditionalRenderingGroupCondition
value={condition}
onChange={(value) => {
dashboardEditActions.edit({
description: t('dashboard.conditional-rendering.conditions.group.condition.label', 'Match rules'),
source: model,
perform: () => model.changeCondition(value),
undo: () => model.changeCondition(condition),
});
}}
/>
)}
{value.map((entry) => entry.render())}
<ConditionalRenderingGroupAdd
itemType={model.getItemType()}
itemType={itemType}
hasVariables={variables.length > 0}
onAdd={(itemType) => model.addItem(itemType)}
onAdd={({ value, label }) => {
const item = model.createItem(value!);
dashboardEditActions.edit({
description: t('dashboard.edit-actions.add-conditional-rule', 'Add {{ruleDescription}} rule', {
ruleDescription: lowerCase(label),
}),
source: model,
perform: () => model.addItem(item),
undo: () => model.removeLastItem(),
});
}}
/>
</Stack>
);
@@ -9,7 +9,7 @@ import { GroupConditionItemType, ItemsWithConditionalRendering } from './types';
interface Props {
itemType: ItemsWithConditionalRendering;
hasVariables: boolean;
onAdd: (itemType: GroupConditionItemType) => void;
onAdd: (option: SelectableValue<GroupConditionItemType>) => void;
}
export const ConditionalRenderingGroupAdd = ({ itemType, hasVariables, onAdd }: Props) => {
@@ -42,7 +42,7 @@ export const ConditionalRenderingGroupAdd = ({ itemType, hasVariables, onAdd }:
variant="secondary"
label={t('dashboard.conditional-rendering.conditions.group.add.button', 'Add rule')}
options={options}
onChange={({ value }) => onAdd(value!)}
onChange={(option) => onAdd(option)}
/>
);
};
@@ -7,6 +7,7 @@ import { t } from '@grafana/i18n';
import { Field, RadioButtonGroup, useStyles2 } from '@grafana/ui';
import { GroupConditionVisibility, ItemsWithConditionalRendering } from './types';
import { translatedItemType } from './utils';
interface Props {
itemType: ItemsWithConditionalRendering;
@@ -28,7 +29,7 @@ export const ConditionalRenderingGroupVisibility = ({ itemType, value, onChange
return (
<Field
label={t('dashboard.conditional-rendering.conditions.group.visibility.label', '{{type}} visibility', {
type: capitalize(itemType),
type: capitalize(translatedItemType(itemType)),
})}
className={styles.container}
>
@@ -1,5 +1,5 @@
import { css } from '@emotion/css';
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { rangeUtil, SelectableValue } from '@grafana/data';
import { t } from '@grafana/i18n';
@@ -7,8 +7,11 @@ import { SceneComponentProps, sceneGraph } from '@grafana/scenes';
import { ConditionalRenderingTimeRangeSizeKind } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen';
import { Field, Select, useStyles2 } from '@grafana/ui';
import { dashboardEditActions } from '../edit-pane/shared';
import { ConditionalRenderingBase, ConditionalRenderingBaseState } from './ConditionalRenderingBase';
import { ConditionalRenderingSerializerRegistryItem, TimeRangeSizeConditionValue } from './types';
import { translatedItemType } from './utils';
type ConditionalRenderingTimeRangeSizeState = ConditionalRenderingBaseState<TimeRangeSizeConditionValue>;
@@ -29,7 +32,7 @@ export class ConditionalRenderingTimeRangeSize extends ConditionalRenderingBase<
return t(
'dashboard.conditional-rendering.conditions.time-range-size.info',
'Show or hide the {{type}} if the dashboard time range is shorter than the selected time frame.',
{ type: this.getItemType() }
{ type: translatedItemType(this.getItemType()) }
);
}
@@ -192,6 +195,18 @@ function ConditionalRenderingTimeRangeSizeRenderer({ model }: SceneComponentProp
return [{ label: value, value }, ...staticOptions];
}, [staticOptions, value]);
const handleChange = useCallback(
(newValue: string | undefined) => {
dashboardEditActions.edit({
description: t('dashboard.edit-actions.edit-time-range-rule', 'Change time range rule'),
source: model,
perform: () => model.setStateAndNotify({ value: newValue }),
undo: () => model.setStateAndNotify({ value }),
});
},
[model, value]
);
return (
<Field
invalid={!isValid}
@@ -201,10 +216,10 @@ function ConditionalRenderingTimeRangeSizeRenderer({ model }: SceneComponentProp
<Select
isClearable={false}
allowCustomValue
onCreateOption={(value) => model.setStateAndNotify({ value })}
onCreateOption={(value) => handleChange(value)}
value={value}
options={options}
onChange={({ value }) => model.setStateAndNotify({ value })}
onChange={({ value }) => handleChange(value)}
/>
</Field>
);
@@ -5,12 +5,15 @@ import { SceneComponentProps, sceneGraph, VariableDependencyConfig } from '@graf
import { ConditionalRenderingVariableKind } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen';
import { Box, Combobox, ComboboxOption, Input, Stack } from '@grafana/ui';
import { dashboardEditActions } from '../edit-pane/shared';
import { ConditionalRenderingBase, ConditionalRenderingBaseState } from './ConditionalRenderingBase';
import {
ConditionalRenderingSerializerRegistryItem,
VariableConditionValue,
VariableConditionValueOperator,
} from './types';
import { translatedItemType } from './utils';
type ConditionalRenderingVariableState = ConditionalRenderingBaseState<VariableConditionValue>;
@@ -31,7 +34,7 @@ export class ConditionalRenderingVariable extends ConditionalRenderingBase<Condi
return t(
'dashboard.conditional-rendering.conditions.variable.info',
'Show or hide the {{type}} dynamically based on the variable value.',
{ type: this.getItemType() }
{ type: translatedItemType(this.getItemType()) }
);
}
@@ -110,6 +113,8 @@ function ConditionalRenderingVariableRenderer({ model }: SceneComponentProps<Con
[]
);
const undoText = t('dashboard.edit-actions.edit-template-variable-rule', 'Change template variable rule');
return (
<Stack direction="column" gap={0.5}>
<Stack direction="row" gap={0.5} grow={1}>
@@ -118,7 +123,14 @@ function ConditionalRenderingVariableRenderer({ model }: SceneComponentProps<Con
placeholder={t('dashboard.conditional-rendering.conditions.variable.name', 'Name')}
options={variableNames}
value={value.name}
onChange={(option) => model.setStateAndNotify({ value: { ...value, name: option.value } })}
onChange={(option) => {
dashboardEditActions.edit({
description: undoText,
source: model,
perform: () => model.setStateAndNotify({ value: { ...value, name: option.value } }),
undo: () => model.setStateAndNotify({ value: { ...value, name: value.name } }),
});
}}
/>
</Box>
@@ -127,13 +139,28 @@ function ConditionalRenderingVariableRenderer({ model }: SceneComponentProps<Con
minWidth={10}
options={operatorOptions}
value={value.operator}
onChange={(option) => model.setStateAndNotify({ value: { ...value, operator: option.value } })}
onChange={(option) => {
dashboardEditActions.edit({
description: undoText,
source: model,
perform: () => model.setStateAndNotify({ value: { ...value, operator: option.value } }),
undo: () => model.setStateAndNotify({ value: { ...value, operator: value.operator } }),
});
}}
/>
</Stack>
<Input
placeholder={t('dashboard.conditional-rendering.conditions.variable.value', 'Value')}
value={value.value}
onChange={(e) => model.setStateAndNotify({ value: { ...value, value: e.currentTarget.value } })}
onChange={(e) => {
const eventValue = e.currentTarget.value;
dashboardEditActions.edit({
description: undoText,
source: model,
perform: () => model.setStateAndNotify({ value: { ...value, value: eventValue } }),
undo: () => model.setStateAndNotify({ value: { ...value, value: value.value } }),
});
}}
/>
</Stack>
);
@@ -1,3 +1,6 @@
import { lowerCase } from 'lodash';
import { t } from '@grafana/i18n';
import { SceneObject } from '@grafana/scenes';
import { AutoGridItem } from '../scene/layout-auto-grid/AutoGridItem';
@@ -17,3 +20,14 @@ export function getItemType(object: SceneObject): ItemsWithConditionalRendering
return 'element';
}
export const translatedItemType = (item: ItemsWithConditionalRendering) => {
const translations: { [key in ItemsWithConditionalRendering]: string } = {
panel: lowerCase(t('dashboard.edit-pane.elements.panel', 'Panel')),
row: lowerCase(t('dashboard.edit-pane.elements.row', 'Row')),
tab: lowerCase(t('dashboard.edit-pane.elements.tab', 'Tab')),
element: lowerCase(t('dashboard.edit-pane.elements.element', 'Element')),
};
return translations[item] || item;
};
+5
View File
@@ -4566,6 +4566,10 @@
},
"edit-actions": {
"add": "Add {{typeName}}",
"add-conditional-rule": "Add {{ruleDescription}} rule",
"edit-query-result-rule": "Change query result rule",
"edit-template-variable-rule": "Change template variable rule",
"edit-time-range-rule": "Change time range rule",
"move": "Move {{typeName}}",
"panel-background": "Change panel background",
"panel-description": "Change panel description",
@@ -4580,6 +4584,7 @@
"edit-pane": {
"elements": {
"dashboard": "Dashboard",
"element": "Element",
"local-variable": "Local variable",
"multiple-elements": "Multiple elements",
"multiple-elements-delete-text": "Are you sure you want to delete these elements?",