Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ describe('Terms Settings Editor', () => {
query: '',
bucketAggs: [termsAgg],
metrics: [avg, derivative, topMetrics],
filters: [],
};

renderWithESProvider(<TermsSettingsEditor bucketAgg={termsAgg} />, { providerProps: { query } });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const query: ElasticsearchQuery = {
query: '',
metrics: [{ id: '1', type: 'count' }],
bucketAggs: [{ type: 'date_histogram', id: '2' }],
filters: []
};

describe('ElasticsearchQueryContext', () => {
Expand Down
6 changes: 4 additions & 2 deletions src/components/QueryEditor/ElasticsearchQueryContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { ElasticsearchQuery } from '@/types';

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

const reducer = combineReducers<Pick<ElasticsearchQuery, 'query' | 'alias' | 'metrics' | 'bucketAggs'>>({
const reducer = combineReducers<Pick<ElasticsearchQuery, 'query' | 'alias' | 'metrics' | 'filters' | 'bucketAggs'>>({
query: queryReducer,
alias: aliasPatternReducer,
metrics: metricsReducer,
filters: filtersReducer,
bucketAggs: createBucketAggsReducer(datasource.timeField),
});

Expand All @@ -78,7 +80,7 @@ export const ElasticsearchProvider = withStore(({
reducer
);

const isUninitialized = !query.metrics || !query.bucketAggs || query.query === undefined;
const isUninitialized = !query.metrics || !query.filters || !query.bucketAggs || query.query === undefined;

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

Expand Down
149 changes: 149 additions & 0 deletions src/components/QueryEditor/FilterEditor/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import React, { useRef } from 'react';

import { useDispatch } from '@/hooks/useStatelessReducer';
import { IconButton } from '../../IconButton';
import { useQuery } from '../ElasticsearchQueryContext';
import { QueryEditorRow } from '../QueryEditorRow';

import { QueryFilter } from '@/types';
import { InlineSegmentGroup, Input, Segment, SegmentAsync, Tooltip } from '@grafana/ui';
import {
addFilter,
removeFilter,
toggleFilterVisibility,
changeFilterField,
changeFilterOperation,
changeFilterValue,
} from '@/components/QueryEditor/FilterEditor/state/actions';
import { segmentStyles } from '@/components/QueryEditor/styles';
import { useFields } from '@/hooks/useFields';
import { newFilterId } from '@/utils/uid';
import { filterOperations } from '@/queryDef';
import { hasWhiteSpace, isSet } from '@/utils';

interface FilterEditorProps {
onSubmit: () => void;
}

function filterErrors(filter: QueryFilter): string[] {
const errors: string[] = [];

if (!isSet(filter.filter.key)) {
errors.push('Field is not set');
}

if (!isSet(filter.filter.operator)) {
errors.push('Operator is not set');
}

if (!['exists', 'not exists'].includes(filter.filter.operator) && !isSet(filter.filter.value)) {
errors.push('Value is not set');
}

if (['term', 'not term'].includes(filter.filter.operator) && filter.filter.value && hasWhiteSpace(filter.filter.value)) {
errors.push('Term cannot have whitespace in value');
}

return errors;
}

export const FilterEditor = ({ onSubmit }: FilterEditorProps) => {
const dispatch = useDispatch();
const { filters } = useQuery();

return (
<>
{filters?.map((filter, index) => {
const errors = filterErrors(filter)
return (
<QueryEditorRow
key={`${filter.id}`}
label={errors.length > 0 ? (
<Tooltip content={errors.join('; ')}>
<span style={{color: "gray"}}>Filter</span>
</Tooltip>
): 'Filter'}
hidden={filter.hide}
onHideClick={() => {
dispatch(toggleFilterVisibility(filter.id));
onSubmit();
}}
onRemoveClick={() => {
dispatch(removeFilter(filter.id));
onSubmit();
}}
>
<FilterEditorRow value={filter} onSubmit={onSubmit} />

{index === 0 && <IconButton
label="add"
iconName="plus"
style={{marginLeft: '4px'}}
onClick={() => dispatch(addFilter(newFilterId()))}
/>}
</QueryEditorRow>
)
})}
</>
);
};

interface FilterEditorRowProps {
value: QueryFilter;
onSubmit: () => void;
}

export const FilterEditorRow = ({ value, onSubmit }: FilterEditorRowProps) => {
const dispatch = useDispatch();
const getFields = useFields('filters', 'startsWith');
const valueInputRef = useRef<HTMLInputElement>(null);

return (
<>
<InlineSegmentGroup>
<SegmentAsync
allowCustomValue={true}
className={segmentStyles}
loadOptions={getFields}
reloadOptionsOnChange={true}
onChange={(e) => {
dispatch(changeFilterField({ id: value.id, field: e.value ?? '' }));
if (['exists', 'not exists'].includes(value.filter.operator) || isSet(value.filter.value)) {
onSubmit();
}
// Auto focus the value input when a field is selected
setTimeout(() => valueInputRef.current?.focus(), 100);
}}
placeholder="Select Field"
value={value.filter.key}
/>
<div style={{ whiteSpace: 'nowrap' }}>
<Segment
value={filterOperations.find((op) => op.value === value.filter.operator)}
options={filterOperations}
onChange={(e) => {
let op = e.value ?? filterOperations[0].value;
dispatch(changeFilterOperation({ id: value.id, op: op }));
if (['exists', 'not exists'].includes(op) || isSet(value.filter.value)) {
onSubmit();
}
}}
/>
</div>
{!['exists', 'not exists'].includes(value.filter.operator) && (
<Input
ref={valueInputRef}
placeholder="Value"
value={value.filter.value}
onChange={(e) => dispatch(changeFilterValue({ id: value.id, value: e.currentTarget.value }))}
onKeyUp={(e) => {
if (e.key === 'Enter') {
onSubmit();
}
}}
/>
)}
</InlineSegmentGroup>
</>
);
};
10 changes: 10 additions & 0 deletions src/components/QueryEditor/FilterEditor/state/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { createAction } from '@reduxjs/toolkit';

import { QueryFilter } from '@/types';

export const addFilter = createAction<QueryFilter['id']>('@filters/add');
export const removeFilter = createAction<QueryFilter['id']>('@filters/remove');
export const toggleFilterVisibility = createAction<QueryFilter['id']>('@filters/toggle_visibility');
export const changeFilterField = createAction<{ id: QueryFilter['id']; field: string }>('@filters/change_field');
export const changeFilterValue = createAction<{ id: QueryFilter['id']; value: string }>('@filters/change_value');
export const changeFilterOperation = createAction<{ id: QueryFilter['id']; op: string }>('@filters/change_operation');
100 changes: 100 additions & 0 deletions src/components/QueryEditor/FilterEditor/state/reducer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { Action } from '@reduxjs/toolkit';
import { defaultFilter } from '@/queryDef';
import { ElasticsearchQuery } from '@/types';
import { initExploreQuery, initQuery } from '../../state';

import {
addFilter,
changeFilterField,
changeFilterOperation,
changeFilterValue,
removeFilter,
toggleFilterVisibility,
} from './actions';

export const reducer = (state: ElasticsearchQuery['filters'], action: Action): ElasticsearchQuery['filters'] => {
// console.log('Running filters reducer with action:', action, state);

if (addFilter.match(action)) {
return [...state!, defaultFilter(action.payload)];
}

if (removeFilter.match(action)) {
const filterToRemove = state!.find((m) => m.id === action.payload)!;
const resultingFilters = state!.filter((filter) => filterToRemove.id !== filter.id);
if (resultingFilters.length === 0) {
return [defaultFilter()];
}
return resultingFilters;
}

if (changeFilterField.match(action)) {
return state!.map((filter) => {
if (filter.id !== action.payload.id) {
return filter;
}

return {
...filter,
filter: {
...filter.filter,
key: action.payload.field,
}
};
});
}

if (changeFilterOperation.match(action)) {
return state!.map((filter) => {
if (filter.id !== action.payload.id) {
return filter;
}

return {
...filter,
filter: {
...filter.filter,
operator: action.payload.op,
}
};
});
}

if (changeFilterValue.match(action)) {
return state!.map((filter) => {
if (filter.id !== action.payload.id) {
return filter;
}

return {
...filter,
filter: {
...filter.filter,
value: action.payload.value,
}
};
});
}

if (toggleFilterVisibility.match(action)) {
return state!.map((filter) => {
if (filter.id !== action.payload) {
return filter;
}

return {
...filter,
hide: !filter.hide,
};
});
}

if (initQuery.match(action) || initExploreQuery.match(action)) {
if (state && state.length > 0) {
return state;
}
return [defaultFilter()];
}

return state;
};
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ describe('Metric Editor', () => {
query: '',
metrics: [avg],
bucketAggs: [defaultBucketAgg('2')],
filters: [],
};

const getFields: ElasticDatasource['getFields'] = jest.fn(() => from([[]]));
Expand Down Expand Up @@ -62,6 +63,7 @@ describe('Metric Editor', () => {
query: '',
metrics: [count],
bucketAggs: [],
filters: [],
};

const wrapper = ({ children }: PropsWithChildren<{}>) => (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ describe('Settings Editor', () => {
},
],
bucketAggs: [],
filters: [],
};

const onChange = jest.fn();
Expand Down Expand Up @@ -102,6 +103,7 @@ describe('Settings Editor', () => {
},
],
bucketAggs: [],
filters: [],
};

const onChange = jest.fn();
Expand Down
2 changes: 2 additions & 0 deletions src/components/QueryEditor/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ describe('QueryEditor', () => {
],
// Even if present, this shouldn't be shown in the UI
bucketAggs: [{ id: '2', type: 'date_histogram' }],
filters: [],
};

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

render(<QueryEditor query={query} datasource={{} as ElasticDatasource} onChange={noop} onRunQuery={noop} />);
Expand Down
2 changes: 2 additions & 0 deletions src/components/QueryEditor/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { QueryTypeSelector } from './QueryTypeSelector';
import { getHook } from '@/utils/context';
import { LuceneQueryEditor } from '@/components/LuceneQueryEditor';
import { useDatasourceFields } from '@/datasource/utils';
import { FilterEditor } from '@/components/QueryEditor/FilterEditor';

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

Expand Down Expand Up @@ -133,6 +134,7 @@ const QueryEditorForm = ({ value, onRunQuery }: Props) => {
value={value?.query}
onSubmit={onSubmitCB}/>
</div>
<FilterEditor onSubmit={onRunQuery} />

<MetricAggregationsEditor nextId={nextId} />
{showBucketAggregationsEditor && <BucketAggregationsEditor nextId={nextId} />}
Expand Down
Loading