Skip to content
This repository was archived by the owner on May 12, 2026. It is now read-only.

Commit 1395b8c

Browse files
committed
fix: quick filters QoL
Generate quoted phrase filters for text values that contain whitespace or punctuation, matching manually-entered Quickwit queries such as service_name:"auth-api" and attributes.grpc_message:"...". Keep unquoted term filters only for simple token values and escape term values correctly. Improve filter autocomplete with fuzzy matching for keys and values, and pass previous complete filters into later value lookups so suggestions narrow progressively. Add tests for phrase rendering, punctuated values, term escaping, fuzzy matching, and prior-filter selection. Signed-off-by: Patrik Cyvoct <patrik@ptrk.io>
1 parent 1c13e5c commit 1395b8c

8 files changed

Lines changed: 422 additions & 67 deletions

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { QueryFilter } from '@/types';
2+
3+
import { getPreviousAdHocFilters } from './index';
4+
5+
describe('FilterEditor helpers', () => {
6+
it('returns only complete filters before the current filter', () => {
7+
const filters: QueryFilter[] = [
8+
{
9+
id: 'first',
10+
filter: { key: 'service', operator: '=', value: 'frontend' },
11+
},
12+
{
13+
id: 'hidden',
14+
hide: true,
15+
filter: { key: 'cluster', operator: '=', value: 'prod' },
16+
},
17+
{
18+
id: 'incomplete',
19+
filter: { key: 'namespace', operator: '=', value: '' },
20+
},
21+
{
22+
id: 'current',
23+
filter: { key: 'attributes.grpc_message', operator: '=', value: '' },
24+
},
25+
{
26+
id: 'later',
27+
filter: { key: 'status', operator: '=', value: '500' },
28+
},
29+
];
30+
31+
expect(getPreviousAdHocFilters(filters, 'current')).toEqual([
32+
{ key: 'service', operator: '=', value: 'frontend' },
33+
]);
34+
});
35+
36+
it('does not include previous term filters with whitespace values', () => {
37+
const filters: QueryFilter[] = [
38+
{
39+
id: 'invalid-term',
40+
filter: { key: 'message', operator: 'term', value: 'invalid token' },
41+
},
42+
{
43+
id: 'current',
44+
filter: { key: 'status', operator: '=', value: '' },
45+
},
46+
];
47+
48+
expect(getPreviousAdHocFilters(filters, 'current')).toEqual([]);
49+
});
50+
});

src/components/QueryEditor/FilterEditor/index.tsx

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { QueryEditorRow } from '../QueryEditorRow';
77

