Alerting: Clean up dangling references when queries are removed

When a data query or expression is deleted, other expressions that
reference it would contain invalid/dangling references, potentially
causing errors or unexpected behavior.

This change introduces automatic cleanup of dangling references across
all expression types:

- Math expressions: Removes variable references ($A, ${A}) from the
  expression string
- Reduce/Resample/Threshold: Clears the expression field if it
  references the removed query
- Classic conditions: Filters out removed refIds from params array
- Handles cascading removals when expressions reference other
  expressions

The cleanup is triggered in two scenarios:
1. When explicitly removing an expression via removeExpression action
2. When updating data queries via setDataQueries (e.g., user deletes
   a query in the query editor)

Also migrates the Reduce component from Select to Combobox for better
UX consistency, with improved layout spacing.
This commit is contained in:
Konrad Lalik
2026-01-07 10:50:56 +01:00
parent dac46eb41f
commit fbc2877f1b
5 changed files with 29 additions and 24 deletions
@@ -471,7 +471,7 @@ describe('Query and expressions reducer', () => {
// The reduce expression should still exist but its reference should be cleared
expect(newState.queries).toHaveLength(1);
expect(newState.queries[0].refId).toBe('B');
expect(newState.queries[0].model.expression).toBeNull();
expect(newState.queries[0].model.expression).toBeUndefined();
});
it('should clear expression reference when removing a data query via setDataQueries', () => {
@@ -521,7 +521,7 @@ describe('Query and expressions reducer', () => {
expect(newState.queries).toHaveLength(2);
expect(newState.queries.map((q) => q.refId)).toEqual(['A', 'C']);
const thresholdQuery = newState.queries.find((q) => q.refId === 'C');
expect(thresholdQuery?.model.expression).toBeNull();
expect(thresholdQuery?.model.expression).toBeUndefined();
});
});
});
@@ -237,7 +237,7 @@ describe('rule-editor', () => {
const updatedQueries = queriesWithRemovedReferences(queries, 'A');
expect(updatedQueries[0]).toEqual(dataSource);
expect(updatedQueries[1].model.expression).toBeNull();
expect(updatedQueries[1].model.expression).toBeUndefined();
});
it('should clear reference in threshold expression when expression is removed', () => {
@@ -246,7 +246,7 @@ describe('rule-editor', () => {
expect(updatedQueries[0]).toEqual(dataSource);
expect(updatedQueries[1]).toEqual(reduceExpression);
expect(updatedQueries[2].model.expression).toBeNull();
expect(updatedQueries[2].model.expression).toBeUndefined();
});
it('should remove reference from math expression', () => {
@@ -282,7 +282,7 @@ describe('rule-editor', () => {
const queries: AlertQuery[] = [dataSource, resampleExpression];
const updatedQueries = queriesWithRemovedReferences(queries, 'A');
expect(updatedQueries[1].model.expression).toBeNull();
expect(updatedQueries[1].model.expression).toBeUndefined();
});
});
@@ -105,8 +105,8 @@ export function queriesWithRemovedReferences(queries: AlertQuery[], removedRefId
...query,
model: {
...query.model,
// Set to null so Select component shows no selection
expression: isReferencing ? null : query.model.expression,
// Set to undefined to clear the dangling reference
expression: isReferencing ? undefined : query.model.expression,
},
};
}
@@ -1,37 +1,39 @@
import * as React from 'react';
import { CoreApp, SelectableValue } from '@grafana/data';
import { CoreApp } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { Alert, InlineField, InlineFieldRow, Input, Select, TextLink } from '@grafana/ui';
import { Alert, Combobox, ComboboxOption, InlineField, InlineFieldRow, Input, TextLink } from '@grafana/ui';
import { ExpressionQuery, ExpressionQuerySettings, ReducerMode, reducerModes, reducerTypes } from '../types';
interface Props {
app?: CoreApp;
labelWidth?: number | 'auto';
refIds: Array<SelectableValue<string>>;
refIds: Array<ComboboxOption<string>>;
query: ExpressionQuery;
onChange: (query: ExpressionQuery) => void;
}
export const Reduce = ({ labelWidth = 'auto', onChange, app, refIds, query }: Props) => {
const reducer = reducerTypes.find((o) => o.value === query.reducer);
const onRefIdChange = (value: SelectableValue<string>) => {
onChange({ ...query, expression: value.value });
const onRefIdChange = (option: ComboboxOption<string> | null) => {
onChange({ ...query, expression: option?.value });
};
const onSelectReducer = (value: SelectableValue<string>) => {
onChange({ ...query, reducer: value.value });
const onSelectReducer = (option: ComboboxOption<string> | null) => {
onChange({ ...query, reducer: option?.value });
};
const onSettingsChanged = (settings: ExpressionQuerySettings) => {
onChange({ ...query, settings: settings });
};
const onModeChanged = (value: SelectableValue<ReducerMode>) => {
const onModeChanged = (option: ComboboxOption<ReducerMode> | null) => {
if (!option || option.value === null || option.value === undefined) {
return;
}
let newSettings: ExpressionQuerySettings;
switch (value.value) {
switch (option.value) {
case ReducerMode.Strict:
newSettings = { mode: ReducerMode.Strict };
break;
@@ -49,7 +51,7 @@ export const Reduce = ({ labelWidth = 'auto', onChange, app, refIds, query }: Pr
default:
newSettings = {
mode: value.value,
mode: option.value,
};
}
onSettingsChanged(newSettings);
@@ -101,15 +103,17 @@ export const Reduce = ({ labelWidth = 'auto', onChange, app, refIds, query }: Pr
{strictModeNotification()}
<InlineFieldRow>
<InlineField label={t('expressions.reduce.label-input', 'Input')} labelWidth={labelWidth}>
<Select onChange={onRefIdChange} options={refIds} value={query.expression} width={'auto'} />
<Combobox onChange={onRefIdChange} options={refIds} value={query.expression} width={50} />
</InlineField>
</InlineFieldRow>
<InlineFieldRow>
<InlineField label={t('expressions.reduce.label-function', 'Function')} labelWidth={labelWidth}>
<Select options={reducerTypes} value={reducer} onChange={onSelectReducer} width={20} />
<Combobox options={reducerTypes} value={query.reducer} onChange={onSelectReducer} width={50} />
</InlineField>
</InlineFieldRow>
<InlineFieldRow>
<InlineField label={t('expressions.reduce.label-mode', 'Mode')} labelWidth={labelWidth}>
<Select onChange={onModeChanged} options={reducerModes} value={mode} width={25} />
<Combobox onChange={onModeChanged} options={reducerModes} value={mode} width={50} />
</InlineField>
{replaceWithNumber()}
</InlineFieldRow>
+3 -2
View File
@@ -1,4 +1,5 @@
import { DataQuery, ReducerID, SelectableValue } from '@grafana/data';
import { ComboboxOption } from '@grafana/ui';
import { config } from 'app/core/config';
import { EvalFunction } from '../alerting/state/alertDef';
@@ -75,7 +76,7 @@ export const expressionTypes: Array<SelectableValue<ExpressionQueryType>> = [
return true;
});
export const reducerTypes: Array<SelectableValue<string>> = [
export const reducerTypes: Array<ComboboxOption<string>> = [
{ value: ReducerID.min, label: 'Min', description: 'Get the minimum value' },
{ value: ReducerID.max, label: 'Max', description: 'Get the maximum value' },
{ value: ReducerID.mean, label: 'Mean', description: 'Get the average value' },
@@ -91,7 +92,7 @@ export enum ReducerMode {
DropNonNumbers = 'dropNN',
}
export const reducerModes: Array<SelectableValue<ReducerMode>> = [
export const reducerModes: Array<ComboboxOption<ReducerMode>> = [
{
value: ReducerMode.Strict,
label: 'Strict',