Compare commits

...

2 Commits

Author SHA1 Message Date
Ashwin Bhatkal
a40670ab21 chore: fix tests 2026-03-23 10:48:04 +05:30
Ashwin Bhatkal
10e6a50e2c fix: bugs in dashboard filter expression 2026-03-23 09:27:53 +05:30
3 changed files with 222 additions and 7 deletions

View File

@@ -1191,6 +1191,172 @@ describe('removeKeysFromExpression', () => {
expect(pairs).toHaveLength(2);
});
});
describe('Parenthesised expressions', () => {
it('should not leave a dangling AND when removing the last filter inside parens', () => {
const expression =
'(deployment.environment = $deployment.environment AND service.name = $service.name AND operation IN $top_level_operation)';
const result = removeKeysFromExpression(expression, ['operation'], true);
expect(result).toBe(
'(deployment.environment = $deployment.environment AND service.name = $service.name)',
);
});
it('should not leave a dangling AND when removing the first filter inside parens', () => {
const expression =
'(deployment.environment = $deployment.environment AND service.name = $service.name AND operation IN $top_level_operation)';
const result = removeKeysFromExpression(
expression,
['deployment.environment'],
true,
);
expect(result).toBe(
'(service.name = $service.name AND operation IN $top_level_operation)',
);
});
it('should not leave a dangling AND when removing a middle filter inside parens', () => {
const expression =
'(deployment.environment = $deployment.environment AND service.name = $service.name AND operation IN $top_level_operation)';
const result = removeKeysFromExpression(expression, ['service.name'], true);
expect(result).toBe(
'(deployment.environment = $deployment.environment AND operation IN $top_level_operation)',
);
});
it('should return empty parens when removing the only filter inside parens', () => {
const expression = '(operation IN $top_level_operation)';
const result = removeKeysFromExpression(expression, ['operation'], true);
expect(result).toBe('()');
});
});
});
describe('convertFiltersToExpressionWithExistingQuery — appending new filters', () => {
it('should insert a new filter inside parens with AND when expression is a single parenthesised group', () => {
const filters = {
items: [
{
id: '1',
key: {
id: 'deployment.environment',
key: 'deployment.environment',
type: 'string',
},
op: OPERATORS['='],
value: '$deployment.environment',
},
{
id: '2',
key: { id: 'service.name', key: 'service.name', type: 'string' },
op: OPERATORS['='],
value: '$service.name',
},
{
id: '3',
key: { id: 'host.id', key: 'host.id', type: 'string' },
op: 'IN',
value: '$host.id',
},
],
op: 'AND',
};
const existingQuery =
'(deployment.environment = $deployment.environment AND service.name = $service.name)';
const result = convertFiltersToExpressionWithExistingQuery(
filters,
existingQuery,
);
expect(result.filter.expression).toBe(
'(deployment.environment = $deployment.environment AND service.name = $service.name AND host.id in $host.id)',
);
});
it('should append a new filter with a space when expression is not parenthesised', () => {
const filters = {
items: [
{
id: '1',
key: { id: 'service.name', key: 'service.name', type: 'string' },
op: OPERATORS['='],
value: '$service.name',
},
{
id: '2',
key: { id: 'host.id', key: 'host.id', type: 'string' },
op: 'IN',
value: '$host.id',
},
],
op: 'AND',
};
const existingQuery = 'service.name = $service.name';
const result = convertFiltersToExpressionWithExistingQuery(
filters,
existingQuery,
);
expect(result.filter.expression).toBe(
'service.name = $service.name host.id in $host.id',
);
});
it('should insert inside parens without double-closing for a multi-filter group', () => {
const filters = {
items: [
{
id: '1',
key: {
id: 'deployment.environment',
key: 'deployment.environment',
type: 'string',
},
op: OPERATORS['='],
value: '$deployment.environment',
},
{
id: '2',
key: { id: 'service.name', key: 'service.name', type: 'string' },
op: OPERATORS['='],
value: '$service.name',
},
{
id: '3',
key: { id: 'operation', key: 'operation', type: 'string' },
op: 'IN',
value: '$top_level_operation',
},
{
id: '4',
key: { id: 'host.id', key: 'host.id', type: 'string' },
op: 'IN',
value: '$host.id',
},
],
op: 'AND',
};
const existingQuery =
'(deployment.environment = $deployment.environment AND service.name = $service.name AND operation IN $top_level_operation)';
const result = convertFiltersToExpressionWithExistingQuery(
filters,
existingQuery,
);
expect(result.filter.expression).toBe(
'(deployment.environment = $deployment.environment AND service.name = $service.name AND operation IN $top_level_operation AND host.id in $host.id)',
);
});
});
describe('formatValueForExpression', () => {

View File

@@ -217,6 +217,29 @@ export const convertExpressionToFilters = (
return filters;
};
/**
* Returns true if the expression is enclosed by a single matching outer parenthesis pair,
* e.g. "(a AND b AND c)". Used to decide whether to insert new filters inside or append.
*/
const isWrappedInParens = (expr: string): boolean => {
const trimmed = expr.trim();
if (!trimmed.startsWith('(') || !trimmed.endsWith(')')) {
return false;
}
let depth = 0;
for (let i = 0; i < trimmed.length - 1; i++) {
if (trimmed[i] === '(') {
depth += 1;
} else if (trimmed[i] === ')') {
depth -= 1;
}
if (depth === 0) {
return false;
} // outer paren closed before the end
}
return true;
};
const getQueryPairsMap = (query: string): Map<string, IQueryPair> => {
const queryPairs = extractQueryPairs(query);
const queryPairsMap: Map<string, IQueryPair> = new Map();
@@ -495,13 +518,18 @@ export const convertFiltersToExpressionWithExistingQuery = (
});
if (nonExistingFilterExpression.expression) {
const trimmedQuery = modifiedQuery.trim();
// If the existing expression is a single parenthesised group, insert the new
// filter inside the closing paren so the group stays intact.
// e.g. "(a AND b)" + "c" => "(a AND b AND c)" not "(a AND b) c"
const expression = isWrappedInParens(trimmedQuery)
? `${trimmedQuery.slice(0, -1)} AND ${
nonExistingFilterExpression.expression
})`
: `${trimmedQuery} ${nonExistingFilterExpression.expression}`;
return {
filters: updatedFilters,
filter: {
expression: `${modifiedQuery.trim()} ${
nonExistingFilterExpression.expression
}`,
},
filter: { expression },
};
}
@@ -569,7 +597,7 @@ export const removeKeysFromExpression = (
const currentQueryPair = queryPairsMap.get(`${key}`.trim().toLowerCase());
if (currentQueryPair && currentQueryPair.isComplete) {
// Determine the start index of the query pair (fallback order: key → operator → value)
const queryPairStart =
let queryPairStart =
currentQueryPair.position.keyStart ??
currentQueryPair.position.operatorStart ??
currentQueryPair.position.valueStart;
@@ -587,6 +615,15 @@ export const removeKeysFromExpression = (
// If match is found, extend the queryPairEnd to include the matched part
queryPairEnd += match[0].length;
}
// If no following conjunction was absorbed (e.g. removed pair is last in expression),
// absorb the preceding AND/OR instead to avoid leaving a dangling conjunction
if (!match?.[3]) {
const beforePair = updatedExpression.slice(0, queryPairStart);
const precedingConjunctionMatch = beforePair.match(/\s+(AND|OR)\s+$/i);
if (precedingConjunctionMatch) {
queryPairStart -= precedingConjunctionMatch[0].length;
}
}
// Remove the full query pair (including any conjunction/whitespace) from the expression
updatedExpression = `${updatedExpression.slice(
0,

View File

@@ -2,6 +2,7 @@
import { useCallback } from 'react';
import { useAddDynamicVariableToPanels } from 'hooks/dashboard/useAddDynamicVariableToPanels';
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import { useNotifications } from 'hooks/useNotifications';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
@@ -44,6 +45,7 @@ export const useDashboardVariableUpdate = (): UseDashboardVariableUpdateReturn =
const addDynamicVariableToPanels = useAddDynamicVariableToPanels();
const updateMutation = useUpdateDashboard();
const { notifications } = useNotifications();
const onValueUpdate = useCallback(
(
@@ -171,6 +173,15 @@ export const useDashboardVariableUpdate = (): UseDashboardVariableUpdateReturn =
// Get current dashboard variables
const currentVariables = selectedDashboard.data.variables || {};
// Prevent duplicate variable names
const nameExists = Object.values(currentVariables).some(
(v) => v.name === name,
);
if (nameExists) {
notifications.error({ message: `Variable "${name}" already exists` });
return;
}
// Create tableRowData like Dashboard Settings does
const tableRowData = [];
const variableOrderArr = [];
@@ -223,7 +234,8 @@ export const useDashboardVariableUpdate = (): UseDashboardVariableUpdateReturn =
// Convert to dashboard format and update
const updatedVariables = convertVariablesToDbFormat(tableRowData);
updateVariables(updatedVariables, newVariable.id, [], false);
// Don't pass currentRequestedId — variable creation should not modify widget filters.
updateVariables(updatedVariables);
},
[selectedDashboard, updateVariables],
);