Compare commits

..

1 Commits

Author SHA1 Message Date
aks07
94ea514e7c fix: remove spanScopeSelector key to sync scope filters 2026-05-19 13:58:35 +05:30
10 changed files with 99 additions and 332 deletions

View File

@@ -66,7 +66,7 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
const handleChangeGroupByKeys = useCallback(
(value: IBuilderQuery['groupBy']) => {
handleChangeQueryData('groupBy', value, { runAfterUpdate: true });
handleChangeQueryData('groupBy', value);
},
[handleChangeQueryData],
);

View File

@@ -283,14 +283,14 @@ function QueryAddOns({
const handleChangeGroupByKeys = useCallback(
(value: IBuilderQuery['groupBy']) => {
handleChangeQueryData('groupBy', value, { runAfterUpdate: true });
handleChangeQueryData('groupBy', value);
},
[handleChangeQueryData],
);
const handleChangeOrderByKeys = useCallback(
(value: IBuilderQuery['orderBy']) => {
handleChangeQueryData('orderBy', value, { runAfterUpdate: true });
handleChangeQueryData('orderBy', value);
},
[handleChangeQueryData],
);

View File

@@ -73,8 +73,8 @@ export const QueryV2 = forwardRef(function QueryV2(
});
const handleToggleDisableQuery = useCallback(() => {
handleChangeQueryData('disabled', !query.disabled, { runAfterUpdate: true });
}, [handleChangeQueryData, query.disabled]);
handleChangeQueryData('disabled', !query.disabled);
}, [handleChangeQueryData, query]);
const handleToggleCollapsQuery = (): void => {
setIsCollapsed(!isCollapsed);

View File

@@ -16,35 +16,31 @@ enum SpanScope {
ENTRYPOINT_SPANS = 'entrypoint_spans',
}
interface SpanFilterConfig {
key: string;
type: string;
}
interface SpanScopeSelectorProps {
onChange?: (value: TagFilter) => void;
query?: IBuilderQuery;
skipQueryBuilderRedirect?: boolean;
}
const SPAN_FILTER_CONFIG: Record<SpanScope, SpanFilterConfig | null> = {
const SPAN_FILTER_KEY: Record<SpanScope, string | null> = {
[SpanScope.ALL_SPANS]: null,
[SpanScope.ROOT_SPANS]: {
key: 'isRoot',
type: 'spanSearchScope',
},
[SpanScope.ENTRYPOINT_SPANS]: {
key: 'isEntryPoint',
type: 'spanSearchScope',
},
[SpanScope.ROOT_SPANS]: 'isRoot',
[SpanScope.ENTRYPOINT_SPANS]: 'isEntryPoint',
};
const createFilterItem = (config: SpanFilterConfig): TagFilterItem => ({
const SCOPE_FILTER_KEYS = Object.values(SPAN_FILTER_KEY).filter(
(key): key is string => key !== null,
);
const isScopeFilter = (filter: TagFilterItem, key: string): boolean =>
filter.key?.key === key && String(filter.value) === 'true';
const createFilterItem = (key: string): TagFilterItem => ({
id: uuid().slice(0, 8),
key: {
key: config.key,
key,
dataType: undefined,
type: config?.type,
type: '',
},
op: '=',
value: 'true',
@@ -70,12 +66,7 @@ function SpanScopeSelector({
filters: TagFilterItem[] = [],
): SpanScope => {
const hasFilter = (key: string): boolean =>
filters?.some(
(filter) =>
filter.key?.type === 'spanSearchScope' &&
filter.key.key === key &&
filter.value === 'true',
);
filters?.some((filter) => isScopeFilter(filter, key));
if (hasFilter('isRoot')) {
return SpanScope.ROOT_SPANS;
@@ -113,28 +104,21 @@ function SpanScopeSelector({
const nonScopeFilters = currentFilters.filter(
(filter) =>
!(
filter.key?.type === 'spanSearchScope' &&
(filter.key.key === 'isRoot' || filter.key.key === 'isEntryPoint')
),
!SCOPE_FILTER_KEYS.some((scopeKey) => isScopeFilter(filter, scopeKey)),
);
const config = SPAN_FILTER_CONFIG[newScope];
const newScopeFilter = config !== null ? [createFilterItem(config)] : [];
const scopeKey = SPAN_FILTER_KEY[newScope];
const newScopeFilter = scopeKey !== null ? [createFilterItem(scopeKey)] : [];
return [...nonScopeFilters, ...newScopeFilter];
};
const keysToRemove = Object.values(SPAN_FILTER_CONFIG)
.map((config) => config?.key)
.filter((key): key is string => typeof key === 'string');
newQuery.builder.queryData = newQuery.builder.queryData.map((item) => ({
...item,
filter: {
expression: removeKeysFromExpression(
item.filter?.expression ?? '',
keysToRemove,
SCOPE_FILTER_KEYS,
),
},
filters: {

View File

@@ -20,12 +20,16 @@ import SpanScopeSelector from '../SpanScopeSelector';
const mockRedirectWithQueryBuilderData = jest.fn();
const SCOPE_KEYS = ['isRoot', 'isEntryPoint'];
const isScopeFilter = (filter: TagFilterItem): boolean =>
SCOPE_KEYS.includes(filter.key?.key ?? '') && String(filter.value) === 'true';
// Helper to create filter items
const createSpanScopeFilter = (key: string): TagFilterItem => ({
id: 'span-filter',
key: {
key,
type: 'spanSearchScope',
type: '',
},
op: '=',
value: 'true',
@@ -143,7 +147,6 @@ describe('SpanScopeSelector', () => {
expect.objectContaining({
key: expect.objectContaining({
key: expectedKey,
type: 'spanSearchScope',
}),
op: '=',
value: 'true',
@@ -162,11 +165,7 @@ describe('SpanScopeSelector', () => {
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalled();
const updatedQuery = mockRedirectWithQueryBuilderData.mock.calls[0][0];
const filters = updatedQuery.builder.queryData[0].filters.items;
expect(filters).not.toContainEqual(
expect.objectContaining({
key: expect.objectContaining({ type: 'spanSearchScope' }),
}),
);
expect(filters.some(isScopeFilter)).toBe(false);
});
it('should add isRoot filter when selecting ROOT_SPANS', async () => {
@@ -206,6 +205,27 @@ describe('SpanScopeSelector', () => {
await expect(screen.findByText(expectedText)).resolves.toBeInTheDocument();
},
);
// Round-trip from filter.expression can deserialize the value as a boolean
// `true` (unquoted in the expression) instead of the string `'true'` produced
// by the dropdown. The dropdown must still recognize that as the scope filter.
it.each([
['Root Spans', 'isRoot'],
['Entrypoint Spans', 'isEntryPoint'],
])(
'should initialize with %s selected when %s = true (boolean value)',
async (expectedText, filterKey) => {
const booleanScopeFilter: TagFilterItem = {
id: 'span-filter',
key: { key: filterKey, type: '' },
op: '=',
value: true as unknown as string,
};
const queryWithFilter = createQueryWithFilters([booleanScopeFilter]);
renderWithContext(queryWithFilter, undefined, defaultQueryBuilderQuery);
await expect(screen.findByText(expectedText)).resolves.toBeInTheDocument();
},
);
});
describe('when onChange and query props are provided', () => {
@@ -233,9 +253,7 @@ describe('SpanScopeSelector', () => {
expect(items).toContainEqual(nonScopeItem);
});
const scopeFiltersInPayload = items.filter(
(filter) => filter.key?.type === 'spanSearchScope',
);
const scopeFiltersInPayload = items.filter(isScopeFilter);
if (expectedScopeKey) {
expect(scopeFiltersInPayload).toHaveLength(1);
@@ -434,9 +452,7 @@ describe('SpanScopeSelector', () => {
items: [],
};
// Count non-scope filters
const nonScopeFilters = items.filter(
(filter) => filter.key?.type !== 'spanSearchScope',
);
const nonScopeFilters = items.filter((filter) => !isScopeFilter(filter));
expect(nonScopeFilters).toHaveLength(1);
expect(nonScopeFilters).toContainEqual(

View File

@@ -5,8 +5,7 @@ import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import {
DataSource,
MetricAggregateOperator,
@@ -334,196 +333,3 @@ describe('useQueryBuilderOperations - Empty Aggregate Attribute Type', () => {
});
});
});
describe('useQueryBuilderOperations - handleChangeQueryData runAfterUpdate', () => {
const mockHandleSetQueryData = jest.fn();
const mockHandleSetTraceOperatorData = jest.fn();
const mockHandleRunQuery = jest.fn();
const baseQuery: IBuilderQuery = {
dataSource: DataSource.METRICS,
aggregateOperator: MetricAggregateOperator.AVG,
aggregateAttribute: {
key: 'system.cpu.load',
dataType: DataTypes.Float64,
type: ATTRIBUTE_TYPES.GAUGE,
} as BaseAutocompleteData,
timeAggregation: MetricAggregateOperator.AVG,
spaceAggregation: '',
aggregations: [],
having: [],
limit: null,
queryName: 'A',
functions: [],
filters: { items: [], op: 'AND' },
groupBy: [],
orderBy: [],
stepInterval: 60,
expression: '',
disabled: false,
reduceTo: ReduceOperators.AVG,
legend: '',
};
const otherQuery: IBuilderQuery = { ...baseQuery, queryName: 'B' };
const buildCurrentQuery = (): Query => ({
queryType: EQueryType.QUERY_BUILDER,
promql: [],
clickhouse_sql: [],
id: 'q-1',
unit: '',
builder: {
queryData: [baseQuery, otherQuery],
queryFormulas: [],
queryTraceOperator: [],
},
});
const setupMock = (overrides: Record<string, unknown> = {}): void => {
(useQueryBuilder as jest.Mock).mockReturnValue({
handleSetQueryData: mockHandleSetQueryData,
handleSetTraceOperatorData: mockHandleSetTraceOperatorData,
handleSetFormulaData: jest.fn(),
removeQueryBuilderEntityByIndex: jest.fn(),
setLastUsedQuery: jest.fn(),
redirectWithQueryBuilderData: jest.fn(),
handleRunQuery: mockHandleRunQuery,
panelType: 'time_series',
currentQuery: buildCurrentQuery(),
...overrides,
});
};
beforeEach(() => {
jest.clearAllMocks();
setupMock();
});
it('does not call handleRunQuery when options is omitted', () => {
const { result } = renderHook(() =>
useQueryOperations({
query: baseQuery,
index: 0,
entityVersion: ENTITY_VERSION_V4,
}),
);
act(() => {
result.current.handleChangeQueryData('legend', 'cpu-load');
});
expect(mockHandleSetQueryData).toHaveBeenCalledWith(
0,
expect.objectContaining({ legend: 'cpu-load' }),
);
expect(mockHandleRunQuery).not.toHaveBeenCalled();
});
it('calls handleRunQuery with the freshly-changed query when runAfterUpdate is true', () => {
const { result } = renderHook(() =>
useQueryOperations({
query: baseQuery,
index: 0,
entityVersion: ENTITY_VERSION_V4,
}),
);
act(() => {
result.current.handleChangeQueryData('disabled', true, {
runAfterUpdate: true,
});
});
expect(mockHandleSetQueryData).toHaveBeenCalledWith(
0,
expect.objectContaining({ disabled: true }),
);
expect(mockHandleRunQuery).toHaveBeenCalledTimes(1);
const [override] = mockHandleRunQuery.mock.calls[0];
// Index 0 reflects the new value...
expect(override.builder.queryData[0]).toStrictEqual(
expect.objectContaining({ queryName: 'A', disabled: true }),
);
// ...siblings stay untouched.
expect(override.builder.queryData[1]).toStrictEqual(
expect.objectContaining({ queryName: 'B', disabled: false }),
);
});
it('applies the change at the correct index without disturbing other queries', () => {
const { result } = renderHook(() =>
useQueryOperations({
query: otherQuery,
index: 1,
entityVersion: ENTITY_VERSION_V4,
}),
);
act(() => {
result.current.handleChangeQueryData(
'groupBy',
[
{
key: 'host.name',
type: 'tag',
dataType: DataTypes.String,
} as BaseAutocompleteData,
],
{ runAfterUpdate: true },
);
});
const [override] = mockHandleRunQuery.mock.calls[0];
expect(override.builder.queryData[0]).toStrictEqual(
expect.objectContaining({ queryName: 'A', groupBy: [] }),
);
expect(override.builder.queryData[1]).toStrictEqual(
expect.objectContaining({
queryName: 'B',
groupBy: [expect.objectContaining({ key: 'host.name' })],
}),
);
});
it('keeps handleSetQueryData and handleRunQuery in sync for legend formatting', () => {
const { result } = renderHook(() =>
useQueryOperations({
query: baseQuery,
index: 0,
entityVersion: ENTITY_VERSION_V4,
}),
);
act(() => {
result.current.handleChangeQueryData('legend', '{{service.name}}', {
runAfterUpdate: true,
});
});
const [override] = mockHandleRunQuery.mock.calls[0];
const setCallLegend = mockHandleSetQueryData.mock.calls[0][1].legend;
expect(override.builder.queryData[0].legend).toBe(setCallLegend);
});
it('does not call handleRunQuery for trace-operator queries (early return)', () => {
const { result } = renderHook(() =>
useQueryOperations({
query: baseQuery,
index: 0,
entityVersion: ENTITY_VERSION_V4,
isForTraceOperator: true,
}),
);
act(() => {
result.current.handleChangeQueryData('disabled', true, {
runAfterUpdate: true,
});
});
expect(mockHandleSetTraceOperatorData).toHaveBeenCalledTimes(1);
expect(mockHandleSetQueryData).not.toHaveBeenCalled();
expect(mockHandleRunQuery).not.toHaveBeenCalled();
});
});

View File

@@ -45,7 +45,6 @@ import {
import {
HandleChangeFormulaData,
HandleChangeQueryData,
HandleChangeQueryDataOptions,
HandleChangeQueryDataV5,
UseQueryOperations,
} from 'types/common/operations.types';
@@ -77,7 +76,6 @@ export const useQueryOperations: UseQueryOperations = ({
currentQuery,
setLastUsedQuery,
redirectWithQueryBuilderData,
handleRunQuery,
} = useQueryBuilder();
const [operators, setOperators] = useState<SelectOption<string, string>[]>([]);
@@ -532,7 +530,7 @@ export const useQueryOperations: UseQueryOperations = ({
const handleChangeQueryData: HandleChangeQueryData | HandleChangeQueryDataV5 =
useCallback(
(key: string, value: any, options?: HandleChangeQueryDataOptions) => {
(key: string, value: any) => {
const newQuery = {
...query,
[key]:
@@ -543,24 +541,8 @@ export const useQueryOperations: UseQueryOperations = ({
if (isForTraceOperator) {
handleSetTraceOperatorData(index, newQuery);
return;
}
handleSetQueryData(index, newQuery);
// `runAfterUpdate` lets callers stage-and-run inline. We pass the
// freshly-computed query straight to `handleRunQuery` because the
// setState above hasn't flushed yet.
if (options?.runAfterUpdate) {
handleRunQuery({
...currentQuery,
builder: {
...currentQuery.builder,
queryData: currentQuery.builder.queryData.map((item, i) =>
i === index ? newQuery : item,
),
},
});
} else {
handleSetQueryData(index, newQuery);
}
},
[
@@ -569,8 +551,6 @@ export const useQueryOperations: UseQueryOperations = ({
handleSetQueryData,
handleSetTraceOperatorData,
isForTraceOperator,
handleRunQuery,
currentQuery,
],
);

View File

@@ -1024,57 +1024,49 @@ export function QueryBuilderProvider({
[],
);
// `overrideQuery` lets callers run a query value that hasn't been committed
// to `currentQuery` state yet — e.g. a click handler that toggles a flag
// and wants to stage-and-run in the same tick, without waiting for the
// state update to flush.
const handleRunQuery = useCallback(
(overrideQuery?: Query) => {
const isExplorer =
location.pathname === ROUTES.LOGS_EXPLORER ||
location.pathname === ROUTES.TRACES_EXPLORER;
if (isExplorer) {
setCalledFromHandleRunQuery(true);
}
const sourceQuery = overrideQuery ?? currentQuery;
const currentQueryData = {
...sourceQuery,
builder: {
...sourceQuery.builder,
queryData: sourceQuery.builder.queryData.map((item) => ({
...item,
filter: {
...item.filter,
expression:
item.filter?.expression.trim() === ''
? ''
: (item.filter?.expression ?? ''),
},
filters: {
items: [],
op: 'AND',
},
})),
},
};
const handleRunQuery = useCallback(() => {
const isExplorer =
location.pathname === ROUTES.LOGS_EXPLORER ||
location.pathname === ROUTES.TRACES_EXPLORER;
if (isExplorer) {
setCalledFromHandleRunQuery(true);
}
const currentQueryData = {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: currentQuery.builder.queryData.map((item) => ({
...item,
filter: {
...item.filter,
expression:
item.filter?.expression.trim() === ''
? ''
: (item.filter?.expression ?? ''),
},
filters: {
items: [],
op: 'AND',
},
})),
},
};
redirectWithQueryBuilderData({
...{
...currentQueryData,
...updateStepInterval({
builder: currentQueryData.builder,
clickhouse_sql: currentQueryData.clickhouse_sql,
promql: currentQueryData.promql,
id: currentQueryData.id,
queryType,
unit: currentQueryData.unit,
}),
},
queryType,
});
},
[currentQuery, location.pathname, queryType, redirectWithQueryBuilderData],
);
redirectWithQueryBuilderData({
...{
...currentQueryData,
...updateStepInterval({
builder: currentQueryData.builder,
clickhouse_sql: currentQueryData.clickhouse_sql,
promql: currentQueryData.promql,
id: currentQueryData.id,
queryType,
unit: currentQueryData.unit,
}),
},
queryType,
});
}, [currentQuery, location.pathname, queryType, redirectWithQueryBuilderData]);
useEffect(() => {
if (location.pathname !== currentPathnameRef.current) {

View File

@@ -26,16 +26,6 @@ type UseQueryOperationsParams = Pick<QueryProps, 'index' | 'query'> &
savePreviousQuery?: boolean;
};
export interface HandleChangeQueryDataOptions {
/**
* When true, stage-and-run the query immediately after the local state
* update — no need to wait for the user to click "Stage and Run".
* Useful for inline toggles (visibility, disable, etc.) where the panel
* should reflect the change without an extra click.
*/
runAfterUpdate?: boolean;
}
// Generic type that can work with both legacy and V5 query types
export type HandleChangeQueryData<T = IBuilderQuery> = <
Key extends keyof T,
@@ -43,7 +33,6 @@ export type HandleChangeQueryData<T = IBuilderQuery> = <
>(
key: Key,
value: Value,
options?: HandleChangeQueryDataOptions,
) => void;
export type HandleChangeTraceOperatorData<T = IBuilderTraceOperator> = <

View File

@@ -280,7 +280,7 @@ export type QueryBuilderContextType = {
shallStringify?: boolean,
newTab?: boolean,
) => void;
handleRunQuery: (overrideQuery?: Query) => void;
handleRunQuery: () => void;
resetQuery: (newCurrentQuery?: QueryState) => void;
handleOnUnitsChange: (units: Format['id']) => void;
updateAllQueriesOperators: (