88
import { QueryFilter } from '@/types';
99
import { Icon, InlineSegmentGroup, Segment, SegmentAsync, Tooltip } from '@grafana/ui';
10-
import { MetricFindValue, SelectableValue } from '@grafana/data';
10+
import { AdHocVariableFilter, MetricFindValue, SelectableValue } from '@grafana/data';
1111
import {
1212
addFilter,
1313
removeFilter,
@@ -17,10 +17,9 @@ import {
1717
changeFilterValue,
1818
} from '@/components/QueryEditor/FilterEditor/state/actions';
1919
import { segmentStyles } from '@/components/QueryEditor/styles';
20-
import { useFields } from '@/hooks/useFields';
2120
import { newFilterId } from '@/utils/uid';
2221
import { categorizeFieldType, filterOperations, filterOperationsFor } from '@/queryDef';
23-
import { hasWhiteSpace, isSet } from '@/utils';
22+
import { fuzzySearchSort, hasWhiteSpace, isSet } from '@/utils';
2423

2524
interface FilterEditorProps {
2625
onSubmit: () => void;
@@ -48,6 +47,30 @@ function filterErrors(filter: QueryFilter): string[] {
4847
return errors;
4948
}
5049

50+
function isFilterComplete(filter: QueryFilter): boolean {
51+
return !filter.hide && filterErrors(filter).length === 0;
52+
}
53+
54+
export function getPreviousAdHocFilters(filters: QueryFilter[] | undefined, currentId: QueryFilter['id']): AdHocVariableFilter[] {
55+
const currentIndex = filters?.findIndex((filter) => filter.id === currentId) ?? -1;
56+
if (!filters || currentIndex <= 0) {
57+
return [];
58+
}
59+
60+
return filters
61+
.slice(0, currentIndex)
62+
.filter(isFilterComplete)
63+
.map((filter) => filter.filter);
64+
}
65+
66+
function toFuzzyOptions(values: MetricFindValue[], query?: string): Array<SelectableValue<string>> {
67+
return fuzzySearchSort(
68+
values.map((value) => String(value.text)),
69+
(text) => text,
70+
query
71+
).map((text) => ({ label: text, value: text }));
72+
}
73+
5174
export const FilterEditor = ({ onSubmit }: FilterEditorProps) => {
5275
const dispatch = useDispatch();
5376
const { filters } = useQuery();
@@ -98,21 +121,27 @@ export const FilterEditorRow = ({ value, onSubmit }: FilterEditorRowProps) => {
98121
const dispatch = useDispatch();
99122
const datasource = useDatasource();
100123
const range = useRange();
101-
const getFields = useFields('filters', 'startsWith');
124+
const { filters } = useQuery();
125+
const previousFilters = getPreviousAdHocFilters(filters, value.id);
102126

103127
const fieldCategory = categorizeFieldType(datasource.getFieldType?.(value.filter.key));
104128
const visibleOperations = filterOperationsFor(fieldCategory);
105129

130+
const loadFields = async (query?: string): Promise<Array<SelectableValue<string>>> => {
131+
const values = await datasource.getTagKeys({ filters: previousFilters, timeRange: range });
132+
return toFuzzyOptions(values as MetricFindValue[], query);
133+
};
134+
106135
const loadValues = async (query?: string): Promise<Array<SelectableValue<string>>> => {
107136
if (!isSet(value.filter.key) || !datasource.getTagValues) {
108137
return [];
109138
}
110-
const values: MetricFindValue[] = await datasource.getTagValues({ key: value.filter.key, timeRange: range });
111-
const q = query?.toLowerCase();
112-
return values
113-
.map((v) => String(v.text))
114-
.filter((text) => !q || text.toLowerCase().includes(q))
115-
.map((text) => ({ label: text, value: text }));
139+
const values: MetricFindValue[] = await datasource.getTagValues({
140+
key: value.filter.key,
141+
filters: previousFilters,
142+
timeRange: range,
143+
});
144+
return toFuzzyOptions(values, query);
116145
};
117146

118147
return (
@@ -121,7 +150,7 @@ export const FilterEditorRow = ({ value, onSubmit }: FilterEditorRowProps) => {
121150
<SegmentAsync
122151
allowCustomValue={true}
123152
className={segmentStyles}
124-
loadOptions={getFields}
153+
loadOptions={loadFields}
125154
reloadOptionsOnChange={true}
126155
onChange={(e) => {
127156
const newKey = e.value ?? '';

src/datasource/base.test.ts

Lines changed: 152 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { formatQuery, luceneEscape } from './base';
1+
import { AdHocVariableFilter } from '@grafana/data';
2+
import { from } from 'rxjs';
3+
4+
import { addAddHocFilter } from '../modifyQuery';
5+
import { ElasticsearchQuery } from '../types';
6+
import { BaseQuickwitDataSource, formatQuery, luceneEscape } from './base';
27

38
describe('BaseQuickwitDataSource', () => {
49
describe('luceneEscape', () => {
@@ -171,4 +176,150 @@ describe('BaseQuickwitDataSource', () => {
171176
});
172177
});
173178
});
179+
180+
describe('quick filters', () => {
181+
const addFilterToQuery = (
182+
fieldTypes: Record<string, string>,
183+
query: ElasticsearchQuery,
184+
key: string,
185+
value: string,
186+
negate = false
187+
) => {
188+
return (BaseQuickwitDataSource.prototype as any).addFilterToQuery.call(
189+
{ fieldTypes },
190+
query,
191+
key,
192+
value,
193+
negate
194+
) as ElasticsearchQuery;
195+
};
196+
197+
const renderAdHocFilters = (
198+
fieldTypes: Record<string, string>,
199+
filters: AdHocVariableFilter[]
200+
) => {
201+
return (BaseQuickwitDataSource.prototype as any).addAdHocFilters.call(
202+
{ fieldTypes },
203+
'',
204+
filters
205+
) as string;
206+
};
207+
208+
it('adds text filters with whitespace as phrase filters', () => {
209+
const query = { refId: 'A', query: '', metrics: [], bucketAggs: [], filters: [] } as any;
210+
211+
const updatedQuery = addFilterToQuery(
212+
{ 'attributes.grpc_message': 'text' },
213+
query,
214+
'attributes.grpc_message',
215+
'Error:[(0) invalid token, ]'
216+
);
217+
218+
expect(updatedQuery.filters?.[0].filter).toEqual({
219+
key: 'attributes.grpc_message',
220+
operator: '=',
221+
value: 'Error:[(0) invalid token, ]',
222+
});
223+
});
224+
225+
it('renders text phrase filters with quoted Quickwit syntax', () => {
226+
const result = renderAdHocFilters(
227+
{ 'attributes.grpc_message': 'text' },
228+
[{
229+
key: 'attributes.grpc_message',
230+
operator: '=',
231+
value: 'Error:[(0) invalid token, ]',
232+
}]
233+
);
234+
235+
expect(result).toBe('attributes.grpc_message:"Error:[(0) invalid token, ]"');
236+
});
237+
238+
it('renders negative text phrase filters with quoted Quickwit syntax', () => {
239+
const result = renderAdHocFilters(
240+
{ 'attributes.grpc_message': 'text' },
241+
[{
242+
key: 'attributes.grpc_message',
243+
operator: '!=',
244+
value: 'Error:[(0) invalid token, ]',
245+
}]
246+
);
247+
248+
expect(result).toBe('-attributes.grpc_message:"Error:[(0) invalid token, ]"');
249+
});
250+
251+
it('keeps simple-token text filters as term filters', () => {
252+
const query = { refId: 'A', query: '', metrics: [], bucketAggs: [], filters: [] } as any;
253+
254+
const updatedQuery = addFilterToQuery(
255+
{ 'attributes.grpc_message': 'text' },
256+
query,
257+
'attributes.grpc_message',
258+
'unavailable'
259+
);
260+
261+
expect(updatedQuery.filters?.[0].filter.operator).toBe('term');
262+
});
263+
264+
it('keeps punctuated text filters as phrase filters', () => {
265+
const query = { refId: 'A', query: '', metrics: [], bucketAggs: [], filters: [] } as any;
266+
267+
const updatedQuery = addFilterToQuery(
268+
{ service_name: 'text' },
269+
query,
270+
'service_name',
271+
'auth-api'
272+
);
273+
274+
expect(updatedQuery.filters?.[0].filter).toEqual({
275+
key: 'service_name',
276+
operator: '=',
277+
value: 'auth-api',
278+
});
279+
});
280+
281+
it('renders punctuated text filters with quoted Quickwit syntax', () => {
282+
const result = renderAdHocFilters(
283+
{ service_name: 'text' },
284+
[{
285+
key: 'service_name',
286+
operator: '=',
287+
value: 'auth-api',
288+
}]
289+
);
290+
291+
expect(result).toBe('service_name:"auth-api"');
292+
});
293+
294+
it('escapes special characters in unquoted term filters', () => {
295+
const result = addAddHocFilter('', {
296+
key: 'attributes.grpc_message',
297+
operator: 'term',
298+
value: 'error:foo',
299+
});
300+
301+
expect(result).toBe('attributes.grpc_message:error\\:foo');
302+
});
303+
304+
it('applies prior filters when loading tag values', async () => {
305+
const getTerms = jest.fn(() => from([[]]));
306+
307+
await (BaseQuickwitDataSource.prototype as any).getTagValues.call(
308+
{
309+
fieldTypes: {},
310+
addAdHocFilters: BaseQuickwitDataSource.prototype.addAdHocFilters,
311+
getTerms,
312+
},
313+
{
314+
key: 'status',
315+
filters: [{ key: 'service', operator: '=', value: 'frontend' }],
316+
}
317+
);
318+
319+
expect(getTerms).toHaveBeenCalledWith(
320+
{ field: 'status', query: 'service:"frontend"' },
321+
undefined
322+
);
323+
});
324+
});
174325
});

0 commit comments

Comments
 (0)