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

Commit 3403416

Browse files
authored
Merge pull request #184 from quickwit-oss/fix_filters
fix: quick filters QoL
2 parents 1c13e5c + 1395b8c commit 3403416

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)