Skip to content

Commit 47a4bfa

Browse files
committed
feat: Added ability to add quick filters
1 parent 888c3e6 commit 47a4bfa

File tree

20 files changed

+384
-23
lines changed

20 files changed

+384
-23
lines changed

src/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/TermsSettingsEditor.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ describe('Terms Settings Editor', () => {
2222
query: '',
2323
bucketAggs: [termsAgg],
2424
metrics: [avg, derivative, topMetrics],
25+
filters: [],
2526
};
2627

2728
renderWithESProvider(<TermsSettingsEditor bucketAgg={termsAgg} />, { providerProps: { query } });

src/components/QueryEditor/ElasticsearchQueryContext.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const query: ElasticsearchQuery = {
1313
query: '',
1414
metrics: [{ id: '1', type: 'count' }],
1515
bucketAggs: [{ type: 'date_histogram', id: '2' }],
16+
filters: []
1617
};
1718

1819
describe('ElasticsearchQueryContext', () => {

src/components/QueryEditor/ElasticsearchQueryContext.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { ElasticsearchQuery } from '@/types';
88

99
import { createReducer as createBucketAggsReducer } from './BucketAggregationsEditor/state/reducer';
1010
import { reducer as metricsReducer } from './MetricAggregationsEditor/state/reducer';
11+
import { reducer as filtersReducer } from './FilterEditor/state/reducer';
1112
import { aliasPatternReducer, queryReducer, initQuery, initExploreQuery } from './state';
1213
import { getHook } from '@/utils/context';
1314
import { Provider, useDispatch } from "react-redux";
@@ -64,10 +65,11 @@ export const ElasticsearchProvider = withStore(({
6465
[onChange]
6566
);
6667

67-
const reducer = combineReducers<Pick<ElasticsearchQuery, 'query' | 'alias' | 'metrics' | 'bucketAggs'>>({
68+
const reducer = combineReducers<Pick<ElasticsearchQuery, 'query' | 'alias' | 'metrics' | 'filters' | 'bucketAggs'>>({
6869
query: queryReducer,
6970
alias: aliasPatternReducer,
7071
metrics: metricsReducer,
72+
filters: filtersReducer,
7173
bucketAggs: createBucketAggsReducer(datasource.timeField),
7274
});
7375

@@ -78,7 +80,7 @@ export const ElasticsearchProvider = withStore(({
7880
reducer
7981
);
8082

81-
const isUninitialized = !query.metrics || !query.bucketAggs || query.query === undefined;
83+
const isUninitialized = !query.metrics || !query.filters || !query.bucketAggs || query.query === undefined;
8284

8385
const [shouldRunInit, setShouldRunInit] = useState(isUninitialized);
8486

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import React, { useRef } from 'react';
2+
3+
import { useDispatch } from '@/hooks/useStatelessReducer';
4+
import { IconButton } from '../../IconButton';
5+
import { useQuery } from '../ElasticsearchQueryContext';
6+
import { QueryEditorRow } from '../QueryEditorRow';
7+
8+
import { QueryFilter } from '@/types';
9+
import { InlineSegmentGroup, Input, Segment, SegmentAsync, Tooltip } from '@grafana/ui';
10+
import {
11+
addFilter,
12+
removeFilter,
13+
toggleFilterVisibility,
14+
changeFilterField,
15+
changeFilterOperation,
16+
changeFilterValue,
17+
} from '@/components/QueryEditor/FilterEditor/state/actions';
18+
import { segmentStyles } from '@/components/QueryEditor/styles';
19+
import { useFields } from '@/hooks/useFields';
20+
import { newFilterId } from '@/utils/uid';
21+
import { filterOperations } from '@/queryDef';
22+
23+
interface FilterEditorProps {
24+
onSubmit: () => void;
25+
}
26+
27+
const isSet = (val: any) => val !== undefined && val !== null && val !== '';
28+
29+
function filterErrors(filter: QueryFilter): string[] {
30+
const errors: string[] = [];
31+
32+
if (!isSet(filter.filter.key)) {
33+
errors.push('Field is not set');
34+
}
35+
36+
if (!isSet(filter.filter.operator)) {
37+
errors.push('Operator is not set');
38+
}
39+
40+
if (!['exists', 'not exists'].includes(filter.filter.operator) && !isSet(filter.filter.value)) {
41+
errors.push('Value is not set');
42+
}
43+
44+
return errors;
45+
}
46+
47+
export const FilterEditor = ({ onSubmit }: FilterEditorProps) => {
48+
const dispatch = useDispatch();
49+
const { filters } = useQuery();
50+
51+
return (
52+
<>
53+
{filters?.map((filter, index) => {
54+
const errors = filterErrors(filter)
55+
return (
56+
<QueryEditorRow
57+
key={`${filter.id}`}
58+
label={errors.length > 0 ? (
59+
<Tooltip content={errors.join('; ')}>
60+
<span style={{color: "gray"}}>Filter</span>
61+
</Tooltip>
62+
): 'Filter'}
63+
hidden={filter.hide}
64+
onHideClick={() => {
65+
dispatch(toggleFilterVisibility(filter.id));
66+
onSubmit();
67+
}}
68+
onRemoveClick={() => {
69+
dispatch(removeFilter(filter.id));
70+
onSubmit();
71+
}}
72+
>
73+
<FilterEditorRow value={filter} onSubmit={onSubmit} />
74+
75+
{index === 0 && <IconButton
76+
label="add"
77+
iconName="plus"
78+
style={{marginLeft: '4px'}}
79+
onClick={() => dispatch(addFilter(newFilterId()))}
80+
/>}
81+
</QueryEditorRow>
82+
)
83+
})}
84+
</>
85+
);
86+
};
87+
88+
interface FilterEditorRowProps {
89+
value: QueryFilter;
90+
onSubmit: () => void;
91+
}
92+
93+
export const FilterEditorRow = ({ value, onSubmit }: FilterEditorRowProps) => {
94+
const dispatch = useDispatch();
95+
const getFields = useFields('filters', 'startsWith');
96+
const valueInputRef = useRef<HTMLInputElement>(null);
97+
98+
return (
99+
<>
100+
<InlineSegmentGroup>
101+
<SegmentAsync
102+
allowCustomValue={true}
103+
className={segmentStyles}
104+
loadOptions={getFields}
105+
reloadOptionsOnChange={true}
106+
onChange={(e) => {
107+
dispatch(changeFilterField({ id: value.id, field: e.value ?? '' }));
108+
if (['exists', 'not exists'].includes(value.filter.operator) || isSet(value.filter.value)) {
109+
onSubmit();
110+
}
111+
// Auto focus the value input when a field is selected
112+
setTimeout(() => valueInputRef.current?.focus(), 100);
113+
}}
114+
placeholder="Select Field"
115+
value={value.filter.key}
116+
/>
117+
<div style={{ whiteSpace: 'nowrap' }}>
118+
<Segment
119+
value={filterOperations.find((op) => op.value === value.filter.operator)}
120+
options={filterOperations}
121+
onChange={(e) => {
122+
let op = e.value ?? filterOperations[0].value;
123+
dispatch(changeFilterOperation({ id: value.id, op: op }));
124+
if (['exists', 'not exists'].includes(op) || isSet(value.filter.value)) {
125+
onSubmit();
126+
}
127+
}}
128+
/>
129+
</div>
130+
{!['exists', 'not exists'].includes(value.filter.operator) && (
131+
<Input
132+
ref={valueInputRef}
133+
placeholder="Value"
134+
value={value.filter.value}
135+
onChange={(e) => dispatch(changeFilterValue({ id: value.id, value: e.currentTarget.value }))}
136+
onKeyUp={(e) => {
137+
if (e.key === 'Enter') {
138+
onSubmit();
139+
}
140+
}}
141+
/>
142+
)}
143+
</InlineSegmentGroup>
144+
</>
145+
);
146+
};
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { createAction } from '@reduxjs/toolkit';
2+
3+
import { QueryFilter } from '@/types';
4+
5+
export const addFilter = createAction<QueryFilter['id']>('@filters/add');
6+
export const removeFilter = createAction<QueryFilter['id']>('@filters/remove');
7+
export const toggleFilterVisibility = createAction<QueryFilter['id']>('@filters/toggle_visibility');
8+
export const changeFilterField = createAction<{ id: QueryFilter['id']; field: string }>('@filters/change_field');
9+
export const changeFilterValue = createAction<{ id: QueryFilter['id']; value: string }>('@filters/change_value');
10+
export const changeFilterOperation = createAction<{ id: QueryFilter['id']; op: string }>('@filters/change_operation');
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { Action } from '@reduxjs/toolkit';
2+
import { defaultFilter } from '@/queryDef';
3+
import { ElasticsearchQuery } from '@/types';
4+
import { initExploreQuery, initQuery } from '../../state';
5+
6+
import {
7+
addFilter,
8+
changeFilterField,
9+
changeFilterOperation,
10+
changeFilterValue,
11+
removeFilter,
12+
toggleFilterVisibility,
13+
} from './actions';
14+
15+
export const reducer = (state: ElasticsearchQuery['filters'], action: Action): ElasticsearchQuery['filters'] => {
16+
// console.log('Running filters reducer with action:', action, state);
17+
18+
if (addFilter.match(action)) {
19+
return [...state!, defaultFilter(action.payload)];
20+
}
21+
22+
if (removeFilter.match(action)) {
23+
const filterToRemove = state!.find((m) => m.id === action.payload)!;
24+
const resultingFilters = state!.filter((filter) => filterToRemove.id !== filter.id);
25+
if (resultingFilters.length === 0) {
26+
return [defaultFilter()];
27+
}
28+
return resultingFilters;
29+
}
30+
31+
if (changeFilterField.match(action)) {
32+
return state!.map((filter) => {
33+
if (filter.id !== action.payload.id) {
34+
return filter;
35+
}
36+
37+
return {
38+
...filter,
39+
filter: {
40+
...filter.filter,
41+
key: action.payload.field,
42+
}
43+
};
44+
});
45+
}
46+
47+
if (changeFilterOperation.match(action)) {
48+
return state!.map((filter) => {
49+
if (filter.id !== action.payload.id) {
50+
return filter;
51+
}
52+
53+
return {
54+
...filter,
55+
filter: {
56+
...filter.filter,
57+
operator: action.payload.op,
58+
}
59+
};
60+
});
61+
}
62+
63+
if (changeFilterValue.match(action)) {
64+
return state!.map((filter) => {
65+
if (filter.id !== action.payload.id) {
66+
return filter;
67+
}
68+
69+
return {
70+
...filter,
71+
filter: {
72+
...filter.filter,
73+
value: action.payload.value,
74+
}
75+
};
76+
});
77+
}
78+
79+
if (toggleFilterVisibility.match(action)) {
80+
return state!.map((filter) => {
81+
if (filter.id !== action.payload) {
82+
return filter;
83+
}
84+
85+
return {
86+
...filter,
87+
hide: !filter.hide,
88+
};
89+
});
90+
}
91+
92+
if (initQuery.match(action) || initExploreQuery.match(action)) {
93+
if (state && state.length > 0) {
94+
return state;
95+
}
96+
return [defaultFilter()];
97+
}
98+
99+
return state;
100+
};

src/components/QueryEditor/MetricAggregationsEditor/MetricEditor.test.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ describe('Metric Editor', () => {
2424
query: '',
2525
metrics: [avg],
2626
bucketAggs: [defaultBucketAgg('2')],
27+
filters: [],
2728
};
2829

2930
const getFields: ElasticDatasource['getFields'] = jest.fn(() => from([[]]));
@@ -62,6 +63,7 @@ describe('Metric Editor', () => {
6263
query: '',
6364
metrics: [count],
6465
bucketAggs: [],
66+
filters: [],
6567
};
6668

6769
const wrapper = ({ children }: PropsWithChildren<{}>) => (

src/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/index.test.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ describe('Settings Editor', () => {
2727
},
2828
],
2929
bucketAggs: [],
30+
filters: [],
3031
};
3132

3233
const onChange = jest.fn();
@@ -102,6 +103,7 @@ describe('Settings Editor', () => {
102103
},
103104
],
104105
bucketAggs: [],
106+
filters: [],
105107
};
106108

107109
const onChange = jest.fn();

src/components/QueryEditor/index.test.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ describe('QueryEditor', () => {
2020
],
2121
// Even if present, this shouldn't be shown in the UI
2222
bucketAggs: [{ id: '2', type: 'date_histogram' }],
23+
filters: [],
2324
};
2425

2526
render(<QueryEditor query={query} datasource={{} as ElasticDatasource} onChange={noop} onRunQuery={noop} />);
@@ -38,6 +39,7 @@ describe('QueryEditor', () => {
3839
},
3940
],
4041
bucketAggs: [{ id: '2', type: 'date_histogram' }],
42+
filters: [],
4143
};
4244

4345
render(<QueryEditor query={query} datasource={{} as ElasticDatasource} onChange={noop} onRunQuery={noop} />);

src/components/QueryEditor/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { QueryTypeSelector } from './QueryTypeSelector';
2525
import { getHook } from '@/utils/context';
2626
import { LuceneQueryEditor } from '@/components/LuceneQueryEditor';
2727
import { useDatasourceFields } from '@/datasource/utils';
28+
import { FilterEditor } from '@/components/QueryEditor/FilterEditor';
2829

2930
export type ElasticQueryEditorProps = QueryEditorProps<ElasticDatasource, ElasticsearchQuery, QuickwitOptions>;
3031

@@ -133,6 +134,7 @@ const QueryEditorForm = ({ value, onRunQuery }: Props) => {
133134
value={value?.query}
134135
onSubmit={onSubmitCB}/>
135136
</div>
137+
<FilterEditor onSubmit={onRunQuery} />
136138

137139
<MetricAggregationsEditor nextId={nextId} />
138140
{showBucketAggregationsEditor && <BucketAggregationsEditor nextId={nextId} />}

0 commit comments

Comments
 (0)