mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[Unified Search] Supports complex filters with AND/OR relationships (#143928)
## Describe the feature: Closes https://github.com/elastic/kibana/issues/144775 This PR allows users to create more than one filter at a time. It enhances the query builder by enabling users to create multiple filters simultaneously. It adds the capability to nest queries and use the logical OR operator in filter pills. <img width="981" alt="image" src="https://user-images.githubusercontent.com/4016496/207942022-3256590d-00f6-45c8-b566-c184d5d18953.png"> ## Tasks: - [x] Add the ability to add/edit multiple filters in one form: - [x] Replace the current implementation of adding and editing a filter with a filtersBuilder - `Vis-Editor`; - [x] Add combined filter support to Data plugin (mapAndFlattenFilters) - `App-Services`; - [x] Add the ability to update data in the Data plugin when updating values in the filters builder - `App-Services`; - [x] Add hide `Edit as Query DSL` in popover case the filter inside FiltersBuilder is combinedFilter - `App-Services`; - [x] Update filter badge to display nested filters: - [x] Replace the current badge filter implementation with a new one - `Vis-Editor`; - [x] Clean up `FilterLabel` component after replace `FIlterBadge` component - `Vis-Editor`; - [x] When editing filters, those filters that belong to the same filter group should be edited - `Vis-Editor`; - [x] Update jest and functional tests with new functionality - `Vis-Editor`; - [x] Fix drag and drop behavior - `Vis-Editor`; Co-authored-by: Lukas Olson <lukas@elastic.co> Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Alexey Antonov <alexwizp@gmail.com> Co-authored-by: Stratoula Kalafateli <efstratia.kalafateli@elastic.co> Co-authored-by: Yaroslav Kuznietsov <kuznetsov.yaroslav.yk@gmail.com> Co-authored-by: Andrea Del Rio <andrea.delrio@elastic.co>
This commit is contained in:
parent
136ed8014b
commit
3c4ab973be
147 changed files with 4646 additions and 2866 deletions
|
@ -22,7 +22,6 @@ export type {
|
|||
ExistsFilter,
|
||||
FieldFilter,
|
||||
Filter,
|
||||
FilterItem,
|
||||
FilterCompareOptions,
|
||||
FilterMeta,
|
||||
LatLon,
|
||||
|
@ -38,6 +37,7 @@ export type {
|
|||
ScriptedPhraseFilter,
|
||||
ScriptedRangeFilter,
|
||||
TimeRange,
|
||||
CombinedFilter,
|
||||
} from './src/filters';
|
||||
|
||||
export type {
|
||||
|
@ -54,6 +54,7 @@ export {
|
|||
decorateQuery,
|
||||
luceneStringToDsl,
|
||||
migrateFilter,
|
||||
fromCombinedFilter,
|
||||
isOfQueryType,
|
||||
isOfAggregateQueryType,
|
||||
getAggregateQueryMode,
|
||||
|
@ -105,9 +106,11 @@ export {
|
|||
toggleFilterPinned,
|
||||
uniqFilters,
|
||||
unpinFilter,
|
||||
updateFilter,
|
||||
extractTimeFilter,
|
||||
extractTimeRange,
|
||||
convertRangeFilterToTimeRange,
|
||||
BooleanRelation,
|
||||
} from './src/filters';
|
||||
|
||||
export {
|
||||
|
|
564
packages/kbn-es-query/src/es_query/from_combined_filter.test.ts
Normal file
564
packages/kbn-es-query/src/es_query/from_combined_filter.test.ts
Normal file
|
@ -0,0 +1,564 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { fields } from '../filters/stubs';
|
||||
import { DataViewBase } from './types';
|
||||
import { fromCombinedFilter } from './from_combined_filter';
|
||||
import {
|
||||
BooleanRelation,
|
||||
buildCombinedFilter,
|
||||
buildExistsFilter,
|
||||
buildPhraseFilter,
|
||||
buildPhrasesFilter,
|
||||
buildRangeFilter,
|
||||
} from '../filters';
|
||||
|
||||
describe('#fromCombinedFilter', function () {
|
||||
const indexPattern: DataViewBase = {
|
||||
id: 'logstash-*',
|
||||
fields,
|
||||
title: 'dataView',
|
||||
};
|
||||
|
||||
const getField = (fieldName: string) => {
|
||||
const field = fields.find(({ name }) => fieldName === name);
|
||||
if (!field) throw new Error(`field ${name} does not exist`);
|
||||
return field;
|
||||
};
|
||||
|
||||
describe('AND relation', () => {
|
||||
it('Generates an empty bool should clause with no filters', () => {
|
||||
const filter = buildCombinedFilter(BooleanRelation.AND, [], indexPattern);
|
||||
const result = fromCombinedFilter(filter);
|
||||
expect(result.query).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"bool": Object {
|
||||
"filter": Array [],
|
||||
"must": Array [],
|
||||
"must_not": Array [],
|
||||
"should": Array [],
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('Generates a bool should clause with its sub-filters', () => {
|
||||
const filters = [
|
||||
buildPhraseFilter(getField('extension'), 'value', indexPattern),
|
||||
buildRangeFilter(getField('bytes'), { gte: 10 }, indexPattern),
|
||||
buildExistsFilter(getField('machine.os'), indexPattern),
|
||||
];
|
||||
const filter = buildCombinedFilter(BooleanRelation.AND, filters, indexPattern);
|
||||
const result = fromCombinedFilter(filter);
|
||||
expect(result.query).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"bool": Object {
|
||||
"filter": Array [
|
||||
Object {
|
||||
"match_phrase": Object {
|
||||
"extension": "value",
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"range": Object {
|
||||
"bytes": Object {
|
||||
"gte": 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"exists": Object {
|
||||
"field": "machine.os",
|
||||
},
|
||||
},
|
||||
],
|
||||
"must": Array [],
|
||||
"must_not": Array [],
|
||||
"should": Array [],
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('Handles negated sub-filters', () => {
|
||||
const negatedFilter = buildPhrasesFilter(getField('extension'), ['tar', 'gz'], indexPattern);
|
||||
negatedFilter.meta.negate = true;
|
||||
const filters = [
|
||||
negatedFilter,
|
||||
buildRangeFilter(getField('bytes'), { gte: 10 }, indexPattern),
|
||||
buildExistsFilter(getField('machine.os'), indexPattern),
|
||||
];
|
||||
const filter = buildCombinedFilter(BooleanRelation.AND, filters, indexPattern);
|
||||
const result = fromCombinedFilter(filter);
|
||||
expect(result.query).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"bool": Object {
|
||||
"filter": Array [
|
||||
Object {
|
||||
"range": Object {
|
||||
"bytes": Object {
|
||||
"gte": 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"exists": Object {
|
||||
"field": "machine.os",
|
||||
},
|
||||
},
|
||||
],
|
||||
"must": Array [],
|
||||
"must_not": Array [
|
||||
Object {
|
||||
"bool": Object {
|
||||
"minimum_should_match": 1,
|
||||
"should": Array [
|
||||
Object {
|
||||
"match_phrase": Object {
|
||||
"extension": "tar",
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"match_phrase": Object {
|
||||
"extension": "gz",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
"should": Array [],
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('Preserves filter properties', () => {
|
||||
const filters = [
|
||||
buildPhraseFilter(getField('extension'), 'value', indexPattern),
|
||||
buildRangeFilter(getField('bytes'), { gte: 10 }, indexPattern),
|
||||
buildExistsFilter(getField('machine.os'), indexPattern),
|
||||
];
|
||||
const filter = buildCombinedFilter(BooleanRelation.AND, filters, indexPattern);
|
||||
const { query, ...rest } = fromCombinedFilter(filter);
|
||||
expect(rest).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"$state": Object {
|
||||
"store": "appState",
|
||||
},
|
||||
"meta": Object {
|
||||
"alias": undefined,
|
||||
"disabled": false,
|
||||
"index": "logstash-*",
|
||||
"negate": false,
|
||||
"params": Array [
|
||||
Object {
|
||||
"meta": Object {
|
||||
"index": "logstash-*",
|
||||
},
|
||||
"query": Object {
|
||||
"match_phrase": Object {
|
||||
"extension": "value",
|
||||
},
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"meta": Object {
|
||||
"field": "bytes",
|
||||
"index": "logstash-*",
|
||||
"params": Object {},
|
||||
},
|
||||
"query": Object {
|
||||
"range": Object {
|
||||
"bytes": Object {
|
||||
"gte": 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"meta": Object {
|
||||
"index": "logstash-*",
|
||||
},
|
||||
"query": Object {
|
||||
"exists": Object {
|
||||
"field": "machine.os",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
"relation": "AND",
|
||||
"type": "combined",
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OR relation', () => {
|
||||
it('Generates an empty bool should clause with no filters', () => {
|
||||
const filter = buildCombinedFilter(BooleanRelation.OR, [], indexPattern);
|
||||
const result = fromCombinedFilter(filter);
|
||||
expect(result.query).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"bool": Object {
|
||||
"minimum_should_match": 1,
|
||||
"should": Array [],
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('Generates a bool should clause with its sub-filters', () => {
|
||||
const filters = [
|
||||
buildPhraseFilter(getField('extension'), 'value', indexPattern),
|
||||
buildRangeFilter(getField('bytes'), { gte: 10 }, indexPattern),
|
||||
buildExistsFilter(getField('machine.os'), indexPattern),
|
||||
];
|
||||
const filter = buildCombinedFilter(BooleanRelation.OR, filters, indexPattern);
|
||||
const result = fromCombinedFilter(filter);
|
||||
expect(result.query).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"bool": Object {
|
||||
"minimum_should_match": 1,
|
||||
"should": Array [
|
||||
Object {
|
||||
"bool": Object {
|
||||
"filter": Array [
|
||||
Object {
|
||||
"match_phrase": Object {
|
||||
"extension": "value",
|
||||
},
|
||||
},
|
||||
],
|
||||
"must": Array [],
|
||||
"must_not": Array [],
|
||||
"should": Array [],
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"bool": Object {
|
||||
"filter": Array [
|
||||
Object {
|
||||
"range": Object {
|
||||
"bytes": Object {
|
||||
"gte": 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
"must": Array [],
|
||||
"must_not": Array [],
|
||||
"should": Array [],
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"bool": Object {
|
||||
"filter": Array [
|
||||
Object {
|
||||
"exists": Object {
|
||||
"field": "machine.os",
|
||||
},
|
||||
},
|
||||
],
|
||||
"must": Array [],
|
||||
"must_not": Array [],
|
||||
"should": Array [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('Handles negated sub-filters', () => {
|
||||
const negatedFilter = buildPhrasesFilter(getField('extension'), ['tar', 'gz'], indexPattern);
|
||||
negatedFilter.meta.negate = true;
|
||||
const filters = [
|
||||
negatedFilter,
|
||||
buildRangeFilter(getField('bytes'), { gte: 10 }, indexPattern),
|
||||
buildExistsFilter(getField('machine.os'), indexPattern),
|
||||
];
|
||||
const filter = buildCombinedFilter(BooleanRelation.OR, filters, indexPattern);
|
||||
const result = fromCombinedFilter(filter);
|
||||
expect(result.query).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"bool": Object {
|
||||
"minimum_should_match": 1,
|
||||
"should": Array [
|
||||
Object {
|
||||
"bool": Object {
|
||||
"filter": Array [],
|
||||
"must": Array [],
|
||||
"must_not": Array [
|
||||
Object {
|
||||
"bool": Object {
|
||||
"minimum_should_match": 1,
|
||||
"should": Array [
|
||||
Object {
|
||||
"match_phrase": Object {
|
||||
"extension": "tar",
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"match_phrase": Object {
|
||||
"extension": "gz",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
"should": Array [],
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"bool": Object {
|
||||
"filter": Array [
|
||||
Object {
|
||||
"range": Object {
|
||||
"bytes": Object {
|
||||
"gte": 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
"must": Array [],
|
||||
"must_not": Array [],
|
||||
"should": Array [],
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"bool": Object {
|
||||
"filter": Array [
|
||||
Object {
|
||||
"exists": Object {
|
||||
"field": "machine.os",
|
||||
},
|
||||
},
|
||||
],
|
||||
"must": Array [],
|
||||
"must_not": Array [],
|
||||
"should": Array [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('Preserves filter properties', () => {
|
||||
const filters = [
|
||||
buildPhraseFilter(getField('extension'), 'value', indexPattern),
|
||||
buildRangeFilter(getField('bytes'), { gte: 10 }, indexPattern),
|
||||
buildExistsFilter(getField('machine.os'), indexPattern),
|
||||
];
|
||||
const filter = buildCombinedFilter(BooleanRelation.OR, filters, indexPattern);
|
||||
const { query, ...rest } = fromCombinedFilter(filter);
|
||||
expect(rest).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"$state": Object {
|
||||
"store": "appState",
|
||||
},
|
||||
"meta": Object {
|
||||
"alias": undefined,
|
||||
"disabled": false,
|
||||
"index": "logstash-*",
|
||||
"negate": false,
|
||||
"params": Array [
|
||||
Object {
|
||||
"meta": Object {
|
||||
"index": "logstash-*",
|
||||
},
|
||||
"query": Object {
|
||||
"match_phrase": Object {
|
||||
"extension": "value",
|
||||
},
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"meta": Object {
|
||||
"field": "bytes",
|
||||
"index": "logstash-*",
|
||||
"params": Object {},
|
||||
},
|
||||
"query": Object {
|
||||
"range": Object {
|
||||
"bytes": Object {
|
||||
"gte": 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"meta": Object {
|
||||
"index": "logstash-*",
|
||||
},
|
||||
"query": Object {
|
||||
"exists": Object {
|
||||
"field": "machine.os",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
"relation": "OR",
|
||||
"type": "combined",
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Nested relations', () => {
|
||||
it('Handles complex-nested filters with ANDs and ORs', () => {
|
||||
const filters = [
|
||||
buildCombinedFilter(
|
||||
BooleanRelation.OR,
|
||||
[
|
||||
buildPhrasesFilter(getField('extension'), ['tar', 'gz'], indexPattern),
|
||||
buildPhraseFilter(getField('ssl'), false, indexPattern),
|
||||
buildCombinedFilter(
|
||||
BooleanRelation.AND,
|
||||
[
|
||||
buildPhraseFilter(getField('extension'), 'value', indexPattern),
|
||||
buildRangeFilter(getField('bytes'), { gte: 10 }, indexPattern),
|
||||
],
|
||||
indexPattern
|
||||
),
|
||||
buildExistsFilter(getField('machine.os'), indexPattern),
|
||||
],
|
||||
indexPattern
|
||||
),
|
||||
buildPhrasesFilter(getField('machine.os.keyword'), ['foo', 'bar'], indexPattern),
|
||||
];
|
||||
const filter = buildCombinedFilter(BooleanRelation.AND, filters, indexPattern);
|
||||
const result = fromCombinedFilter(filter);
|
||||
expect(result.query).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"bool": Object {
|
||||
"filter": Array [
|
||||
Object {
|
||||
"bool": Object {
|
||||
"minimum_should_match": 1,
|
||||
"should": Array [
|
||||
Object {
|
||||
"bool": Object {
|
||||
"filter": Array [
|
||||
Object {
|
||||
"bool": Object {
|
||||
"minimum_should_match": 1,
|
||||
"should": Array [
|
||||
Object {
|
||||
"match_phrase": Object {
|
||||
"extension": "tar",
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"match_phrase": Object {
|
||||
"extension": "gz",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
"must": Array [],
|
||||
"must_not": Array [],
|
||||
"should": Array [],
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"bool": Object {
|
||||
"filter": Array [
|
||||
Object {
|
||||
"match_phrase": Object {
|
||||
"ssl": false,
|
||||
},
|
||||
},
|
||||
],
|
||||
"must": Array [],
|
||||
"must_not": Array [],
|
||||
"should": Array [],
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"bool": Object {
|
||||
"filter": Array [
|
||||
Object {
|
||||
"bool": Object {
|
||||
"filter": Array [
|
||||
Object {
|
||||
"match_phrase": Object {
|
||||
"extension": "value",
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"range": Object {
|
||||
"bytes": Object {
|
||||
"gte": 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
"must": Array [],
|
||||
"must_not": Array [],
|
||||
"should": Array [],
|
||||
},
|
||||
},
|
||||
],
|
||||
"must": Array [],
|
||||
"must_not": Array [],
|
||||
"should": Array [],
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"bool": Object {
|
||||
"filter": Array [
|
||||
Object {
|
||||
"exists": Object {
|
||||
"field": "machine.os",
|
||||
},
|
||||
},
|
||||
],
|
||||
"must": Array [],
|
||||
"must_not": Array [],
|
||||
"should": Array [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"bool": Object {
|
||||
"minimum_should_match": 1,
|
||||
"should": Array [
|
||||
Object {
|
||||
"match_phrase": Object {
|
||||
"machine.os.keyword": "foo",
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"match_phrase": Object {
|
||||
"machine.os.keyword": "bar",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
"must": Array [],
|
||||
"must_not": Array [],
|
||||
"should": Array [],
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
49
packages/kbn-es-query/src/es_query/from_combined_filter.ts
Normal file
49
packages/kbn-es-query/src/es_query/from_combined_filter.ts
Normal file
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { Filter, isCombinedFilter } from '../filters';
|
||||
import { DataViewBase } from './types';
|
||||
import { buildQueryFromFilters, EsQueryFiltersConfig } from './from_filters';
|
||||
import { BooleanRelation, CombinedFilter } from '../filters/build_filters';
|
||||
|
||||
const fromAndFilter = (
|
||||
filter: CombinedFilter,
|
||||
dataViews?: DataViewBase | DataViewBase[],
|
||||
options: EsQueryFiltersConfig = {}
|
||||
) => {
|
||||
const bool = buildQueryFromFilters(filter.meta.params, dataViews, options);
|
||||
return { ...filter, query: { bool } };
|
||||
};
|
||||
|
||||
const fromOrFilter = (
|
||||
filter: CombinedFilter,
|
||||
dataViews?: DataViewBase | DataViewBase[],
|
||||
options: EsQueryFiltersConfig = {}
|
||||
) => {
|
||||
const should = filter.meta.params.map((subFilter) => ({
|
||||
bool: buildQueryFromFilters([subFilter], dataViews, options),
|
||||
}));
|
||||
const bool = { should, minimum_should_match: 1 };
|
||||
return { ...filter, query: { bool } };
|
||||
};
|
||||
|
||||
export const fromCombinedFilter = (
|
||||
filter: Filter,
|
||||
dataViews?: DataViewBase | DataViewBase[],
|
||||
options: EsQueryFiltersConfig = {}
|
||||
): Filter => {
|
||||
if (!isCombinedFilter(filter)) {
|
||||
return filter;
|
||||
}
|
||||
|
||||
if (filter.meta.relation === BooleanRelation.AND) {
|
||||
return fromAndFilter(filter, dataViews, options);
|
||||
}
|
||||
|
||||
return fromOrFilter(filter, dataViews, options);
|
||||
};
|
|
@ -12,8 +12,8 @@ import { migrateFilter } from './migrate_filter';
|
|||
import { filterMatchesIndex } from './filter_matches_index';
|
||||
import { Filter, cleanFilter, isFilterDisabled } from '../filters';
|
||||
import { BoolQuery, DataViewBase } from './types';
|
||||
import { handleNestedFilter } from './handle_nested_filter';
|
||||
import { handleCombinedFilter } from './handle_combined_filter';
|
||||
import { fromNestedFilter } from './from_nested_filter';
|
||||
import { fromCombinedFilter } from './from_combined_filter';
|
||||
|
||||
/**
|
||||
* Create a filter that can be reversed for filters with negate set
|
||||
|
@ -90,11 +90,11 @@ export const buildQueryFromFilters = (
|
|||
.map((filter) => {
|
||||
const indexPattern = findIndexPattern(filter.meta?.index);
|
||||
const migratedFilter = migrateFilter(filter, indexPattern);
|
||||
return handleNestedFilter(migratedFilter, indexPattern, {
|
||||
return fromNestedFilter(migratedFilter, indexPattern, {
|
||||
ignoreUnmapped: nestedIgnoreUnmapped,
|
||||
});
|
||||
})
|
||||
.map((filter) => handleCombinedFilter(filter, inputDataViews, options))
|
||||
.map((filter) => fromCombinedFilter(filter, inputDataViews, options))
|
||||
.map(cleanFilter)
|
||||
.map(translateToQuery);
|
||||
};
|
||||
|
|
|
@ -6,12 +6,12 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { handleNestedFilter } from './handle_nested_filter';
|
||||
import { fromNestedFilter } from './from_nested_filter';
|
||||
import { fields } from '../filters/stubs';
|
||||
import { buildPhraseFilter, buildQueryFilter } from '../filters';
|
||||
import { DataViewBase } from './types';
|
||||
|
||||
describe('handleNestedFilter', function () {
|
||||
describe('fromNestedFilter', function () {
|
||||
const indexPattern: DataViewBase = {
|
||||
id: 'logstash-*',
|
||||
fields,
|
||||
|
@ -21,7 +21,7 @@ describe('handleNestedFilter', function () {
|
|||
it("should return the filter's query wrapped in nested query if the target field is nested", () => {
|
||||
const field = getField('nestedField.child');
|
||||
const filter = buildPhraseFilter(field!, 'foo', indexPattern);
|
||||
const result = handleNestedFilter(filter, indexPattern);
|
||||
const result = fromNestedFilter(filter, indexPattern);
|
||||
expect(result).toEqual({
|
||||
meta: {
|
||||
index: 'logstash-*',
|
||||
|
@ -42,7 +42,7 @@ describe('handleNestedFilter', function () {
|
|||
it('should allow to configure ignore_unmapped', () => {
|
||||
const field = getField('nestedField.child');
|
||||
const filter = buildPhraseFilter(field!, 'foo', indexPattern);
|
||||
const result = handleNestedFilter(filter, indexPattern, { ignoreUnmapped: true });
|
||||
const result = fromNestedFilter(filter, indexPattern, { ignoreUnmapped: true });
|
||||
expect(result).toEqual({
|
||||
meta: {
|
||||
index: 'logstash-*',
|
||||
|
@ -64,7 +64,7 @@ describe('handleNestedFilter', function () {
|
|||
it('should return filter untouched if it does not target a nested field', () => {
|
||||
const field = getField('extension');
|
||||
const filter = buildPhraseFilter(field!, 'jpg', indexPattern);
|
||||
const result = handleNestedFilter(filter, indexPattern);
|
||||
const result = fromNestedFilter(filter, indexPattern);
|
||||
expect(result).toBe(filter);
|
||||
});
|
||||
|
||||
|
@ -75,14 +75,14 @@ describe('handleNestedFilter', function () {
|
|||
name: 'notarealfield',
|
||||
};
|
||||
const filter = buildPhraseFilter(unrealField, 'jpg', indexPattern);
|
||||
const result = handleNestedFilter(filter, indexPattern);
|
||||
const result = fromNestedFilter(filter, indexPattern);
|
||||
expect(result).toBe(filter);
|
||||
});
|
||||
|
||||
it('should return filter untouched if no index pattern is provided', () => {
|
||||
const field = getField('extension');
|
||||
const filter = buildPhraseFilter(field!, 'jpg', indexPattern);
|
||||
const result = handleNestedFilter(filter);
|
||||
const result = fromNestedFilter(filter);
|
||||
expect(result).toBe(filter);
|
||||
});
|
||||
|
||||
|
@ -97,7 +97,7 @@ describe('handleNestedFilter', function () {
|
|||
'logstash-*',
|
||||
'foo'
|
||||
);
|
||||
const result = handleNestedFilter(filter);
|
||||
const result = fromNestedFilter(filter);
|
||||
expect(result).toBe(filter);
|
||||
});
|
||||
|
|
@ -11,7 +11,7 @@ import { DataViewBase } from './types';
|
|||
import { getDataViewFieldSubtypeNested } from '../utils';
|
||||
|
||||
/** @internal */
|
||||
export const handleNestedFilter = (
|
||||
export const fromNestedFilter = (
|
||||
filter: Filter,
|
||||
indexPattern?: DataViewBase,
|
||||
config: { ignoreUnmapped?: boolean } = {}
|
|
@ -1,610 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { fields } from '../filters/stubs';
|
||||
import { DataViewBase } from './types';
|
||||
import { handleCombinedFilter } from './handle_combined_filter';
|
||||
import {
|
||||
buildExistsFilter,
|
||||
buildCombinedFilter,
|
||||
buildPhraseFilter,
|
||||
buildPhrasesFilter,
|
||||
buildRangeFilter,
|
||||
} from '../filters';
|
||||
|
||||
describe('#handleCombinedFilter', function () {
|
||||
const indexPattern: DataViewBase = {
|
||||
id: 'logstash-*',
|
||||
fields,
|
||||
title: 'dataView',
|
||||
};
|
||||
|
||||
const getField = (fieldName: string) => {
|
||||
const field = fields.find(({ name }) => fieldName === name);
|
||||
if (!field) throw new Error(`field ${name} does not exist`);
|
||||
return field;
|
||||
};
|
||||
|
||||
it('Handles an empty list of filters', () => {
|
||||
const filter = buildCombinedFilter([]);
|
||||
const result = handleCombinedFilter(filter);
|
||||
expect(result.query).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"bool": Object {
|
||||
"minimum_should_match": 1,
|
||||
"should": Array [],
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('Handles a simple list of filters', () => {
|
||||
const filters = [
|
||||
buildPhraseFilter(getField('extension'), 'value', indexPattern),
|
||||
buildRangeFilter(getField('bytes'), { gte: 10 }, indexPattern),
|
||||
buildExistsFilter(getField('machine.os'), indexPattern),
|
||||
];
|
||||
const filter = buildCombinedFilter(filters);
|
||||
const result = handleCombinedFilter(filter);
|
||||
expect(result.query).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"bool": Object {
|
||||
"minimum_should_match": 1,
|
||||
"should": Array [
|
||||
Object {
|
||||
"bool": Object {
|
||||
"filter": Array [
|
||||
Object {
|
||||
"match_phrase": Object {
|
||||
"extension": "value",
|
||||
},
|
||||
},
|
||||
],
|
||||
"must": Array [],
|
||||
"must_not": Array [],
|
||||
"should": Array [],
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"bool": Object {
|
||||
"filter": Array [
|
||||
Object {
|
||||
"range": Object {
|
||||
"bytes": Object {
|
||||
"gte": 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
"must": Array [],
|
||||
"must_not": Array [],
|
||||
"should": Array [],
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"bool": Object {
|
||||
"filter": Array [
|
||||
Object {
|
||||
"exists": Object {
|
||||
"field": "machine.os",
|
||||
},
|
||||
},
|
||||
],
|
||||
"must": Array [],
|
||||
"must_not": Array [],
|
||||
"should": Array [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('Handles a combination of filters and filter arrays', () => {
|
||||
const filters = [
|
||||
buildPhraseFilter(getField('extension'), 'value', indexPattern),
|
||||
[
|
||||
buildRangeFilter(getField('bytes'), { gte: 10 }, indexPattern),
|
||||
buildExistsFilter(getField('machine.os'), indexPattern),
|
||||
],
|
||||
];
|
||||
const filter = buildCombinedFilter(filters);
|
||||
const result = handleCombinedFilter(filter);
|
||||
expect(result.query).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"bool": Object {
|
||||
"minimum_should_match": 1,
|
||||
"should": Array [
|
||||
Object {
|
||||
"bool": Object {
|
||||
"filter": Array [
|
||||
Object {
|
||||
"match_phrase": Object {
|
||||
"extension": "value",
|
||||
},
|
||||
},
|
||||
],
|
||||
"must": Array [],
|
||||
"must_not": Array [],
|
||||
"should": Array [],
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"bool": Object {
|
||||
"filter": Array [
|
||||
Object {
|
||||
"range": Object {
|
||||
"bytes": Object {
|
||||
"gte": 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"exists": Object {
|
||||
"field": "machine.os",
|
||||
},
|
||||
},
|
||||
],
|
||||
"must": Array [],
|
||||
"must_not": Array [],
|
||||
"should": Array [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('Handles nested COMBINED filters', () => {
|
||||
const nestedCombinedFilter = buildCombinedFilter([
|
||||
buildPhraseFilter(getField('machine.os'), 'value', indexPattern),
|
||||
buildPhraseFilter(getField('extension'), 'value', indexPattern),
|
||||
]);
|
||||
const filters = [
|
||||
buildPhraseFilter(getField('extension'), 'value2', indexPattern),
|
||||
nestedCombinedFilter,
|
||||
buildRangeFilter(getField('bytes'), { gte: 10 }, indexPattern),
|
||||
buildExistsFilter(getField('machine.os.raw'), indexPattern),
|
||||
];
|
||||
const filter = buildCombinedFilter(filters);
|
||||
const result = handleCombinedFilter(filter);
|
||||
expect(result.query).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"bool": Object {
|
||||
"minimum_should_match": 1,
|
||||
"should": Array [
|
||||
Object {
|
||||
"bool": Object {
|
||||
"filter": Array [
|
||||
Object {
|
||||
"match_phrase": Object {
|
||||
"extension": "value2",
|
||||
},
|
||||
},
|
||||
],
|
||||
"must": Array [],
|
||||
"must_not": Array [],
|
||||
"should": Array [],
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"bool": Object {
|
||||
"filter": Array [
|
||||
Object {
|
||||
"bool": Object {
|
||||
"minimum_should_match": 1,
|
||||
"should": Array [
|
||||
Object {
|
||||
"bool": Object {
|
||||
"filter": Array [
|
||||
Object {
|
||||
"match_phrase": Object {
|
||||
"machine.os": "value",
|
||||
},
|
||||
},
|
||||
],
|
||||
"must": Array [],
|
||||
"must_not": Array [],
|
||||
"should": Array [],
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"bool": Object {
|
||||
"filter": Array [
|
||||
Object {
|
||||
"match_phrase": Object {
|
||||
"extension": "value",
|
||||
},
|
||||
},
|
||||
],
|
||||
"must": Array [],
|
||||
"must_not": Array [],
|
||||
"should": Array [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
"must": Array [],
|
||||
"must_not": Array [],
|
||||
"should": Array [],
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"bool": Object {
|
||||
"filter": Array [
|
||||
Object {
|
||||
"range": Object {
|
||||
"bytes": Object {
|
||||
"gte": 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
"must": Array [],
|
||||
"must_not": Array [],
|
||||
"should": Array [],
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"bool": Object {
|
||||
"filter": Array [
|
||||
Object {
|
||||
"exists": Object {
|
||||
"field": "machine.os.raw",
|
||||
},
|
||||
},
|
||||
],
|
||||
"must": Array [],
|
||||
"must_not": Array [],
|
||||
"should": Array [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('Handles negated sub-filters', () => {
|
||||
const negatedFilter = buildPhrasesFilter(getField('extension'), ['tar', 'gz'], indexPattern);
|
||||
negatedFilter.meta.negate = true;
|
||||
|
||||
const filters = [
|
||||
[negatedFilter, buildPhraseFilter(getField('extension'), 'value', indexPattern)],
|
||||
buildRangeFilter(getField('bytes'), { gte: 10 }, indexPattern),
|
||||
buildExistsFilter(getField('machine.os'), indexPattern),
|
||||
];
|
||||
const filter = buildCombinedFilter(filters);
|
||||
const result = handleCombinedFilter(filter);
|
||||
expect(result.query).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"bool": Object {
|
||||
"minimum_should_match": 1,
|
||||
"should": Array [
|
||||
Object {
|
||||
"bool": Object {
|
||||
"filter": Array [
|
||||
Object {
|
||||
"match_phrase": Object {
|
||||
"extension": "value",
|
||||
},
|
||||
},
|
||||
],
|
||||
"must": Array [],
|
||||
"must_not": Array [
|
||||
Object {
|
||||
"bool": Object {
|
||||
"minimum_should_match": 1,
|
||||
"should": Array [
|
||||
Object {
|
||||
"match_phrase": Object {
|
||||
"extension": "tar",
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"match_phrase": Object {
|
||||
"extension": "gz",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
"should": Array [],
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"bool": Object {
|
||||
"filter": Array [
|
||||
Object {
|
||||
"range": Object {
|
||||
"bytes": Object {
|
||||
"gte": 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
"must": Array [],
|
||||
"must_not": Array [],
|
||||
"should": Array [],
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"bool": Object {
|
||||
"filter": Array [
|
||||
Object {
|
||||
"exists": Object {
|
||||
"field": "machine.os",
|
||||
},
|
||||
},
|
||||
],
|
||||
"must": Array [],
|
||||
"must_not": Array [],
|
||||
"should": Array [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('Handles disabled filters within a filter array', () => {
|
||||
const disabledFilter = buildPhraseFilter(getField('ssl'), false, indexPattern);
|
||||
disabledFilter.meta.disabled = true;
|
||||
const filters = [
|
||||
buildPhraseFilter(getField('extension'), 'value', indexPattern),
|
||||
[disabledFilter, buildRangeFilter(getField('bytes'), { gte: 10 }, indexPattern)],
|
||||
buildExistsFilter(getField('machine.os'), indexPattern),
|
||||
];
|
||||
const filter = buildCombinedFilter(filters);
|
||||
const result = handleCombinedFilter(filter);
|
||||
expect(result.query).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"bool": Object {
|
||||
"minimum_should_match": 1,
|
||||
"should": Array [
|
||||
Object {
|
||||
"bool": Object {
|
||||
"filter": Array [
|
||||
Object {
|
||||
"match_phrase": Object {
|
||||
"extension": "value",
|
||||
},
|
||||
},
|
||||
],
|
||||
"must": Array [],
|
||||
"must_not": Array [],
|
||||
"should": Array [],
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"bool": Object {
|
||||
"filter": Array [
|
||||
Object {
|
||||
"range": Object {
|
||||
"bytes": Object {
|
||||
"gte": 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
"must": Array [],
|
||||
"must_not": Array [],
|
||||
"should": Array [],
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"bool": Object {
|
||||
"filter": Array [
|
||||
Object {
|
||||
"exists": Object {
|
||||
"field": "machine.os",
|
||||
},
|
||||
},
|
||||
],
|
||||
"must": Array [],
|
||||
"must_not": Array [],
|
||||
"should": Array [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('Handles complex-nested filters with ANDs and ORs', () => {
|
||||
const filters = [
|
||||
[
|
||||
buildPhrasesFilter(getField('extension'), ['tar', 'gz'], indexPattern),
|
||||
buildPhraseFilter(getField('ssl'), false, indexPattern),
|
||||
buildCombinedFilter([
|
||||
buildPhraseFilter(getField('extension'), 'value', indexPattern),
|
||||
buildRangeFilter(getField('bytes'), { gte: 10 }, indexPattern),
|
||||
]),
|
||||
buildExistsFilter(getField('machine.os'), indexPattern),
|
||||
],
|
||||
buildPhrasesFilter(getField('machine.os.keyword'), ['foo', 'bar'], indexPattern),
|
||||
];
|
||||
const filter = buildCombinedFilter(filters);
|
||||
const result = handleCombinedFilter(filter);
|
||||
expect(result.query).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"bool": Object {
|
||||
"minimum_should_match": 1,
|
||||
"should": Array [
|
||||
Object {
|
||||
"bool": Object {
|
||||
"filter": Array [
|
||||
Object {
|
||||
"bool": Object {
|
||||
"minimum_should_match": 1,
|
||||
"should": Array [
|
||||
Object {
|
||||
"match_phrase": Object {
|
||||
"extension": "tar",
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"match_phrase": Object {
|
||||
"extension": "gz",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"match_phrase": Object {
|
||||
"ssl": false,
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"bool": Object {
|
||||
"minimum_should_match": 1,
|
||||
"should": Array [
|
||||
Object {
|
||||
"bool": Object {
|
||||
"filter": Array [
|
||||
Object {
|
||||
"match_phrase": Object {
|
||||
"extension": "value",
|
||||
},
|
||||
},
|
||||
],
|
||||
"must": Array [],
|
||||
"must_not": Array [],
|
||||
"should": Array [],
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"bool": Object {
|
||||
"filter": Array [
|
||||
Object {
|
||||
"range": Object {
|
||||
"bytes": Object {
|
||||
"gte": 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
"must": Array [],
|
||||
"must_not": Array [],
|
||||
"should": Array [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"exists": Object {
|
||||
"field": "machine.os",
|
||||
},
|
||||
},
|
||||
],
|
||||
"must": Array [],
|
||||
"must_not": Array [],
|
||||
"should": Array [],
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"bool": Object {
|
||||
"filter": Array [
|
||||
Object {
|
||||
"bool": Object {
|
||||
"minimum_should_match": 1,
|
||||
"should": Array [
|
||||
Object {
|
||||
"match_phrase": Object {
|
||||
"machine.os.keyword": "foo",
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"match_phrase": Object {
|
||||
"machine.os.keyword": "bar",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
"must": Array [],
|
||||
"must_not": Array [],
|
||||
"should": Array [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('Preserves filter properties', () => {
|
||||
const filters = [
|
||||
buildPhraseFilter(getField('extension'), 'value', indexPattern),
|
||||
buildRangeFilter(getField('bytes'), { gte: 10 }, indexPattern),
|
||||
buildExistsFilter(getField('machine.os'), indexPattern),
|
||||
];
|
||||
const filter = buildCombinedFilter(filters);
|
||||
const { query, ...rest } = handleCombinedFilter(filter);
|
||||
expect(rest).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"$state": Object {
|
||||
"store": "appState",
|
||||
},
|
||||
"meta": Object {
|
||||
"alias": null,
|
||||
"disabled": false,
|
||||
"index": undefined,
|
||||
"negate": false,
|
||||
"params": Array [
|
||||
Object {
|
||||
"meta": Object {
|
||||
"index": "logstash-*",
|
||||
},
|
||||
"query": Object {
|
||||
"match_phrase": Object {
|
||||
"extension": "value",
|
||||
},
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"meta": Object {
|
||||
"field": "bytes",
|
||||
"index": "logstash-*",
|
||||
"params": Object {},
|
||||
},
|
||||
"query": Object {
|
||||
"range": Object {
|
||||
"bytes": Object {
|
||||
"gte": 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"meta": Object {
|
||||
"index": "logstash-*",
|
||||
},
|
||||
"query": Object {
|
||||
"exists": Object {
|
||||
"field": "machine.os",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
"type": "combined",
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
|
@ -1,41 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { Filter, FilterItem, isCombinedFilter } from '../filters';
|
||||
import { DataViewBase } from './types';
|
||||
import { buildQueryFromFilters, EsQueryFiltersConfig } from './from_filters';
|
||||
|
||||
/** @internal */
|
||||
export const handleCombinedFilter = (
|
||||
filter: Filter,
|
||||
inputDataViews?: DataViewBase | DataViewBase[],
|
||||
options: EsQueryFiltersConfig = {}
|
||||
): Filter => {
|
||||
if (!isCombinedFilter(filter)) return filter;
|
||||
const { params } = filter.meta;
|
||||
const should = params.map((subFilter) => {
|
||||
const subFilters = Array.isArray(subFilter) ? subFilter : [subFilter];
|
||||
return { bool: buildQueryFromFilters(flattenFilters(subFilters), inputDataViews, options) };
|
||||
});
|
||||
return {
|
||||
...filter,
|
||||
query: {
|
||||
bool: {
|
||||
should,
|
||||
minimum_should_match: 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
function flattenFilters(filters: FilterItem[]): Filter[] {
|
||||
return filters.reduce<Filter[]>((result, filter) => {
|
||||
if (Array.isArray(filter)) return [...result, ...flattenFilters(filter)];
|
||||
return [...result, filter];
|
||||
}, []);
|
||||
}
|
|
@ -19,6 +19,7 @@ export {
|
|||
getAggregateQueryMode,
|
||||
getIndexPatternFromSQLQuery,
|
||||
} from './es_query_sql';
|
||||
export { fromCombinedFilter } from './from_combined_filter';
|
||||
export type {
|
||||
IFieldSubType,
|
||||
BoolQuery,
|
||||
|
|
|
@ -64,7 +64,7 @@ export function migrateFilter(filter: Filter, indexPattern?: DataViewBase) {
|
|||
}
|
||||
|
||||
if (!filter.query) {
|
||||
filter.query = {};
|
||||
filter = { ...filter, query: {} };
|
||||
} else {
|
||||
// handle the case where .query already exists and filter has other top level keys on there
|
||||
filter = pick(filter, ['meta', 'query', '$state']);
|
||||
|
|
|
@ -6,22 +6,24 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { Filter, FilterMeta, FILTERS } from './types';
|
||||
import { buildEmptyFilter } from './build_empty_filter';
|
||||
import { Filter, FilterMeta, FILTERS, FilterStateStore } from './types';
|
||||
import { DataViewBase } from '../../es_query';
|
||||
|
||||
/**
|
||||
* Each item in an COMBINED filter may represent either one filter (to be ORed) or an array of filters (ANDed together before
|
||||
* becoming part of the OR clause).
|
||||
* @public
|
||||
*/
|
||||
export type FilterItem = Filter | FilterItem[];
|
||||
export enum BooleanRelation {
|
||||
AND = 'AND',
|
||||
OR = 'OR',
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface CombinedFilterMeta extends FilterMeta {
|
||||
type: typeof FILTERS.COMBINED;
|
||||
params: FilterItem[];
|
||||
relation: BooleanRelation;
|
||||
params: Filter[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -38,20 +40,38 @@ export function isCombinedFilter(filter: Filter): filter is CombinedFilter {
|
|||
return filter?.meta?.type === FILTERS.COMBINED;
|
||||
}
|
||||
|
||||
const cleanUpFilter = (filter: Filter) => {
|
||||
const { $state, meta, ...cleanedUpFilter } = filter;
|
||||
const { alias, disabled, ...cleanedUpMeta } = meta;
|
||||
return { ...cleanedUpFilter, meta: cleanedUpMeta };
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds an COMBINED filter. An COMBINED filter is a filter with multiple sub-filters. Each sub-filter (FilterItem) represents a
|
||||
* condition.
|
||||
* @param filters An array of CombinedFilterItem
|
||||
* Builds an COMBINED filter. An COMBINED filter is a filter with multiple sub-filters. Each sub-filter (FilterItem)
|
||||
* represents a condition.
|
||||
* @param relation The type of relation with which to combine the filters (AND/OR)
|
||||
* @param filters An array of sub-filters
|
||||
* @public
|
||||
*/
|
||||
export function buildCombinedFilter(filters: FilterItem[]): CombinedFilter {
|
||||
const filter = buildEmptyFilter(false);
|
||||
export function buildCombinedFilter(
|
||||
relation: BooleanRelation,
|
||||
filters: Filter[],
|
||||
indexPattern: DataViewBase,
|
||||
disabled: FilterMeta['disabled'] = false,
|
||||
negate: FilterMeta['negate'] = false,
|
||||
alias?: FilterMeta['alias'],
|
||||
store: FilterStateStore = FilterStateStore.APP_STATE
|
||||
): CombinedFilter {
|
||||
return {
|
||||
...filter,
|
||||
$state: { store },
|
||||
meta: {
|
||||
...filter.meta,
|
||||
type: FILTERS.COMBINED,
|
||||
params: filters,
|
||||
relation,
|
||||
params: filters.map(cleanUpFilter),
|
||||
index: indexPattern.id,
|
||||
disabled,
|
||||
negate,
|
||||
alias,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -6,8 +6,15 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { compareFilters, COMPARE_ALL_OPTIONS } from './compare_filters';
|
||||
import { buildEmptyFilter, buildQueryFilter, FilterStateStore } from '..';
|
||||
import { COMPARE_ALL_OPTIONS, compareFilters } from './compare_filters';
|
||||
import {
|
||||
BooleanRelation,
|
||||
buildCombinedFilter,
|
||||
buildEmptyFilter,
|
||||
buildQueryFilter,
|
||||
FilterStateStore,
|
||||
} from '..';
|
||||
import { DataViewBase } from '@kbn/es-query';
|
||||
|
||||
describe('filter manager utilities', () => {
|
||||
describe('compare filters', () => {
|
||||
|
@ -177,5 +184,68 @@ describe('filter manager utilities', () => {
|
|||
|
||||
expect(compareFilters([f1], [f2], { index: true })).toBeFalsy();
|
||||
});
|
||||
|
||||
test('should compare two AND filters as the same', () => {
|
||||
const dataView: DataViewBase = {
|
||||
id: 'logstash-*',
|
||||
fields: [
|
||||
{
|
||||
name: 'bytes',
|
||||
type: 'number',
|
||||
scripted: false,
|
||||
},
|
||||
],
|
||||
title: 'dataView',
|
||||
};
|
||||
|
||||
const f1 = buildQueryFilter({ query_string: { query: 'apache' } }, dataView.id!, '');
|
||||
const f2 = buildQueryFilter({ query_string: { query: 'apache' } }, dataView.id!, '');
|
||||
const f3 = buildCombinedFilter(BooleanRelation.AND, [f1, f2], dataView);
|
||||
const f4 = buildCombinedFilter(BooleanRelation.AND, [f1, f2], dataView);
|
||||
|
||||
expect(compareFilters([f3], [f4])).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should compare an AND and OR filter as different', () => {
|
||||
const dataView: DataViewBase = {
|
||||
id: 'logstash-*',
|
||||
fields: [
|
||||
{
|
||||
name: 'bytes',
|
||||
type: 'number',
|
||||
scripted: false,
|
||||
},
|
||||
],
|
||||
title: 'dataView',
|
||||
};
|
||||
|
||||
const f1 = buildQueryFilter({ query_string: { query: 'apache' } }, dataView.id!, '');
|
||||
const f2 = buildQueryFilter({ query_string: { query: 'apache' } }, dataView.id!, '');
|
||||
const f3 = buildCombinedFilter(BooleanRelation.AND, [f1, f2], dataView);
|
||||
const f4 = buildCombinedFilter(BooleanRelation.OR, [f1, f2], dataView);
|
||||
|
||||
expect(compareFilters([f3], [f4])).toBeFalsy();
|
||||
});
|
||||
|
||||
test('should compare two different combined filters as different', () => {
|
||||
const dataView: DataViewBase = {
|
||||
id: 'logstash-*',
|
||||
fields: [
|
||||
{
|
||||
name: 'bytes',
|
||||
type: 'number',
|
||||
scripted: false,
|
||||
},
|
||||
],
|
||||
title: 'dataView',
|
||||
};
|
||||
|
||||
const f1 = buildQueryFilter({ query_string: { query: 'apache' } }, dataView.id!, '');
|
||||
const f2 = buildQueryFilter({ query_string: { query: 'apaches' } }, dataView.id!, '');
|
||||
const f3 = buildCombinedFilter(BooleanRelation.AND, [f1], dataView);
|
||||
const f4 = buildCombinedFilter(BooleanRelation.AND, [f2], dataView);
|
||||
|
||||
expect(compareFilters([f3], [f4])).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
import { defaults, isEqual, omit, map } from 'lodash';
|
||||
import type { FilterMeta, Filter } from '../build_filters';
|
||||
import { isCombinedFilter } from '../build_filters';
|
||||
|
||||
/** @public */
|
||||
export interface FilterCompareOptions {
|
||||
|
@ -30,13 +31,21 @@ export const COMPARE_ALL_OPTIONS: FilterCompareOptions = {
|
|||
alias: true,
|
||||
};
|
||||
|
||||
// Combined filters include sub-filters in the `meta` property and the relation type in the `relation` property, so
|
||||
// they should never be excluded in the comparison
|
||||
const removeRequiredAttributes = (excludedAttributes: string[]) =>
|
||||
excludedAttributes.filter((attribute) => !['meta', 'relation'].includes(attribute));
|
||||
|
||||
const mapFilter = (
|
||||
filter: Filter,
|
||||
comparators: FilterCompareOptions,
|
||||
excludedAttributes: string[]
|
||||
) => {
|
||||
const cleaned: FilterMeta = omit(filter, excludedAttributes) as FilterMeta;
|
||||
const attrsToExclude = isCombinedFilter(filter)
|
||||
? removeRequiredAttributes(excludedAttributes)
|
||||
: excludedAttributes;
|
||||
|
||||
const cleaned: FilterMeta = omit(filter, attrsToExclude) as FilterMeta;
|
||||
if (comparators.index) cleaned.index = filter.meta?.index;
|
||||
if (comparators.negate) cleaned.negate = filter.meta && Boolean(filter.meta.negate);
|
||||
if (comparators.disabled) cleaned.disabled = filter.meta && Boolean(filter.meta.disabled);
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
export * from './compare_filters';
|
||||
export * from './dedup_filters';
|
||||
export * from './uniq_filters';
|
||||
export * from './update_filter';
|
||||
export * from './meta_filter';
|
||||
export * from './only_disabled';
|
||||
export * from './extract_time_filter';
|
||||
|
|
138
packages/kbn-es-query/src/filters/helpers/update_filter.ts
Normal file
138
packages/kbn-es-query/src/filters/helpers/update_filter.ts
Normal file
|
@ -0,0 +1,138 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { identity, pickBy } from 'lodash';
|
||||
|
||||
import type { Filter, FilterMeta } from '..';
|
||||
|
||||
type FilterOperator = Pick<FilterMeta, 'type' | 'negate'>;
|
||||
|
||||
export const updateFilter = (
|
||||
filter: Filter,
|
||||
field?: string,
|
||||
operator?: FilterOperator,
|
||||
params?: Filter['meta']['params']
|
||||
) => {
|
||||
if (!field || !operator) {
|
||||
return updateField(filter, field);
|
||||
}
|
||||
|
||||
if (operator.type === 'exists') {
|
||||
return updateWithExistsOperator(filter, operator);
|
||||
}
|
||||
if (operator.type === 'range') {
|
||||
return updateWithRangeOperator(filter, operator, params, field);
|
||||
}
|
||||
if (Array.isArray(params)) {
|
||||
return updateWithIsOneOfOperator(filter, operator, params);
|
||||
}
|
||||
|
||||
return updateWithIsOperator(filter, operator, params);
|
||||
};
|
||||
|
||||
function updateField(filter: Filter, field?: string) {
|
||||
return {
|
||||
...filter,
|
||||
meta: {
|
||||
...filter.meta,
|
||||
key: field,
|
||||
// @todo: check why we need to pass "key" and "field" with the same data
|
||||
field,
|
||||
params: { query: undefined },
|
||||
value: undefined,
|
||||
type: undefined,
|
||||
},
|
||||
query: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function updateWithExistsOperator(filter: Filter, operator?: FilterOperator) {
|
||||
return {
|
||||
...filter,
|
||||
meta: {
|
||||
...filter.meta,
|
||||
negate: operator?.negate,
|
||||
type: operator?.type,
|
||||
params: undefined,
|
||||
value: 'exists',
|
||||
},
|
||||
query: { exists: { field: filter.meta.key } },
|
||||
};
|
||||
}
|
||||
|
||||
function updateWithIsOperator(
|
||||
filter: Filter,
|
||||
operator?: FilterOperator,
|
||||
params?: Filter['meta']['params']
|
||||
) {
|
||||
return {
|
||||
...filter,
|
||||
meta: {
|
||||
...filter.meta,
|
||||
negate: operator?.negate,
|
||||
type: operator?.type,
|
||||
params: { ...filter.meta.params, query: params },
|
||||
},
|
||||
query: { match_phrase: { [filter.meta.key!]: params ?? '' } },
|
||||
};
|
||||
}
|
||||
|
||||
function updateWithRangeOperator(
|
||||
filter: Filter,
|
||||
operator: FilterOperator,
|
||||
rawParams: Array<Filter['meta']['params']>,
|
||||
field: string
|
||||
) {
|
||||
const params = {
|
||||
...filter.meta.params,
|
||||
...pickBy(rawParams, identity),
|
||||
};
|
||||
|
||||
params.gte = params.from;
|
||||
params.lt = params.to;
|
||||
|
||||
const updatedFilter = {
|
||||
...filter,
|
||||
meta: {
|
||||
...filter.meta,
|
||||
negate: operator?.negate,
|
||||
type: operator?.type,
|
||||
params,
|
||||
},
|
||||
query: {
|
||||
range: {
|
||||
[field]: params,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return updatedFilter;
|
||||
}
|
||||
|
||||
function updateWithIsOneOfOperator(
|
||||
filter: Filter,
|
||||
operator?: FilterOperator,
|
||||
params?: Array<Filter['meta']['params']>
|
||||
) {
|
||||
return {
|
||||
...filter,
|
||||
meta: {
|
||||
...filter.meta,
|
||||
negate: operator?.negate,
|
||||
type: operator?.type,
|
||||
params,
|
||||
},
|
||||
query: {
|
||||
bool: {
|
||||
minimum_should_match: 1,
|
||||
...filter!.query?.should,
|
||||
should: params?.map((param) => ({ match_phrase: { [filter.meta.key!]: param } })),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
|
@ -17,6 +17,7 @@ export {
|
|||
isFilter,
|
||||
isFilters,
|
||||
pinFilter,
|
||||
updateFilter,
|
||||
isFilterPinned,
|
||||
onlyDisabledFiltersChanged,
|
||||
enableFilter,
|
||||
|
@ -57,6 +58,7 @@ export {
|
|||
isScriptedPhraseFilter,
|
||||
isScriptedRangeFilter,
|
||||
getFilterParams,
|
||||
BooleanRelation,
|
||||
} from './build_filters';
|
||||
|
||||
export type {
|
||||
|
@ -79,7 +81,6 @@ export type {
|
|||
QueryStringFilter,
|
||||
CombinedFilter,
|
||||
CombinedFilterMeta,
|
||||
FilterItem,
|
||||
} from './build_filters';
|
||||
|
||||
export { FilterStateStore, FILTERS } from './build_filters/types';
|
||||
|
|
|
@ -6,13 +6,16 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { Filter } from '@kbn/es-query';
|
||||
import { Filter, FILTERS, fromCombinedFilter } from '@kbn/es-query';
|
||||
import { buildExpression, buildExpressionFunction } from '@kbn/expressions-plugin/common';
|
||||
import { ExpressionFunctionKibanaFilter } from './kibana_filter';
|
||||
|
||||
export const filtersToAst = (filters: Filter[] | Filter) => {
|
||||
return (Array.isArray(filters) ? filters : [filters]).map((filter) => {
|
||||
const { meta, $state, query, ...restOfFilters } = filter;
|
||||
const filterWithQuery =
|
||||
filter.meta.type === FILTERS.COMBINED ? fromCombinedFilter(filter) : filter;
|
||||
const { meta, $state, query, ...restOfFilters } = filterWithQuery;
|
||||
|
||||
return buildExpression([
|
||||
buildExpressionFunction<ExpressionFunctionKibanaFilter>('kibanaFilter', {
|
||||
query: JSON.stringify(query || restOfFilters),
|
||||
|
|
|
@ -6,11 +6,11 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { uniqBy } from 'lodash';
|
||||
import { isEqual, uniqBy } from 'lodash';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { ExpressionFunctionDefinition, ExecutionContext } from '@kbn/expressions-plugin/common';
|
||||
import { Adapters } from '@kbn/inspector-plugin/common';
|
||||
import { Filter } from '@kbn/es-query';
|
||||
import { Filter, fromCombinedFilter } from '@kbn/es-query';
|
||||
import { Query, uniqFilters } from '@kbn/es-query';
|
||||
import { unboxExpressionValue } from '@kbn/expressions-plugin/common';
|
||||
import { SavedObjectReference } from '@kbn/core/types';
|
||||
|
@ -124,10 +124,9 @@ export const getKibanaContextFn = (
|
|||
|
||||
const timeRange = args.timeRange || input?.timeRange;
|
||||
let queries = mergeQueries(input?.query, args?.q?.filter(Boolean) || []);
|
||||
let filters = [
|
||||
...(input?.filters || []),
|
||||
...((args?.filters?.map(unboxExpressionValue) || []) as Filter[]),
|
||||
];
|
||||
const filterFromArgs = (args?.filters?.map(unboxExpressionValue) || []) as Filter[];
|
||||
|
||||
let filters = [...(input?.filters || [])];
|
||||
|
||||
if (args.savedSearchId) {
|
||||
const obj = await savedObjectsClient.get('search', args.savedSearchId);
|
||||
|
@ -141,6 +140,14 @@ export const getKibanaContextFn = (
|
|||
filters = [...filters, ...(Array.isArray(filter) ? filter : [filter])];
|
||||
}
|
||||
}
|
||||
const uniqueArgFilters = filterFromArgs.filter(
|
||||
(argF) =>
|
||||
!filters.some((f) => {
|
||||
return isEqual(fromCombinedFilter(f).query, argF.query);
|
||||
})
|
||||
);
|
||||
|
||||
filters = [...filters, ...uniqueArgFilters];
|
||||
|
||||
return {
|
||||
type: 'kibana_context',
|
||||
|
|
|
@ -1013,6 +1013,7 @@ export class SearchSource {
|
|||
const filters = (
|
||||
typeof searchRequest.filters === 'function' ? searchRequest.filters() : searchRequest.filters
|
||||
) as Filter[] | Filter | undefined;
|
||||
|
||||
const ast = buildExpression([
|
||||
buildExpressionFunction<ExpressionFunctionKibanaContext>('kibana_context', {
|
||||
q: query?.map(queryToAst),
|
||||
|
|
|
@ -16,18 +16,20 @@ import {
|
|||
isScriptedPhraseFilter,
|
||||
isScriptedRangeFilter,
|
||||
getFilterField,
|
||||
DataViewBase,
|
||||
DataViewFieldBase,
|
||||
} from '@kbn/es-query';
|
||||
import { getPhraseDisplayValue } from './mappers/map_phrase';
|
||||
import { getPhrasesDisplayValue } from './mappers/map_phrases';
|
||||
import { getRangeDisplayValue } from './mappers/map_range';
|
||||
import { getIndexPatternFromFilter } from './get_index_pattern_from_filter';
|
||||
|
||||
function getValueFormatter(indexPattern?: DataView, key?: string) {
|
||||
function getValueFormatter(indexPattern?: DataViewBase | DataView, key?: string) {
|
||||
// checking getFormatterForField exists because there is at least once case where an index pattern
|
||||
// is an object rather than an IndexPattern class
|
||||
if (!indexPattern || !indexPattern.getFormatterForField || !key) return;
|
||||
if (!indexPattern || !('getFormatterForField' in indexPattern) || !key) return;
|
||||
|
||||
const field = indexPattern.fields.find((f: DataViewField) => f.name === key);
|
||||
const field = indexPattern.fields.find((f) => f.name === key);
|
||||
if (!field) {
|
||||
throw new Error(
|
||||
i18n.translate('data.filter.filterBar.fieldNotFound', {
|
||||
|
@ -39,18 +41,24 @@ function getValueFormatter(indexPattern?: DataView, key?: string) {
|
|||
return indexPattern.getFormatterForField(field);
|
||||
}
|
||||
|
||||
export function getFieldDisplayValueFromFilter(filter: Filter, indexPatterns: DataView[]): string {
|
||||
const indexPattern = getIndexPatternFromFilter(filter, indexPatterns);
|
||||
export function getFieldDisplayValueFromFilter(
|
||||
filter: Filter,
|
||||
indexPatterns: DataView[] | DataViewBase[]
|
||||
): string {
|
||||
const indexPattern = getIndexPatternFromFilter<DataView | DataViewBase>(filter, indexPatterns);
|
||||
if (!indexPattern) return '';
|
||||
|
||||
const fieldName = getFilterField(filter);
|
||||
if (!fieldName) return '';
|
||||
|
||||
const field = indexPattern.fields.find((f: DataViewField) => f.name === fieldName);
|
||||
return field?.customLabel ?? '';
|
||||
const field = indexPattern.fields.find(
|
||||
(f: DataViewFieldBase | DataViewField) => f.name === fieldName
|
||||
);
|
||||
|
||||
return field && 'customLabel' in field ? (field as DataViewField).customLabel ?? '' : '';
|
||||
}
|
||||
|
||||
export function getDisplayValueFromFilter(filter: Filter, indexPatterns: DataView[]): string {
|
||||
export function getDisplayValueFromFilter(filter: Filter, indexPatterns: DataViewBase[]): string {
|
||||
const indexPattern = getIndexPatternFromFilter(filter, indexPatterns);
|
||||
const fieldName = getFilterField(filter);
|
||||
const valueFormatter = getValueFormatter(indexPattern, fieldName);
|
||||
|
|
|
@ -6,12 +6,11 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { DataView } from '@kbn/data-views-plugin/public';
|
||||
import { Filter } from '@kbn/es-query';
|
||||
import { Filter, DataViewBase } from '@kbn/es-query';
|
||||
|
||||
export function getIndexPatternFromFilter(
|
||||
export function getIndexPatternFromFilter<T extends DataViewBase = DataViewBase>(
|
||||
filter: Filter,
|
||||
indexPatterns: DataView[]
|
||||
): DataView | undefined {
|
||||
indexPatterns: T[]
|
||||
): T | undefined {
|
||||
return indexPatterns.find((indexPattern) => indexPattern.id === filter.meta.index);
|
||||
}
|
||||
|
|
|
@ -6,9 +6,10 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { reduceRight } from 'lodash';
|
||||
import { cloneDeep, reduceRight } from 'lodash';
|
||||
|
||||
import { Filter } from '@kbn/es-query';
|
||||
import { mapCombined } from './mappers/map_combined';
|
||||
import { mapSpatialFilter } from './mappers/map_spatial_filter';
|
||||
import { mapMatchAll } from './mappers/map_match_all';
|
||||
import { mapPhrase } from './mappers/map_phrase';
|
||||
|
@ -37,6 +38,7 @@ export function mapFilter(filter: Filter) {
|
|||
// that either handles the mapping operation or not
|
||||
// and add it here. ProTip: These are executed in order listed
|
||||
const mappers = [
|
||||
mapCombined,
|
||||
mapSpatialFilter,
|
||||
mapMatchAll,
|
||||
mapRange,
|
||||
|
@ -59,19 +61,20 @@ export function mapFilter(filter: Filter) {
|
|||
noop
|
||||
);
|
||||
|
||||
const mapped = mapFn(filter);
|
||||
const mappedFilter = cloneDeep(filter);
|
||||
const mapped = mapFn(mappedFilter);
|
||||
|
||||
// Map the filter into an object with the key and value exposed so it's
|
||||
// easier to work with in the template
|
||||
filter.meta = filter.meta || {};
|
||||
filter.meta.type = mapped.type;
|
||||
filter.meta.key = mapped.key;
|
||||
mappedFilter.meta = filter.meta || {};
|
||||
mappedFilter.meta.type = mapped.type;
|
||||
mappedFilter.meta.key = mapped.key;
|
||||
// Display value or formatter function.
|
||||
filter.meta.value = mapped.value;
|
||||
filter.meta.params = mapped.params;
|
||||
filter.meta.disabled = Boolean(filter.meta.disabled);
|
||||
filter.meta.negate = Boolean(filter.meta.negate);
|
||||
filter.meta.alias = filter.meta.alias || null;
|
||||
mappedFilter.meta.value = mapped.value;
|
||||
mappedFilter.meta.params = mapped.params;
|
||||
mappedFilter.meta.disabled = Boolean(mappedFilter.meta.disabled);
|
||||
mappedFilter.meta.negate = Boolean(mappedFilter.meta.negate);
|
||||
mappedFilter.meta.alias = mappedFilter.meta.alias || null;
|
||||
|
||||
return filter;
|
||||
return mappedFilter;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,76 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { DataView } from '@kbn/data-views-plugin/common';
|
||||
import {
|
||||
BooleanRelation,
|
||||
buildEmptyFilter,
|
||||
buildCombinedFilter,
|
||||
FilterMeta,
|
||||
RangeFilter,
|
||||
} from '@kbn/es-query';
|
||||
import { mapCombined } from './map_combined';
|
||||
|
||||
describe('filter manager utilities', () => {
|
||||
describe('mapCombined()', () => {
|
||||
test('should throw if not a combinedFilter', async () => {
|
||||
const filter = buildEmptyFilter(true);
|
||||
try {
|
||||
mapCombined(filter);
|
||||
} catch (e) {
|
||||
expect(e).toBe(filter);
|
||||
}
|
||||
});
|
||||
|
||||
test('should call mapFilter for sub-filters', async () => {
|
||||
const rangeFilter = {
|
||||
meta: { index: 'logstash-*' } as FilterMeta,
|
||||
query: { range: { bytes: { lt: 2048, gt: 1024 } } },
|
||||
} as RangeFilter;
|
||||
const filter = buildCombinedFilter(BooleanRelation.AND, [rangeFilter], {
|
||||
id: 'logstash-*',
|
||||
} as DataView);
|
||||
const result = mapCombined(filter);
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"key": undefined,
|
||||
"params": Array [
|
||||
Object {
|
||||
"meta": Object {
|
||||
"alias": null,
|
||||
"disabled": false,
|
||||
"index": "logstash-*",
|
||||
"key": "bytes",
|
||||
"negate": false,
|
||||
"params": Object {
|
||||
"gt": 1024,
|
||||
"lt": 2048,
|
||||
},
|
||||
"type": "range",
|
||||
"value": Object {
|
||||
"gt": 1024,
|
||||
"lt": 2048,
|
||||
},
|
||||
},
|
||||
"query": Object {
|
||||
"range": Object {
|
||||
"bytes": Object {
|
||||
"gt": 1024,
|
||||
"lt": 2048,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
"type": "combined",
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { Filter, isCombinedFilter } from '@kbn/es-query';
|
||||
import { mapFilter } from '../map_filter';
|
||||
|
||||
export const mapCombined = (filter: Filter) => {
|
||||
if (!isCombinedFilter(filter)) {
|
||||
throw filter;
|
||||
}
|
||||
|
||||
const { type, key, params } = filter.meta;
|
||||
|
||||
return {
|
||||
type,
|
||||
key,
|
||||
params: params.map(mapFilter),
|
||||
};
|
||||
};
|
|
@ -13,4 +13,5 @@ module.exports = {
|
|||
coverageDirectory: '<rootDir>/target/kibana-coverage/jest/src/plugins/unified_search',
|
||||
coverageReporters: ['text', 'html'],
|
||||
collectCoverageFrom: ['<rootDir>/src/plugins/unified_search/public/**/*.{ts,tsx}'],
|
||||
setupFiles: ['jest-canvas-mock'],
|
||||
};
|
||||
|
|
|
@ -25,8 +25,8 @@ import {
|
|||
getFieldDisplayValueFromFilter,
|
||||
} from '@kbn/data-plugin/public';
|
||||
import type { Filter } from '@kbn/es-query';
|
||||
import { DataView } from '@kbn/data-views-plugin/public';
|
||||
import { FilterLabel } from '../filter_bar';
|
||||
import type { DataView } from '@kbn/data-views-plugin/public';
|
||||
import { FilterContent } from '../filter_badge';
|
||||
|
||||
interface Props {
|
||||
filters: Filter[];
|
||||
|
@ -58,7 +58,7 @@ export default class ApplyFiltersPopoverContent extends Component<Props, State>
|
|||
private getLabel = (filter: Filter) => {
|
||||
const valueLabel = getDisplayValueFromFilter(filter, this.props.indexPatterns);
|
||||
const fieldLabel = getFieldDisplayValueFromFilter(filter, this.props.indexPatterns);
|
||||
return <FilterLabel filter={filter} valueLabel={valueLabel} fieldLabel={fieldLabel} />;
|
||||
return <FilterContent filter={filter} valueLabel={valueLabel} fieldLabel={fieldLabel} />;
|
||||
};
|
||||
|
||||
public render() {
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
import { css } from '@emotion/css';
|
||||
import { euiThemeVars } from '@kbn/ui-theme';
|
||||
import type { EuiThemeComputed } from '@elastic/eui';
|
||||
|
||||
export const badgePaddingCss = (euiTheme: EuiThemeComputed) => css`
|
||||
padding: calc(${euiTheme.size.xs} + ${euiTheme.size.xxs});
|
||||
`;
|
||||
|
||||
export const marginLeftLabelCss = (euiTheme: EuiThemeComputed) => css`
|
||||
margin-left: ${euiTheme.size.xs};
|
||||
`;
|
||||
|
||||
export const bracketColorCss = css`
|
||||
color: ${euiThemeVars.euiColorPrimary};
|
||||
`;
|
||||
|
||||
export const conditionSpacesCss = (euiTheme: EuiThemeComputed) => css`
|
||||
margin-inline: -${euiTheme.size.xs};
|
||||
`;
|
||||
|
||||
export const conditionCss = css`
|
||||
${bracketColorCss}
|
||||
`;
|
|
@ -0,0 +1,84 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { EuiBadge, EuiTextColor, useEuiTheme } from '@elastic/eui';
|
||||
import type { DataView } from '@kbn/data-views-plugin/common';
|
||||
import type { Filter } from '@kbn/es-query';
|
||||
import { isCombinedFilter } from '@kbn/es-query';
|
||||
import { FilterBadgeGroup } from './filter_badge_group';
|
||||
import type { FilterLabelStatus } from '../filter_bar/filter_item/filter_item';
|
||||
import { badgePaddingCss, marginLeftLabelCss } from './filter_badge.styles';
|
||||
import { strings } from './i18n';
|
||||
|
||||
export interface FilterBadgeProps {
|
||||
filter: Filter;
|
||||
dataViews: DataView[];
|
||||
valueLabel: string;
|
||||
hideAlias?: boolean;
|
||||
filterLabelStatus: FilterLabelStatus;
|
||||
}
|
||||
|
||||
function FilterBadge({
|
||||
filter,
|
||||
dataViews,
|
||||
valueLabel,
|
||||
hideAlias,
|
||||
filterLabelStatus,
|
||||
...rest
|
||||
}: FilterBadgeProps) {
|
||||
const { euiTheme } = useEuiTheme();
|
||||
|
||||
if (!dataViews.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const prefixText = filter.meta.negate ? ` ${strings.getNotLabel()}` : '';
|
||||
|
||||
const prefix =
|
||||
filter.meta.negate && !filter.meta.disabled ? (
|
||||
<EuiTextColor color="danger">{prefixText}</EuiTextColor>
|
||||
) : (
|
||||
prefixText
|
||||
);
|
||||
|
||||
const filterLabelValue = <span className="globalFilterLabel__value">{valueLabel}</span>;
|
||||
|
||||
return (
|
||||
<EuiBadge
|
||||
className={badgePaddingCss(euiTheme)}
|
||||
color="hollow"
|
||||
iconType="cross"
|
||||
iconSide="right"
|
||||
{...rest}
|
||||
>
|
||||
{!hideAlias && filter.meta.alias !== null ? (
|
||||
<>
|
||||
<span className={marginLeftLabelCss(euiTheme)}>
|
||||
{prefix}
|
||||
{filter.meta.alias}
|
||||
{filterLabelStatus && <>: {filterLabelValue}</>}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
{isCombinedFilter(filter) && prefix}
|
||||
<FilterBadgeGroup
|
||||
filters={[filter]}
|
||||
dataViews={dataViews}
|
||||
filterLabelStatus={valueLabel}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</EuiBadge>
|
||||
);
|
||||
}
|
||||
|
||||
// React.lazy support
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default FilterBadge;
|
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
import React, { Component } from 'react';
|
||||
import { FilterBadgeInvalidPlaceholder } from './filter_badge_invalid';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
interface FilterBadgeErrorBoundaryProps {}
|
||||
|
||||
interface FilterBadgeErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
export class FilterBadgeErrorBoundary extends Component<
|
||||
FilterBadgeErrorBoundaryProps,
|
||||
FilterBadgeErrorBoundaryState
|
||||
> {
|
||||
constructor(props: FilterBadgeErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError() {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentWillReceiveProps() {
|
||||
this.setState({ hasError: false });
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
// You can render any custom fallback UI
|
||||
return <FilterBadgeInvalidPlaceholder />;
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,87 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { getDisplayValueFromFilter, getFieldDisplayValueFromFilter } from '@kbn/data-plugin/public';
|
||||
import type { Filter, DataViewBase } from '@kbn/es-query';
|
||||
import { EuiTextColor } from '@elastic/eui';
|
||||
import { FilterBadgeGroup } from './filter_badge_group';
|
||||
import { FilterContent } from './filter_content';
|
||||
import { getBooleanRelationType } from '../utils';
|
||||
import { FilterBadgeInvalidPlaceholder } from './filter_badge_invalid';
|
||||
import { bracketColorCss } from './filter_badge.styles';
|
||||
|
||||
export interface FilterBadgeExpressionProps {
|
||||
filter: Filter;
|
||||
shouldShowBrackets?: boolean;
|
||||
dataViews: DataViewBase[];
|
||||
filterLabelStatus?: string;
|
||||
}
|
||||
|
||||
interface FilterBadgeContentProps {
|
||||
filter: Filter;
|
||||
dataViews: DataViewBase[];
|
||||
filterLabelStatus?: string;
|
||||
}
|
||||
|
||||
const FilterBadgeContent = ({ filter, dataViews, filterLabelStatus }: FilterBadgeContentProps) => {
|
||||
const valueLabel = filterLabelStatus || getDisplayValueFromFilter(filter, dataViews);
|
||||
|
||||
const fieldLabel = getFieldDisplayValueFromFilter(filter, dataViews);
|
||||
|
||||
if (!valueLabel || !filter) {
|
||||
return <FilterBadgeInvalidPlaceholder />;
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterContent
|
||||
filter={filter}
|
||||
valueLabel={valueLabel}
|
||||
fieldLabel={fieldLabel}
|
||||
hideAlias={true}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export function FilterExpressionBadge({
|
||||
filter,
|
||||
shouldShowBrackets,
|
||||
dataViews,
|
||||
filterLabelStatus,
|
||||
}: FilterBadgeExpressionProps) {
|
||||
const conditionalOperationType = getBooleanRelationType(filter);
|
||||
|
||||
return conditionalOperationType ? (
|
||||
<>
|
||||
{shouldShowBrackets && (
|
||||
<span>
|
||||
<EuiTextColor className={bracketColorCss}>(</EuiTextColor>
|
||||
</span>
|
||||
)}
|
||||
<FilterBadgeGroup
|
||||
filters={filter.meta?.params}
|
||||
dataViews={dataViews}
|
||||
filterLabelStatus={filterLabelStatus}
|
||||
booleanRelation={getBooleanRelationType(filter)}
|
||||
/>
|
||||
{shouldShowBrackets && (
|
||||
<span>
|
||||
<EuiTextColor className={bracketColorCss}>)</EuiTextColor>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span>
|
||||
<FilterBadgeContent
|
||||
filter={filter}
|
||||
dataViews={dataViews}
|
||||
filterLabelStatus={filterLabelStatus}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,61 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { Filter, BooleanRelation, DataViewBase } from '@kbn/es-query';
|
||||
import { EuiTextColor } from '@elastic/eui';
|
||||
import { FilterBadgeErrorBoundary } from './filter_badge_error_boundary';
|
||||
import { FilterExpressionBadge } from './filter_badge_expression';
|
||||
import { conditionCss } from './filter_badge.styles';
|
||||
|
||||
export interface FilterBadgeGroupProps {
|
||||
filters: Filter[];
|
||||
dataViews: DataViewBase[];
|
||||
filterLabelStatus?: string;
|
||||
shouldShowBrackets?: boolean;
|
||||
booleanRelation?: BooleanRelation;
|
||||
}
|
||||
|
||||
const BooleanRelationDelimiter = ({ conditional }: { conditional: BooleanRelation }) => {
|
||||
/**
|
||||
* Spaces have been added to make the title readable.
|
||||
*/
|
||||
return <EuiTextColor className={conditionCss}>{` ${conditional} `}</EuiTextColor>;
|
||||
};
|
||||
|
||||
export function FilterBadgeGroup({
|
||||
filters,
|
||||
dataViews,
|
||||
filterLabelStatus,
|
||||
booleanRelation,
|
||||
shouldShowBrackets = true,
|
||||
}: FilterBadgeGroupProps) {
|
||||
return (
|
||||
<FilterBadgeErrorBoundary>
|
||||
{filters.map((filter, index, filterArr) => {
|
||||
const showRelationDelimiter = booleanRelation && index + 1 < filterArr.length;
|
||||
const showBrackets = shouldShowBrackets && (filter.meta.negate || filterArr.length > 1);
|
||||
return (
|
||||
<>
|
||||
<FilterExpressionBadge
|
||||
filter={filter}
|
||||
shouldShowBrackets={showBrackets}
|
||||
dataViews={dataViews}
|
||||
filterLabelStatus={filterLabelStatus}
|
||||
/>
|
||||
{showRelationDelimiter && <BooleanRelationDelimiter conditional={booleanRelation} />}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</FilterBadgeErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
// Needed for React.lazy
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default FilterBadgeGroup;
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { EuiBadge, useEuiTheme } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
|
||||
export const FilterBadgeInvalidPlaceholder = () => {
|
||||
const { euiTheme } = useEuiTheme();
|
||||
|
||||
return (
|
||||
<EuiBadge iconType="unlink" color={euiTheme.colors.lightestShade}>
|
||||
<FormattedMessage
|
||||
id="unifiedSearch.filter.filterBadgeInvalidPlaceholder.label"
|
||||
defaultMessage="filter value is invalid or incomplete"
|
||||
/>
|
||||
</EuiBadge>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,81 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`alias 1`] = `
|
||||
<div>
|
||||
<span
|
||||
class="globalFilterLabel__value emotion-euiTextColor-success"
|
||||
>
|
||||
geo.coordinates in US
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`alias with error status 1`] = `
|
||||
<div>
|
||||
<span
|
||||
class="emotion-euiTextColor-danger"
|
||||
>
|
||||
NOT
|
||||
</span>
|
||||
machine.os
|
||||
:
|
||||
<span
|
||||
class="globalFilterLabel__value emotion-euiTextColor-success"
|
||||
>
|
||||
Error
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`alias with warning status 1`] = `
|
||||
<div>
|
||||
<span
|
||||
class="emotion-euiTextColor-danger"
|
||||
>
|
||||
NOT
|
||||
</span>
|
||||
machine.os
|
||||
:
|
||||
<span
|
||||
class="globalFilterLabel__value emotion-euiTextColor-success"
|
||||
>
|
||||
Warning
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`error 1`] = `
|
||||
<div>
|
||||
machine.os
|
||||
:
|
||||
<span
|
||||
class="globalFilterLabel__value emotion-euiTextColor-success"
|
||||
>
|
||||
Error
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`field custom label 1`] = `
|
||||
<div>
|
||||
test label
|
||||
:
|
||||
<span
|
||||
class="globalFilterLabel__value emotion-euiTextColor-success"
|
||||
>
|
||||
ios
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`warning 1`] = `
|
||||
<div>
|
||||
machine.os
|
||||
:
|
||||
<span
|
||||
class="globalFilterLabel__value emotion-euiTextColor-success"
|
||||
>
|
||||
Warning
|
||||
</span>
|
||||
</div>
|
||||
`;
|
|
@ -7,7 +7,7 @@
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import FilterLabel from './filter_label';
|
||||
import FilterContent from './filter_content';
|
||||
import { render } from '@testing-library/react';
|
||||
import { phraseFilter } from '@kbn/data-plugin/common/stubs';
|
||||
|
||||
|
@ -19,7 +19,7 @@ test('alias', () => {
|
|||
alias: 'geo.coordinates in US',
|
||||
},
|
||||
};
|
||||
const { container } = render(<FilterLabel filter={filter} />);
|
||||
const { container } = render(<FilterContent filter={filter} valueLabel={'ios'} />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
|
@ -28,10 +28,12 @@ test('field custom label', () => {
|
|||
...phraseFilter,
|
||||
meta: {
|
||||
...phraseFilter.meta,
|
||||
alias: 'geo.coordinates in US',
|
||||
alias: null,
|
||||
},
|
||||
};
|
||||
const { container } = render(<FilterLabel filter={filter} fieldLabel="test label" />);
|
||||
const { container } = render(
|
||||
<FilterContent filter={filter} valueLabel={'ios'} fieldLabel="test label" />
|
||||
);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
|
@ -40,13 +42,11 @@ test('alias with warning status', () => {
|
|||
...phraseFilter,
|
||||
meta: {
|
||||
...phraseFilter.meta,
|
||||
alias: 'geo.coordinates in US',
|
||||
alias: null,
|
||||
negate: true,
|
||||
},
|
||||
};
|
||||
const { container } = render(
|
||||
<FilterLabel filter={filter} valueLabel={'Warning'} filterLabelStatus={'warn'} />
|
||||
);
|
||||
const { container } = render(<FilterContent filter={filter} valueLabel={'Warning'} />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
|
@ -55,22 +55,20 @@ test('alias with error status', () => {
|
|||
...phraseFilter,
|
||||
meta: {
|
||||
...phraseFilter.meta,
|
||||
alias: 'geo.coordinates in US',
|
||||
alias: null,
|
||||
negate: true,
|
||||
},
|
||||
};
|
||||
const { container } = render(
|
||||
<FilterLabel filter={filter} valueLabel={'Error'} filterLabelStatus={'error'} />
|
||||
);
|
||||
const { container } = render(<FilterContent filter={filter} valueLabel={'Error'} />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('warning', () => {
|
||||
const { container } = render(<FilterLabel filter={phraseFilter} valueLabel={'Warning'} />);
|
||||
const { container } = render(<FilterContent filter={phraseFilter} valueLabel={'Warning'} />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('error', () => {
|
||||
const { container } = render(<FilterLabel filter={phraseFilter} valueLabel={'Error'} />);
|
||||
const { container } = render(<FilterContent filter={phraseFilter} valueLabel={'Error'} />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
|
@ -0,0 +1,103 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { EuiTextColor } from '@elastic/eui';
|
||||
import type { Filter } from '@kbn/es-query';
|
||||
import { FILTERS } from '@kbn/es-query';
|
||||
import { existsOperator, isOneOfOperator } from '../../filter_bar/filter_editor';
|
||||
import { strings } from '../i18n';
|
||||
|
||||
const FilterValue = ({ value }: { value: string | number }) => {
|
||||
return (
|
||||
<EuiTextColor
|
||||
color={typeof value === 'string' ? 'success' : 'accent'}
|
||||
className="globalFilterLabel__value"
|
||||
>
|
||||
{` ${value}`}
|
||||
</EuiTextColor>
|
||||
);
|
||||
};
|
||||
|
||||
const FilterField = ({
|
||||
filter,
|
||||
fieldLabel,
|
||||
}: {
|
||||
filter: Filter;
|
||||
fieldLabel?: string | undefined;
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<Prefix prefix={filter.meta.negate} />
|
||||
{fieldLabel || filter.meta.key}:
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Prefix = ({ prefix }: { prefix?: boolean }) =>
|
||||
prefix ? <EuiTextColor color="danger">{strings.getNotLabel()}</EuiTextColor> : null;
|
||||
|
||||
export interface FilterContentProps {
|
||||
filter: Filter;
|
||||
valueLabel: string;
|
||||
fieldLabel?: string;
|
||||
hideAlias?: boolean;
|
||||
}
|
||||
|
||||
export function FilterContent({ filter, valueLabel, fieldLabel, hideAlias }: FilterContentProps) {
|
||||
if (!hideAlias && filter.meta.alias !== null) {
|
||||
return (
|
||||
<>
|
||||
<Prefix prefix={filter.meta.negate} />
|
||||
<FilterValue value={`${filter.meta.alias}`} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
switch (filter.meta.type) {
|
||||
case FILTERS.EXISTS:
|
||||
return (
|
||||
<>
|
||||
<FilterField filter={filter} fieldLabel={fieldLabel} />
|
||||
<FilterValue value={`${existsOperator.message}`} />
|
||||
</>
|
||||
);
|
||||
case FILTERS.PHRASES:
|
||||
return (
|
||||
<>
|
||||
<FilterField filter={filter} fieldLabel={fieldLabel} />
|
||||
<FilterValue value={`${isOneOfOperator.message} ${valueLabel}`} />
|
||||
</>
|
||||
);
|
||||
case FILTERS.QUERY_STRING:
|
||||
return (
|
||||
<>
|
||||
<Prefix prefix={filter.meta.negate} /> <FilterValue value={valueLabel} />
|
||||
</>
|
||||
);
|
||||
case FILTERS.PHRASE:
|
||||
case FILTERS.RANGE:
|
||||
return (
|
||||
<>
|
||||
<FilterField filter={filter} fieldLabel={fieldLabel} />
|
||||
<FilterValue value={valueLabel} />
|
||||
</>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<>
|
||||
<Prefix prefix={filter.meta.negate} />
|
||||
<FilterValue value={`${JSON.stringify(filter.query) || filter.meta.value}`} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Needed for React.lazy
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default FilterContent;
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { withSuspense } from '@kbn/shared-ux-utility';
|
||||
|
||||
/**
|
||||
* The Lazily-loaded `FilterContent` component. Consumers should use `React.Suspense` or
|
||||
* the withSuspense` HOC to load this component.
|
||||
*/
|
||||
export const FilterContentLazy = React.lazy(() => import('./filter_content'));
|
||||
|
||||
/**
|
||||
* A `FilterContent` component that is wrapped by the `withSuspense` HOC. This component can
|
||||
* be used directly by consumers and will load the `FilterContentLazy` component lazily with
|
||||
* a predefined fallback and error boundary.
|
||||
*/
|
||||
export const FilterContent = withSuspense(FilterContentLazy);
|
16
src/plugins/unified_search/public/filter_badge/i18n.ts
Normal file
16
src/plugins/unified_search/public/filter_badge/i18n.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
export const strings = {
|
||||
getNotLabel: () =>
|
||||
i18n.translate('unifiedSearch.filter.filterBar.negatedFilterPrefix', {
|
||||
defaultMessage: 'NOT ',
|
||||
}),
|
||||
};
|
38
src/plugins/unified_search/public/filter_badge/index.ts
Normal file
38
src/plugins/unified_search/public/filter_badge/index.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { withSuspense } from '@kbn/shared-ux-utility';
|
||||
|
||||
export { FilterContent, FilterContentLazy } from './filter_content';
|
||||
|
||||
/**
|
||||
* The Lazily-loaded `FilterBadge` component. Consumers should use `React.Suspense` or
|
||||
* the withSuspense` HOC to load this component.
|
||||
*/
|
||||
export const FilterBadgeLazy = React.lazy(() => import('./filter_badge'));
|
||||
|
||||
/**
|
||||
* A `FilterBadge` component that is wrapped by the `withSuspense` HOC. This component can
|
||||
* be used directly by consumers and will load the `FilterBadgeLazy` component lazily with
|
||||
* a predefined fallback and error boundary.
|
||||
*/
|
||||
export const FilterBadge = withSuspense(FilterBadgeLazy);
|
||||
|
||||
/**
|
||||
* The Lazily-loaded `FilterBadgeGroup` component. Consumers should use `React.Suspense` or
|
||||
* the withSuspense` HOC to load this component.
|
||||
*/
|
||||
export const FilterBadgeGroupLazy = React.lazy(() => import('./filter_badge_group'));
|
||||
|
||||
/**
|
||||
* A `FilterBadgeGroup` component that is wrapped by the `withSuspense` HOC. This component can
|
||||
* be used directly by consumers and will load the `FilterBadgeGroupLazy` component lazily with
|
||||
* a predefined fallback and error boundary.
|
||||
*/
|
||||
export const FilterBadgeGroup = withSuspense(FilterBadgeGroupLazy);
|
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { EuiThemeComputed } from '@elastic/eui';
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
export const filtersBuilderMaxHeightCss = (euiTheme: EuiThemeComputed) => css`
|
||||
max-height: ${euiTheme.size.base} * 10;
|
||||
`;
|
||||
|
||||
/** @todo: should be removed, no hardcoded sizes **/
|
||||
export const filterBadgeStyle = css`
|
||||
.euiFormRow__fieldWrapper {
|
||||
line-height: 1.5;
|
||||
}
|
||||
`;
|
||||
|
||||
export const filterPreviewLabelStyle = css`
|
||||
& .euiFormLabel[for] {
|
||||
cursor: default;
|
||||
}
|
||||
`;
|
|
@ -6,10 +6,11 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { UseEuiTheme, EuiThemeComputed } from '@elastic/eui';
|
||||
import { registerTestBed, TestBed } from '@kbn/test-jest-helpers';
|
||||
import type { FilterEditorProps } from '.';
|
||||
import { FilterEditor } from '.';
|
||||
import React from 'react';
|
||||
|
||||
jest.mock('@kbn/kibana-react-plugin/public', () => {
|
||||
const original = jest.requireActual('@kbn/kibana-react-plugin/public');
|
||||
|
@ -34,6 +35,11 @@ describe('<FilterEditor />', () => {
|
|||
|
||||
beforeEach(async () => {
|
||||
const defaultProps: Omit<FilterEditorProps, 'intl'> = {
|
||||
theme: {
|
||||
euiTheme: {} as unknown as EuiThemeComputed<{}>,
|
||||
colorMode: 'DARK',
|
||||
modifications: [],
|
||||
} as UseEuiTheme<{}>,
|
||||
filter: {
|
||||
meta: {
|
||||
type: 'phase',
|
||||
|
|
|
@ -14,117 +14,173 @@ import {
|
|||
EuiFlexItem,
|
||||
EuiForm,
|
||||
EuiFormRow,
|
||||
EuiIcon,
|
||||
EuiPopoverFooter,
|
||||
EuiPopoverTitle,
|
||||
EuiSpacer,
|
||||
EuiSwitch,
|
||||
EuiSwitchEvent,
|
||||
EuiText,
|
||||
EuiToolTip,
|
||||
withEuiTheme,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n-react';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import {
|
||||
Filter,
|
||||
FieldFilter,
|
||||
buildFilter,
|
||||
BooleanRelation,
|
||||
buildCombinedFilter,
|
||||
buildCustomFilter,
|
||||
buildEmptyFilter,
|
||||
cleanFilter,
|
||||
Filter,
|
||||
getFilterParams,
|
||||
isCombinedFilter,
|
||||
} from '@kbn/es-query';
|
||||
import { get } from 'lodash';
|
||||
import { merge } from 'lodash';
|
||||
import React, { Component } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { XJsonLang } from '@kbn/monaco';
|
||||
import { DataView, DataViewField } from '@kbn/data-views-plugin/common';
|
||||
import { DataView } from '@kbn/data-views-plugin/common';
|
||||
import { getIndexPatternFromFilter } from '@kbn/data-plugin/public';
|
||||
import { CodeEditor } from '@kbn/kibana-react-plugin/public';
|
||||
import { GenericComboBox, GenericComboBoxProps } from './generic_combo_box';
|
||||
import { cx } from '@emotion/css';
|
||||
import { WithEuiThemeProps } from '@elastic/eui/src/services/theme';
|
||||
import { GenericComboBox } from './generic_combo_box';
|
||||
import {
|
||||
getFieldFromFilter,
|
||||
getFilterableFields,
|
||||
getOperatorFromFilter,
|
||||
getOperatorOptions,
|
||||
isFilterValid,
|
||||
} from './lib/filter_editor_utils';
|
||||
import { Operator } from './lib/filter_operators';
|
||||
import { PhraseValueInput } from './phrase_value_input';
|
||||
import { PhrasesValuesInput } from './phrases_values_input';
|
||||
import { RangeValueInput } from './range_value_input';
|
||||
import { getFieldValidityAndErrorMessage } from './lib/helpers';
|
||||
import { FiltersBuilder } from '../../filters_builder';
|
||||
import { FilterBadgeGroup } from '../../filter_badge/filter_badge_group';
|
||||
import { flattenFilters } from './lib/helpers';
|
||||
import {
|
||||
filterBadgeStyle,
|
||||
filterPreviewLabelStyle,
|
||||
filtersBuilderMaxHeightCss,
|
||||
} from './filter_editor.styles';
|
||||
|
||||
export interface FilterEditorProps {
|
||||
export const strings = {
|
||||
getPanelTitleAdd: () =>
|
||||
i18n.translate('unifiedSearch.filter.filterEditor.addFilterPopupTitle', {
|
||||
defaultMessage: 'Add filter',
|
||||
}),
|
||||
getPanelTitleEdit: () =>
|
||||
i18n.translate('unifiedSearch.filter.filterEditor.editFilterPopupTitle', {
|
||||
defaultMessage: 'Edit filter',
|
||||
}),
|
||||
|
||||
getAddButtonLabel: () =>
|
||||
i18n.translate('unifiedSearch.filter.filterEditor.addButtonLabel', {
|
||||
defaultMessage: 'Add filter',
|
||||
}),
|
||||
getUpdateButtonLabel: () =>
|
||||
i18n.translate('unifiedSearch.filter.filterEditor.updateButtonLabel', {
|
||||
defaultMessage: 'Update filter',
|
||||
}),
|
||||
getDisableToggleModeTooltip: () =>
|
||||
i18n.translate('unifiedSearch.filter.filterEditor.disableToggleModeTooltip', {
|
||||
defaultMessage: '"Edit as Query DSL" operation is not supported for combined filters',
|
||||
}),
|
||||
getSelectDataViewToolTip: () =>
|
||||
i18n.translate('unifiedSearch.filter.filterEditor.chooseDataViewFirstToolTip', {
|
||||
defaultMessage: 'You need to select a data view first',
|
||||
}),
|
||||
getCustomLabel: () =>
|
||||
i18n.translate('unifiedSearch.filter.filterEditor.createCustomLabelInputLabel', {
|
||||
defaultMessage: 'Custom label (optional)',
|
||||
}),
|
||||
getAddCustomLabel: () =>
|
||||
i18n.translate('unifiedSearch.filter.filterEditor.customLabelPlaceholder', {
|
||||
defaultMessage: 'Add a custom label here',
|
||||
}),
|
||||
getSelectDataView: () =>
|
||||
i18n.translate('unifiedSearch.filter.filterBar.indexPatternSelectPlaceholder', {
|
||||
defaultMessage: 'Select a data view',
|
||||
}),
|
||||
getDataView: () =>
|
||||
i18n.translate('unifiedSearch.filter.filterEditor.dateViewSelectLabel', {
|
||||
defaultMessage: 'Data view',
|
||||
}),
|
||||
getQueryDslLabel: () =>
|
||||
i18n.translate('unifiedSearch.filter.filterEditor.queryDslLabel', {
|
||||
defaultMessage: 'Elasticsearch Query DSL',
|
||||
}),
|
||||
getQueryDslAriaLabel: () =>
|
||||
i18n.translate('unifiedSearch.filter.filterEditor.queryDslAriaLabel', {
|
||||
defaultMessage: 'Elasticsearch Query DSL editor',
|
||||
}),
|
||||
};
|
||||
export interface FilterEditorComponentProps {
|
||||
filter: Filter;
|
||||
indexPatterns: DataView[];
|
||||
onSubmit: (filter: Filter) => void;
|
||||
onCancel: () => void;
|
||||
intl: InjectedIntl;
|
||||
timeRangeForSuggestionsOverride?: boolean;
|
||||
mode?: 'edit' | 'add';
|
||||
}
|
||||
|
||||
export type FilterEditorProps = WithEuiThemeProps & FilterEditorComponentProps;
|
||||
|
||||
interface State {
|
||||
selectedIndexPattern?: DataView;
|
||||
selectedField?: DataViewField;
|
||||
selectedOperator?: Operator;
|
||||
params: any;
|
||||
useCustomLabel: boolean;
|
||||
selectedDataView?: DataView;
|
||||
customLabel: string | null;
|
||||
queryDsl: string;
|
||||
isCustomEditorOpen: boolean;
|
||||
localFilter: Filter;
|
||||
}
|
||||
|
||||
const panelTitleAdd = i18n.translate('unifiedSearch.filter.filterEditor.addFilterPopupTitle', {
|
||||
defaultMessage: 'Add filter',
|
||||
});
|
||||
const panelTitleEdit = i18n.translate('unifiedSearch.filter.filterEditor.editFilterPopupTitle', {
|
||||
defaultMessage: 'Edit filter',
|
||||
});
|
||||
|
||||
const addButtonLabel = i18n.translate('unifiedSearch.filter.filterEditor.addButtonLabel', {
|
||||
defaultMessage: 'Add filter',
|
||||
});
|
||||
const updateButtonLabel = i18n.translate('unifiedSearch.filter.filterEditor.updateButtonLabel', {
|
||||
defaultMessage: 'Update filter',
|
||||
});
|
||||
|
||||
class FilterEditorUI extends Component<FilterEditorProps, State> {
|
||||
class FilterEditorComponent extends Component<FilterEditorProps, State> {
|
||||
constructor(props: FilterEditorProps) {
|
||||
super(props);
|
||||
const dataView = this.getIndexPatternFromFilter();
|
||||
this.state = {
|
||||
selectedIndexPattern: this.getIndexPatternFromFilter(),
|
||||
selectedField: this.getFieldFromFilter(),
|
||||
selectedOperator: this.getSelectedOperator(),
|
||||
params: getFilterParams(props.filter),
|
||||
useCustomLabel: props.filter.meta.alias !== null,
|
||||
selectedDataView: dataView,
|
||||
customLabel: props.filter.meta.alias || '',
|
||||
queryDsl: JSON.stringify(cleanFilter(props.filter), null, 2),
|
||||
queryDsl: this.parseFilterToQueryDsl(props.filter),
|
||||
isCustomEditorOpen: this.isUnknownFilterType(),
|
||||
localFilter: dataView ? merge({}, props.filter) : buildEmptyFilter(false),
|
||||
};
|
||||
}
|
||||
|
||||
private parseFilterToQueryDsl(filter: Filter) {
|
||||
return JSON.stringify(cleanFilter(filter), null, 2);
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { localFilter } = this.state;
|
||||
const shouldDisableToggle = isCombinedFilter(localFilter);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<EuiPopoverTitle paddingSize="s">
|
||||
<EuiFlexGroup alignItems="baseline" responsive={false}>
|
||||
<EuiFlexItem>{this.props.mode === 'add' ? panelTitleAdd : panelTitleEdit}</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
{this.props.mode === 'add' ? strings.getPanelTitleAdd() : strings.getPanelTitleEdit()}
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false} className="filterEditor__hiddenItem" />
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty
|
||||
size="xs"
|
||||
data-test-subj="editQueryDSL"
|
||||
onClick={this.toggleCustomEditor}
|
||||
<EuiToolTip
|
||||
position="top"
|
||||
content={shouldDisableToggle ? strings.getDisableToggleModeTooltip() : null}
|
||||
display="block"
|
||||
>
|
||||
{this.state.isCustomEditorOpen ? (
|
||||
<FormattedMessage
|
||||
id="unifiedSearch.filter.filterEditor.editFilterValuesButtonLabel"
|
||||
defaultMessage="Edit filter values"
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id="unifiedSearch.filter.filterEditor.editQueryDslButtonLabel"
|
||||
defaultMessage="Edit as Query DSL"
|
||||
/>
|
||||
)}
|
||||
</EuiButtonEmpty>
|
||||
<EuiButtonEmpty
|
||||
size="xs"
|
||||
data-test-subj="editQueryDSL"
|
||||
disabled={shouldDisableToggle}
|
||||
onClick={this.toggleCustomEditor}
|
||||
>
|
||||
{this.state.isCustomEditorOpen ? (
|
||||
<FormattedMessage
|
||||
id="unifiedSearch.filter.filterEditor.editFilterValuesButtonLabel"
|
||||
defaultMessage="Edit filter values"
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id="unifiedSearch.filter.filterEditor.editQueryDslButtonLabel"
|
||||
defaultMessage="Edit as Query DSL"
|
||||
/>
|
||||
)}
|
||||
</EuiButtonEmpty>
|
||||
</EuiToolTip>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiPopoverTitle>
|
||||
|
@ -133,39 +189,19 @@ class FilterEditorUI extends Component<FilterEditorProps, State> {
|
|||
<div className="globalFilterItem__editorForm">
|
||||
{this.renderIndexPatternInput()}
|
||||
|
||||
{this.state.isCustomEditorOpen ? this.renderCustomEditor() : this.renderRegularEditor()}
|
||||
{this.state.isCustomEditorOpen
|
||||
? this.renderCustomEditor()
|
||||
: this.renderFiltersBuilderEditor()}
|
||||
|
||||
<EuiSpacer size="m" />
|
||||
|
||||
<EuiSwitch
|
||||
id="filterEditorCustomLabelSwitch"
|
||||
data-test-subj="createCustomLabel"
|
||||
label={this.props.intl.formatMessage({
|
||||
id: 'unifiedSearch.filter.filterEditor.createCustomLabelSwitchLabel',
|
||||
defaultMessage: 'Create custom label?',
|
||||
})}
|
||||
checked={this.state.useCustomLabel}
|
||||
onChange={this.onCustomLabelSwitchChange}
|
||||
/>
|
||||
|
||||
{this.state.useCustomLabel && (
|
||||
<div>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiFormRow
|
||||
label={this.props.intl.formatMessage({
|
||||
id: 'unifiedSearch.filter.filterEditor.createCustomLabelInputLabel',
|
||||
defaultMessage: 'Custom label',
|
||||
})}
|
||||
fullWidth
|
||||
>
|
||||
<EuiFieldText
|
||||
value={`${this.state.customLabel}`}
|
||||
onChange={this.onCustomLabelChange}
|
||||
fullWidth
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</div>
|
||||
)}
|
||||
<EuiSpacer size="l" />
|
||||
<EuiFormRow label={strings.getCustomLabel()} fullWidth>
|
||||
<EuiFieldText
|
||||
value={`${this.state.customLabel}`}
|
||||
onChange={this.onCustomLabelChange}
|
||||
placeholder={strings.getAddCustomLabel()}
|
||||
fullWidth
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</div>
|
||||
|
||||
<EuiPopoverFooter paddingSize="s">
|
||||
|
@ -183,7 +219,9 @@ class FilterEditorUI extends Component<FilterEditorProps, State> {
|
|||
isDisabled={!this.isFilterValid()}
|
||||
data-test-subj="saveFilter"
|
||||
>
|
||||
{this.props.mode === 'add' ? addButtonLabel : updateButtonLabel}
|
||||
{this.props.mode === 'add'
|
||||
? strings.getAddButtonLabel()
|
||||
: strings.getUpdateButtonLabel()}
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
|
@ -220,24 +258,15 @@ class FilterEditorUI extends Component<FilterEditorProps, State> {
|
|||
|
||||
return '';
|
||||
}
|
||||
const { selectedIndexPattern } = this.state;
|
||||
const { selectedDataView } = this.state;
|
||||
return (
|
||||
<>
|
||||
<EuiFormRow
|
||||
fullWidth
|
||||
label={this.props.intl.formatMessage({
|
||||
id: 'unifiedSearch.filter.filterEditor.dateViewSelectLabel',
|
||||
defaultMessage: 'Data view',
|
||||
})}
|
||||
>
|
||||
<IndexPatternComboBox
|
||||
<EuiFormRow fullWidth label={strings.getDataView()}>
|
||||
<GenericComboBox
|
||||
fullWidth
|
||||
placeholder={this.props.intl.formatMessage({
|
||||
id: 'unifiedSearch.filter.filterBar.indexPatternSelectPlaceholder',
|
||||
defaultMessage: 'Select a data view',
|
||||
})}
|
||||
placeholder={strings.getSelectDataView()}
|
||||
options={this.props.indexPatterns}
|
||||
selectedOptions={selectedIndexPattern ? [selectedIndexPattern] : []}
|
||||
selectedOptions={selectedDataView ? [selectedDataView] : []}
|
||||
getLabel={(indexPattern) => indexPattern.getName()}
|
||||
onChange={this.onIndexPatternChange}
|
||||
singleSelection={{ asPlainText: true }}
|
||||
|
@ -250,98 +279,77 @@ class FilterEditorUI extends Component<FilterEditorProps, State> {
|
|||
);
|
||||
}
|
||||
|
||||
private renderRegularEditor() {
|
||||
return (
|
||||
<div>
|
||||
<EuiFlexGroup responsive={true} gutterSize="s">
|
||||
<EuiFlexItem grow={2}>{this.renderFieldInput()}</EuiFlexItem>
|
||||
<EuiFlexItem grow={false} style={{ flexBasis: 160 }}>
|
||||
{this.renderOperatorInput()}
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size="s" />
|
||||
<div data-test-subj="filterParams">{this.renderParamsEditor()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
private renderFiltersBuilderEditor() {
|
||||
const { selectedDataView, localFilter } = this.state;
|
||||
const flattenedFilters = flattenFilters([localFilter]);
|
||||
|
||||
private renderFieldInput() {
|
||||
const { selectedIndexPattern, selectedField } = this.state;
|
||||
const fields = selectedIndexPattern ? getFilterableFields(selectedIndexPattern) : [];
|
||||
const shouldShowPreview =
|
||||
selectedDataView &&
|
||||
(flattenedFilters.length > 1 ||
|
||||
(flattenedFilters.length === 1 &&
|
||||
isFilterValid(
|
||||
selectedDataView,
|
||||
getFieldFromFilter(flattenedFilters[0], selectedDataView),
|
||||
getOperatorFromFilter(flattenedFilters[0]),
|
||||
getFilterParams(flattenedFilters[0])
|
||||
)));
|
||||
|
||||
return (
|
||||
<EuiFormRow
|
||||
fullWidth
|
||||
label={this.props.intl.formatMessage({
|
||||
id: 'unifiedSearch.filter.filterEditor.fieldSelectLabel',
|
||||
defaultMessage: 'Field',
|
||||
})}
|
||||
>
|
||||
<FieldComboBox
|
||||
fullWidth
|
||||
id="fieldInput"
|
||||
isDisabled={!selectedIndexPattern}
|
||||
placeholder={this.props.intl.formatMessage({
|
||||
id: 'unifiedSearch.filter.filterEditor.fieldSelectPlaceholder',
|
||||
defaultMessage: 'Select a field first',
|
||||
})}
|
||||
options={fields}
|
||||
selectedOptions={selectedField ? [selectedField] : []}
|
||||
getLabel={(field) => field.customLabel || field.name}
|
||||
onChange={this.onFieldChange}
|
||||
singleSelection={{ asPlainText: true }}
|
||||
isClearable={false}
|
||||
data-test-subj="filterFieldSuggestionList"
|
||||
/>
|
||||
</EuiFormRow>
|
||||
);
|
||||
}
|
||||
<>
|
||||
<div
|
||||
role="region"
|
||||
aria-label=""
|
||||
className={cx(filtersBuilderMaxHeightCss(this.props.theme.euiTheme), 'eui-yScroll')}
|
||||
>
|
||||
<EuiToolTip
|
||||
position="top"
|
||||
content={selectedDataView ? '' : strings.getSelectDataViewToolTip()}
|
||||
display="block"
|
||||
>
|
||||
<FiltersBuilder
|
||||
filters={[localFilter]}
|
||||
timeRangeForSuggestionsOverride={this.props.timeRangeForSuggestionsOverride}
|
||||
dataView={selectedDataView!}
|
||||
onChange={this.onLocalFilterChange}
|
||||
disabled={!selectedDataView}
|
||||
/>
|
||||
</EuiToolTip>
|
||||
</div>
|
||||
|
||||
private renderOperatorInput() {
|
||||
const { selectedField, selectedOperator } = this.state;
|
||||
const operators = selectedField ? getOperatorOptions(selectedField) : [];
|
||||
return (
|
||||
<EuiFormRow
|
||||
fullWidth
|
||||
label={this.props.intl.formatMessage({
|
||||
id: 'unifiedSearch.filter.filterEditor.operatorSelectLabel',
|
||||
defaultMessage: 'Operator',
|
||||
})}
|
||||
>
|
||||
<OperatorComboBox
|
||||
fullWidth
|
||||
isDisabled={!selectedField}
|
||||
placeholder={
|
||||
selectedField
|
||||
? this.props.intl.formatMessage({
|
||||
id: 'unifiedSearch.filter.filterEditor.operatorSelectPlaceholderSelect',
|
||||
defaultMessage: 'Select',
|
||||
})
|
||||
: this.props.intl.formatMessage({
|
||||
id: 'unifiedSearch.filter.filterEditor.operatorSelectPlaceholderWaiting',
|
||||
defaultMessage: 'Waiting',
|
||||
})
|
||||
}
|
||||
options={operators}
|
||||
selectedOptions={selectedOperator ? [selectedOperator] : []}
|
||||
getLabel={({ message }) => message}
|
||||
onChange={this.onOperatorChange}
|
||||
singleSelection={{ asPlainText: true }}
|
||||
isClearable={false}
|
||||
data-test-subj="filterOperatorList"
|
||||
/>
|
||||
</EuiFormRow>
|
||||
{shouldShowPreview ? (
|
||||
<EuiFormRow
|
||||
fullWidth
|
||||
hasEmptyLabelSpace={true}
|
||||
className={cx(filterBadgeStyle, filterPreviewLabelStyle)}
|
||||
label={
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
id="unifiedSearch.filter.filterBar.preview"
|
||||
defaultMessage="{icon} Preview"
|
||||
values={{
|
||||
icon: <EuiIcon type="inspect" size="s" />,
|
||||
}}
|
||||
/>
|
||||
</strong>
|
||||
}
|
||||
>
|
||||
<EuiText size="xs" data-test-subj="filter-preview">
|
||||
<FilterBadgeGroup
|
||||
filters={[localFilter]}
|
||||
dataViews={this.props.indexPatterns}
|
||||
booleanRelation={BooleanRelation.AND}
|
||||
shouldShowBrackets={false}
|
||||
/>
|
||||
</EuiText>
|
||||
</EuiFormRow>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
private renderCustomEditor() {
|
||||
return (
|
||||
<EuiFormRow
|
||||
fullWidth
|
||||
label={i18n.translate('unifiedSearch.filter.filterEditor.queryDslLabel', {
|
||||
defaultMessage: 'Elasticsearch Query DSL',
|
||||
})}
|
||||
>
|
||||
<EuiFormRow fullWidth label={strings.getQueryDslLabel()}>
|
||||
<CodeEditor
|
||||
languageId={XJsonLang.ID}
|
||||
width="100%"
|
||||
|
@ -349,82 +357,12 @@ class FilterEditorUI extends Component<FilterEditorProps, State> {
|
|||
value={this.state.queryDsl}
|
||||
onChange={this.onQueryDslChange}
|
||||
data-test-subj="customEditorInput"
|
||||
aria-label={i18n.translate('unifiedSearch.filter.filterEditor.queryDslAriaLabel', {
|
||||
defaultMessage: 'Elasticsearch Query DSL editor',
|
||||
})}
|
||||
aria-label={strings.getQueryDslAriaLabel()}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
);
|
||||
}
|
||||
|
||||
private renderParamsEditor() {
|
||||
const indexPattern = this.state.selectedIndexPattern;
|
||||
if (!indexPattern || !this.state.selectedOperator || !this.state.selectedField) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(
|
||||
this.state.selectedField,
|
||||
this.state.params
|
||||
);
|
||||
|
||||
switch (this.state.selectedOperator.type) {
|
||||
case 'exists':
|
||||
return '';
|
||||
case 'phrase':
|
||||
return (
|
||||
<EuiFormRow
|
||||
fullWidth
|
||||
label={this.props.intl.formatMessage({
|
||||
id: 'unifiedSearch.filter.filterEditor.valueInputLabel',
|
||||
defaultMessage: 'Value',
|
||||
})}
|
||||
isInvalid={isInvalid}
|
||||
error={errorMessage}
|
||||
>
|
||||
<PhraseValueInput
|
||||
indexPattern={indexPattern}
|
||||
field={this.state.selectedField}
|
||||
value={this.state.params}
|
||||
onChange={this.onParamsChange}
|
||||
data-test-subj="phraseValueInput"
|
||||
timeRangeForSuggestionsOverride={this.props.timeRangeForSuggestionsOverride}
|
||||
fullWidth
|
||||
/>
|
||||
</EuiFormRow>
|
||||
);
|
||||
case 'phrases':
|
||||
return (
|
||||
<EuiFormRow
|
||||
fullWidth
|
||||
label={this.props.intl.formatMessage({
|
||||
id: 'unifiedSearch.filter.filterEditor.valuesSelectLabel',
|
||||
defaultMessage: 'Values',
|
||||
})}
|
||||
>
|
||||
<PhrasesValuesInput
|
||||
indexPattern={indexPattern}
|
||||
field={this.state.selectedField}
|
||||
values={this.state.params}
|
||||
onChange={this.onParamsChange}
|
||||
onParamsUpdate={this.onParamsUpdate}
|
||||
timeRangeForSuggestionsOverride={this.props.timeRangeForSuggestionsOverride}
|
||||
fullWidth
|
||||
/>
|
||||
</EuiFormRow>
|
||||
);
|
||||
case 'range':
|
||||
return (
|
||||
<RangeValueInput
|
||||
field={this.state.selectedField}
|
||||
value={this.state.params}
|
||||
onChange={this.onParamsChange}
|
||||
fullWidth
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private toggleCustomEditor = () => {
|
||||
const isCustomEditorOpen = !this.state.isCustomEditorOpen;
|
||||
this.setState({ isCustomEditorOpen });
|
||||
|
@ -432,31 +370,15 @@ class FilterEditorUI extends Component<FilterEditorProps, State> {
|
|||
|
||||
private isUnknownFilterType() {
|
||||
const { type } = this.props.filter.meta;
|
||||
return !!type && !['phrase', 'phrases', 'range', 'exists'].includes(type);
|
||||
return !!type && !['phrase', 'phrases', 'range', 'exists', 'combined'].includes(type);
|
||||
}
|
||||
|
||||
private getIndexPatternFromFilter() {
|
||||
return getIndexPatternFromFilter(this.props.filter, this.props.indexPatterns);
|
||||
}
|
||||
|
||||
private getFieldFromFilter() {
|
||||
const indexPattern = this.getIndexPatternFromFilter();
|
||||
return indexPattern && getFieldFromFilter(this.props.filter as FieldFilter, indexPattern);
|
||||
}
|
||||
|
||||
private getSelectedOperator() {
|
||||
return getOperatorFromFilter(this.props.filter);
|
||||
}
|
||||
|
||||
private isFilterValid() {
|
||||
const {
|
||||
isCustomEditorOpen,
|
||||
queryDsl,
|
||||
selectedIndexPattern: indexPattern,
|
||||
selectedField: field,
|
||||
selectedOperator: operator,
|
||||
params,
|
||||
} = this.state;
|
||||
const { isCustomEditorOpen, queryDsl, selectedDataView, localFilter } = this.state;
|
||||
|
||||
if (isCustomEditorOpen) {
|
||||
try {
|
||||
|
@ -467,35 +389,25 @@ class FilterEditorUI extends Component<FilterEditorProps, State> {
|
|||
}
|
||||
}
|
||||
|
||||
return isFilterValid(indexPattern, field, operator, params);
|
||||
if (!selectedDataView) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return flattenFilters([localFilter]).every((f) =>
|
||||
isFilterValid(
|
||||
selectedDataView,
|
||||
getFieldFromFilter(f, selectedDataView),
|
||||
getOperatorFromFilter(f),
|
||||
getFilterParams(f)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private onIndexPatternChange = ([selectedIndexPattern]: DataView[]) => {
|
||||
const selectedField = undefined;
|
||||
const selectedOperator = undefined;
|
||||
const params = undefined;
|
||||
this.setState({ selectedIndexPattern, selectedField, selectedOperator, params });
|
||||
};
|
||||
|
||||
private onFieldChange = ([selectedField]: DataViewField[]) => {
|
||||
const selectedOperator = undefined;
|
||||
const params = undefined;
|
||||
this.setState({ selectedField, selectedOperator, params });
|
||||
};
|
||||
|
||||
private onOperatorChange = ([selectedOperator]: Operator[]) => {
|
||||
// Only reset params when the operator type changes
|
||||
const params =
|
||||
get(this.state.selectedOperator, 'type') === get(selectedOperator, 'type')
|
||||
? this.state.params
|
||||
: undefined;
|
||||
this.setState({ selectedOperator, params });
|
||||
};
|
||||
|
||||
private onCustomLabelSwitchChange = (event: EuiSwitchEvent) => {
|
||||
const useCustomLabel = event.target.checked;
|
||||
const customLabel = event.target.checked ? '' : null;
|
||||
this.setState({ useCustomLabel, customLabel });
|
||||
private onIndexPatternChange = ([selectedDataView]: DataView[]) => {
|
||||
this.setState({
|
||||
selectedDataView,
|
||||
localFilter: buildEmptyFilter(false, selectedDataView.id),
|
||||
});
|
||||
};
|
||||
|
||||
private onCustomLabelChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
|
@ -503,68 +415,87 @@ class FilterEditorUI extends Component<FilterEditorProps, State> {
|
|||
this.setState({ customLabel });
|
||||
};
|
||||
|
||||
private onParamsChange = (params: any) => {
|
||||
this.setState({ params });
|
||||
};
|
||||
|
||||
private onParamsUpdate = (value: string) => {
|
||||
this.setState((prevState) => ({ params: [value, ...(prevState.params || [])] }));
|
||||
};
|
||||
|
||||
private onQueryDslChange = (queryDsl: string) => {
|
||||
this.setState({ queryDsl });
|
||||
};
|
||||
|
||||
private onSubmit = () => {
|
||||
private onLocalFilterChange = (updatedFilters: Filter[]) => {
|
||||
const { selectedDataView, customLabel } = this.state;
|
||||
const alias = customLabel || null;
|
||||
const {
|
||||
selectedIndexPattern: indexPattern,
|
||||
selectedField: field,
|
||||
selectedOperator: operator,
|
||||
params,
|
||||
useCustomLabel,
|
||||
customLabel,
|
||||
isCustomEditorOpen,
|
||||
queryDsl,
|
||||
} = this.state;
|
||||
$state,
|
||||
meta: { disabled = false, negate = false },
|
||||
} = this.props.filter;
|
||||
|
||||
const { $state } = this.props.filter;
|
||||
if (!$state || !$state.store) {
|
||||
return; // typescript validation
|
||||
if (!$state || !$state.store || !selectedDataView) {
|
||||
return;
|
||||
}
|
||||
const alias = useCustomLabel ? customLabel : null;
|
||||
|
||||
if (isCustomEditorOpen) {
|
||||
const { index, disabled = false, negate = false } = this.props.filter.meta;
|
||||
const newIndex = index || this.props.indexPatterns[0].id!;
|
||||
const body = JSON.parse(queryDsl);
|
||||
const filter = buildCustomFilter(newIndex, body, disabled, negate, alias, $state.store);
|
||||
this.props.onSubmit(filter);
|
||||
} else if (indexPattern && field && operator) {
|
||||
const filter = buildFilter(
|
||||
indexPattern,
|
||||
field,
|
||||
operator.type,
|
||||
operator.negate,
|
||||
this.props.filter.meta.disabled ?? false,
|
||||
params ?? '',
|
||||
let newFilter: Filter;
|
||||
|
||||
if (updatedFilters.length === 1) {
|
||||
const f = updatedFilters[0];
|
||||
newFilter = {
|
||||
...f,
|
||||
$state: {
|
||||
store: $state.store,
|
||||
},
|
||||
meta: {
|
||||
...f.meta,
|
||||
disabled,
|
||||
alias,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
newFilter = buildCombinedFilter(
|
||||
BooleanRelation.AND,
|
||||
updatedFilters,
|
||||
selectedDataView,
|
||||
disabled,
|
||||
negate,
|
||||
alias,
|
||||
$state.store
|
||||
);
|
||||
}
|
||||
|
||||
this.setState({ localFilter: newFilter });
|
||||
};
|
||||
|
||||
private onSubmit = () => {
|
||||
const { isCustomEditorOpen, queryDsl, customLabel } = this.state;
|
||||
const {
|
||||
$state,
|
||||
meta: { index, disabled = false, negate = false },
|
||||
} = this.props.filter;
|
||||
|
||||
if (!$state || !$state.store) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCustomEditorOpen) {
|
||||
const newIndex = index || this.props.indexPatterns[0].id!;
|
||||
const body = JSON.parse(queryDsl);
|
||||
const filter = buildCustomFilter(
|
||||
newIndex,
|
||||
body,
|
||||
disabled,
|
||||
negate,
|
||||
customLabel || null,
|
||||
$state.store
|
||||
);
|
||||
|
||||
this.props.onSubmit(filter);
|
||||
} else {
|
||||
const localFilter = {
|
||||
...this.state.localFilter,
|
||||
meta: {
|
||||
...this.state.localFilter.meta,
|
||||
alias: customLabel || null,
|
||||
},
|
||||
};
|
||||
this.props.onSubmit(localFilter);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function IndexPatternComboBox(props: GenericComboBoxProps<DataView>) {
|
||||
return GenericComboBox(props);
|
||||
}
|
||||
|
||||
function FieldComboBox(props: GenericComboBoxProps<DataViewField>) {
|
||||
return GenericComboBox(props);
|
||||
}
|
||||
|
||||
function OperatorComboBox(props: GenericComboBoxProps<Operator>) {
|
||||
return GenericComboBox(props);
|
||||
}
|
||||
|
||||
export const FilterEditor = injectI18n(FilterEditorUI);
|
||||
export const FilterEditor = withEuiTheme(FilterEditorComponent);
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { css } from '@emotion/css';
|
||||
import type { EuiThemeComputed } from '@elastic/eui';
|
||||
|
||||
export const genericComboBoxStyle = (euiTheme: EuiThemeComputed) => css`
|
||||
.euiComboBoxPlaceholder {
|
||||
padding-right: calc(${euiTheme.size.xs});
|
||||
}
|
||||
`;
|
|
@ -6,14 +6,20 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui';
|
||||
import { EuiComboBox, EuiComboBoxOptionOption, useEuiTheme } from '@elastic/eui';
|
||||
import React from 'react';
|
||||
import { genericComboBoxStyle } from './generic_combo_box.styles';
|
||||
|
||||
export interface GenericComboBoxProps<T> {
|
||||
options: T[];
|
||||
selectedOptions: T[];
|
||||
getLabel: (value: T) => string;
|
||||
onChange: (values: T[]) => void;
|
||||
renderOption?: (
|
||||
option: EuiComboBoxOptionOption,
|
||||
searchValue: string,
|
||||
OPTION_CONTENT_CLASSNAME: string
|
||||
) => React.ReactNode;
|
||||
[propName: string]: any;
|
||||
}
|
||||
|
||||
|
@ -25,7 +31,7 @@ export interface GenericComboBoxProps<T> {
|
|||
*/
|
||||
export function GenericComboBox<T>(props: GenericComboBoxProps<T>) {
|
||||
const { options, selectedOptions, getLabel, onChange, ...otherProps } = props;
|
||||
|
||||
const { euiTheme } = useEuiTheme();
|
||||
const labels = options.map(getLabel);
|
||||
const euiOptions: EuiComboBoxOptionOption[] = labels.map((label) => ({ label }));
|
||||
const selectedEuiOptions = selectedOptions
|
||||
|
@ -46,6 +52,7 @@ export function GenericComboBox<T>(props: GenericComboBoxProps<T>) {
|
|||
return (
|
||||
<EuiComboBox
|
||||
options={euiOptions}
|
||||
className={genericComboBoxStyle(euiTheme)}
|
||||
selectedOptions={selectedEuiOptions}
|
||||
onChange={onComboBoxChange}
|
||||
sortMatchesBy="startsWith"
|
||||
|
|
|
@ -28,7 +28,7 @@ export {
|
|||
|
||||
export type { GenericComboBoxProps } from './generic_combo_box';
|
||||
export type { PhraseSuggestorProps } from './phrase_suggestor';
|
||||
export type { PhrasesSuggestorProps } from './phrases_values_input';
|
||||
export type { PhrasesValuesInputProps } from './phrases_values_input';
|
||||
|
||||
export { GenericComboBox } from './generic_combo_box';
|
||||
export { PhraseSuggestor } from './phrase_suggestor';
|
||||
|
@ -36,6 +36,7 @@ export { PhrasesValuesInput } from './phrases_values_input';
|
|||
export { PhraseValueInput } from './phrase_value_input';
|
||||
export { RangeValueInput, isRangeParams } from './range_value_input';
|
||||
export { ValueInputType } from './value_input_type';
|
||||
export { TruncatedLabel } from './truncated_label';
|
||||
|
||||
export { FilterEditor } from './filter_editor';
|
||||
export type { FilterEditorProps } from './filter_editor';
|
||||
|
|
|
@ -7,15 +7,15 @@
|
|||
*/
|
||||
|
||||
import dateMath from '@kbn/datemath';
|
||||
import { Filter, FieldFilter } from '@kbn/es-query';
|
||||
import { Filter } from '@kbn/es-query';
|
||||
import { ES_FIELD_TYPES } from '@kbn/field-types';
|
||||
import isSemverValid from 'semver/functions/valid';
|
||||
import { isFilterable, IpAddress } from '@kbn/data-plugin/common';
|
||||
import type { DataView, DataViewField } from '@kbn/data-views-plugin/common';
|
||||
import { FILTER_OPERATORS, Operator } from './filter_operators';
|
||||
|
||||
export function getFieldFromFilter(filter: FieldFilter, indexPattern: DataView) {
|
||||
return indexPattern.fields.find((field) => field.name === filter.meta.key);
|
||||
export function getFieldFromFilter(filter: Filter, indexPattern?: DataView) {
|
||||
return indexPattern?.fields.find((field) => field.name === filter.meta.key);
|
||||
}
|
||||
|
||||
export function getOperatorFromFilter(filter: Filter) {
|
||||
|
@ -66,6 +66,7 @@ export function isFilterValid(
|
|||
if (!indexPattern || !field || !operator) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (operator.type) {
|
||||
case 'phrase':
|
||||
return validateParams(params, field);
|
||||
|
|
|
@ -11,6 +11,41 @@ import { FILTERS } from '@kbn/es-query';
|
|||
import { ES_FIELD_TYPES } from '@kbn/field-types';
|
||||
import { DataViewField } from '@kbn/data-views-plugin/common';
|
||||
|
||||
export const strings = {
|
||||
getIsOperatorOptionLabel: () =>
|
||||
i18n.translate('unifiedSearch.filter.filterEditor.isOperatorOptionLabel', {
|
||||
defaultMessage: 'is',
|
||||
}),
|
||||
getIsNotOperatorOptionLabel: () =>
|
||||
i18n.translate('unifiedSearch.filter.filterEditor.isNotOperatorOptionLabel', {
|
||||
defaultMessage: 'is not',
|
||||
}),
|
||||
getIsOneOfOperatorOptionLabel: () =>
|
||||
i18n.translate('unifiedSearch.filter.filterEditor.isOneOfOperatorOptionLabel', {
|
||||
defaultMessage: 'is one of',
|
||||
}),
|
||||
getIsNotOneOfOperatorOptionLabel: () =>
|
||||
i18n.translate('unifiedSearch.filter.filterEditor.isNotOneOfOperatorOptionLabel', {
|
||||
defaultMessage: 'is not one of',
|
||||
}),
|
||||
getIsBetweenOperatorOptionLabel: () =>
|
||||
i18n.translate('unifiedSearch.filter.filterEditor.isBetweenOperatorOptionLabel', {
|
||||
defaultMessage: 'is between',
|
||||
}),
|
||||
getIsNotBetweenOperatorOptionLabel: () =>
|
||||
i18n.translate('unifiedSearch.filter.filterEditor.isNotBetweenOperatorOptionLabel', {
|
||||
defaultMessage: 'is not between',
|
||||
}),
|
||||
getExistsOperatorOptionLabel: () =>
|
||||
i18n.translate('unifiedSearch.filter.filterEditor.existsOperatorOptionLabel', {
|
||||
defaultMessage: 'exists',
|
||||
}),
|
||||
getDoesNotExistOperatorOptionLabel: () =>
|
||||
i18n.translate('unifiedSearch.filter.filterEditor.doesNotExistOperatorOptionLabel', {
|
||||
defaultMessage: 'does not exist',
|
||||
}),
|
||||
};
|
||||
|
||||
export interface Operator {
|
||||
message: string;
|
||||
type: FILTERS;
|
||||
|
@ -29,43 +64,33 @@ export interface Operator {
|
|||
}
|
||||
|
||||
export const isOperator = {
|
||||
message: i18n.translate('unifiedSearch.filter.filterEditor.isOperatorOptionLabel', {
|
||||
defaultMessage: 'is',
|
||||
}),
|
||||
message: strings.getIsOperatorOptionLabel(),
|
||||
type: FILTERS.PHRASE,
|
||||
negate: false,
|
||||
};
|
||||
|
||||
export const isNotOperator = {
|
||||
message: i18n.translate('unifiedSearch.filter.filterEditor.isNotOperatorOptionLabel', {
|
||||
defaultMessage: 'is not',
|
||||
}),
|
||||
message: strings.getIsNotOperatorOptionLabel(),
|
||||
type: FILTERS.PHRASE,
|
||||
negate: true,
|
||||
};
|
||||
|
||||
export const isOneOfOperator = {
|
||||
message: i18n.translate('unifiedSearch.filter.filterEditor.isOneOfOperatorOptionLabel', {
|
||||
defaultMessage: 'is one of',
|
||||
}),
|
||||
message: strings.getIsOneOfOperatorOptionLabel(),
|
||||
type: FILTERS.PHRASES,
|
||||
negate: false,
|
||||
fieldTypes: ['string', 'number', 'date', 'ip', 'geo_point', 'geo_shape'],
|
||||
};
|
||||
|
||||
export const isNotOneOfOperator = {
|
||||
message: i18n.translate('unifiedSearch.filter.filterEditor.isNotOneOfOperatorOptionLabel', {
|
||||
defaultMessage: 'is not one of',
|
||||
}),
|
||||
message: strings.getIsNotOneOfOperatorOptionLabel(),
|
||||
type: FILTERS.PHRASES,
|
||||
negate: true,
|
||||
fieldTypes: ['string', 'number', 'date', 'ip', 'geo_point', 'geo_shape'],
|
||||
};
|
||||
|
||||
export const isBetweenOperator = {
|
||||
message: i18n.translate('unifiedSearch.filter.filterEditor.isBetweenOperatorOptionLabel', {
|
||||
defaultMessage: 'is between',
|
||||
}),
|
||||
message: strings.getIsBetweenOperatorOptionLabel(),
|
||||
type: FILTERS.RANGE,
|
||||
negate: false,
|
||||
field: (field: DataViewField) => {
|
||||
|
@ -79,9 +104,7 @@ export const isBetweenOperator = {
|
|||
};
|
||||
|
||||
export const isNotBetweenOperator = {
|
||||
message: i18n.translate('unifiedSearch.filter.filterEditor.isNotBetweenOperatorOptionLabel', {
|
||||
defaultMessage: 'is not between',
|
||||
}),
|
||||
message: strings.getIsNotBetweenOperatorOptionLabel(),
|
||||
type: FILTERS.RANGE,
|
||||
negate: true,
|
||||
field: (field: DataViewField) => {
|
||||
|
@ -95,17 +118,13 @@ export const isNotBetweenOperator = {
|
|||
};
|
||||
|
||||
export const existsOperator = {
|
||||
message: i18n.translate('unifiedSearch.filter.filterEditor.existsOperatorOptionLabel', {
|
||||
defaultMessage: 'exists',
|
||||
}),
|
||||
message: strings.getExistsOperatorOptionLabel(),
|
||||
type: FILTERS.EXISTS,
|
||||
negate: false,
|
||||
};
|
||||
|
||||
export const doesNotExistOperator = {
|
||||
message: i18n.translate('unifiedSearch.filter.filterEditor.doesNotExistOperatorOptionLabel', {
|
||||
defaultMessage: 'does not exist',
|
||||
}),
|
||||
message: strings.getDoesNotExistOperatorOptionLabel(),
|
||||
type: FILTERS.EXISTS,
|
||||
negate: true,
|
||||
};
|
||||
|
|
|
@ -6,12 +6,20 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import type { DataViewField } from '@kbn/data-views-plugin/common';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { KBN_FIELD_TYPES } from '@kbn/data-plugin/public';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import type { DataViewField } from '@kbn/data-views-plugin/common';
|
||||
import { KBN_FIELD_TYPES } from '@kbn/data-plugin/public';
|
||||
import { Filter, isCombinedFilter } from '@kbn/es-query';
|
||||
import { validateParams } from './filter_editor_utils';
|
||||
|
||||
export const strings = {
|
||||
getInvalidDateFormatProvidedErrorMessage: () =>
|
||||
i18n.translate('unifiedSearch.filter.filterBar.invalidDateFormatProvidedErrorMessage', {
|
||||
defaultMessage: 'Invalid date format provided',
|
||||
}),
|
||||
};
|
||||
|
||||
export const getFieldValidityAndErrorMessage = (
|
||||
field: DataViewField,
|
||||
value?: string | undefined
|
||||
|
@ -37,11 +45,21 @@ const noError = (): { isInvalid: boolean } => {
|
|||
const invalidFormatError = (): { isInvalid: boolean; errorMessage?: string } => {
|
||||
return {
|
||||
isInvalid: true,
|
||||
errorMessage: i18n.translate(
|
||||
'unifiedSearch.filter.filterBar.invalidDateFormatProvidedErrorMessage',
|
||||
{
|
||||
defaultMessage: 'Invalid date format provided',
|
||||
}
|
||||
),
|
||||
errorMessage: strings.getInvalidDateFormatProvidedErrorMessage(),
|
||||
};
|
||||
};
|
||||
|
||||
export const flattenFilters = (filter: Filter[]) => {
|
||||
const returnArray: Filter[] = [];
|
||||
const flattenFilterRecursively = (f: Filter) => {
|
||||
if (isCombinedFilter(f)) {
|
||||
f.meta.params.forEach(flattenFilterRecursively);
|
||||
} else if (f) {
|
||||
returnArray.push(f);
|
||||
}
|
||||
};
|
||||
|
||||
filter.forEach(flattenFilterRecursively);
|
||||
|
||||
return returnArray;
|
||||
};
|
||||
|
|
|
@ -10,9 +10,11 @@ import { InjectedIntl, injectI18n } from '@kbn/i18n-react';
|
|||
import { uniq } from 'lodash';
|
||||
import React from 'react';
|
||||
import { withKibana } from '@kbn/kibana-react-plugin/public';
|
||||
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
|
||||
import { GenericComboBox, GenericComboBoxProps } from './generic_combo_box';
|
||||
import { PhraseSuggestorUI, PhraseSuggestorProps } from './phrase_suggestor';
|
||||
import { ValueInputType } from './value_input_type';
|
||||
import { TruncatedLabel } from './truncated_label';
|
||||
|
||||
interface PhraseValueInputProps extends PhraseSuggestorProps {
|
||||
value?: string;
|
||||
|
@ -21,10 +23,21 @@ interface PhraseValueInputProps extends PhraseSuggestorProps {
|
|||
fullWidth?: boolean;
|
||||
compressed?: boolean;
|
||||
disabled?: boolean;
|
||||
isInvalid?: boolean;
|
||||
invalid?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_COMBOBOX_WIDTH = 250;
|
||||
const COMBOBOX_PADDINGS = 10;
|
||||
const DEFAULT_FONT = '14px Inter';
|
||||
|
||||
class PhraseValueInputUI extends PhraseSuggestorUI<PhraseValueInputProps> {
|
||||
comboBoxRef: React.RefObject<HTMLInputElement>;
|
||||
|
||||
constructor(props: PhraseValueInputProps) {
|
||||
super(props);
|
||||
this.comboBoxRef = React.createRef();
|
||||
}
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<>
|
||||
|
@ -42,7 +55,7 @@ class PhraseValueInputUI extends PhraseSuggestorUI<PhraseValueInputProps> {
|
|||
value={this.props.value}
|
||||
onChange={this.props.onChange}
|
||||
field={this.props.field}
|
||||
isInvalid={this.props.isInvalid}
|
||||
isInvalid={this.props.invalid}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
@ -56,24 +69,40 @@ class PhraseValueInputUI extends PhraseSuggestorUI<PhraseValueInputProps> {
|
|||
const valueAsStr = String(value);
|
||||
const options = value ? uniq([valueAsStr, ...suggestions]) : suggestions;
|
||||
return (
|
||||
<StringComboBox
|
||||
isDisabled={this.props.disabled}
|
||||
fullWidth={fullWidth}
|
||||
compressed={this.props.compressed}
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'unifiedSearch.filter.filterEditor.valueSelectPlaceholder',
|
||||
defaultMessage: 'Select a value',
|
||||
})}
|
||||
options={options}
|
||||
getLabel={(option) => option}
|
||||
selectedOptions={value ? [valueAsStr] : []}
|
||||
onChange={([newValue = '']) => onChange(newValue)}
|
||||
onSearchChange={this.onSearchChange}
|
||||
singleSelection={{ asPlainText: true }}
|
||||
onCreateOption={onChange}
|
||||
isClearable={false}
|
||||
data-test-subj="filterParamsComboBox phraseParamsComboxBox"
|
||||
/>
|
||||
<div ref={this.comboBoxRef}>
|
||||
<StringComboBox
|
||||
isDisabled={this.props.disabled}
|
||||
fullWidth={fullWidth}
|
||||
compressed={this.props.compressed}
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'unifiedSearch.filter.filterEditor.valueSelectPlaceholder',
|
||||
defaultMessage: 'Select a value',
|
||||
})}
|
||||
options={options}
|
||||
getLabel={(option) => option}
|
||||
selectedOptions={value ? [valueAsStr] : []}
|
||||
onChange={([newValue = '']) => onChange(newValue)}
|
||||
onSearchChange={this.onSearchChange}
|
||||
singleSelection={{ asPlainText: true }}
|
||||
onCreateOption={onChange}
|
||||
isClearable={false}
|
||||
data-test-subj="filterParamsComboBox phraseParamsComboxBox"
|
||||
renderOption={(option, searchValue) => (
|
||||
<EuiFlexGroup gutterSize="s" alignItems="center" responsive={false}>
|
||||
<EuiFlexItem>
|
||||
<TruncatedLabel
|
||||
defaultComboboxWidth={DEFAULT_COMBOBOX_WIDTH}
|
||||
defaultFont={DEFAULT_FONT}
|
||||
comboboxPaddings={COMBOBOX_PADDINGS}
|
||||
comboBoxRef={this.comboBoxRef}
|
||||
label={option.label}
|
||||
search={searchValue}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,43 +10,74 @@ import { InjectedIntl, injectI18n } from '@kbn/i18n-react';
|
|||
import { uniq } from 'lodash';
|
||||
import React from 'react';
|
||||
import { withKibana } from '@kbn/kibana-react-plugin/public';
|
||||
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
|
||||
import { GenericComboBox, GenericComboBoxProps } from './generic_combo_box';
|
||||
import { PhraseSuggestorUI, PhraseSuggestorProps } from './phrase_suggestor';
|
||||
import { TruncatedLabel } from './truncated_label';
|
||||
|
||||
export interface PhrasesSuggestorProps extends PhraseSuggestorProps {
|
||||
export interface PhrasesValuesInputProps extends PhraseSuggestorProps {
|
||||
values?: string[];
|
||||
onChange: (values: string[]) => void;
|
||||
onParamsUpdate: (value: string) => void;
|
||||
intl: InjectedIntl;
|
||||
fullWidth?: boolean;
|
||||
compressed?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
class PhrasesValuesInputUI extends PhraseSuggestorUI<PhrasesSuggestorProps> {
|
||||
const DEFAULT_COMBOBOX_WIDTH = 250;
|
||||
const COMBOBOX_PADDINGS = 20;
|
||||
const DEFAULT_FONT = '14px Inter';
|
||||
|
||||
class PhrasesValuesInputUI extends PhraseSuggestorUI<PhrasesValuesInputProps> {
|
||||
comboBoxRef: React.RefObject<HTMLInputElement>;
|
||||
|
||||
constructor(props: PhrasesValuesInputProps) {
|
||||
super(props);
|
||||
this.comboBoxRef = React.createRef();
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { suggestions } = this.state;
|
||||
const { values, intl, onChange, fullWidth, onParamsUpdate, compressed } = this.props;
|
||||
const { values, intl, onChange, fullWidth, onParamsUpdate, compressed, disabled } = this.props;
|
||||
const options = values ? uniq([...values, ...suggestions]) : suggestions;
|
||||
return (
|
||||
<StringComboBox
|
||||
fullWidth={fullWidth}
|
||||
compressed={compressed}
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'unifiedSearch.filter.filterEditor.valuesSelectPlaceholder',
|
||||
defaultMessage: 'Select values',
|
||||
})}
|
||||
delimiter=","
|
||||
options={options}
|
||||
getLabel={(option) => option}
|
||||
selectedOptions={values || []}
|
||||
onSearchChange={this.onSearchChange}
|
||||
onCreateOption={(option: string) => {
|
||||
onParamsUpdate(option.trim());
|
||||
}}
|
||||
onChange={onChange}
|
||||
isClearable={false}
|
||||
data-test-subj="filterParamsComboBox phrasesParamsComboxBox"
|
||||
/>
|
||||
<div ref={this.comboBoxRef}>
|
||||
<StringComboBox
|
||||
fullWidth={fullWidth}
|
||||
compressed={compressed}
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'unifiedSearch.filter.filterEditor.valuesSelectPlaceholder',
|
||||
defaultMessage: 'Select values',
|
||||
})}
|
||||
delimiter=","
|
||||
options={options}
|
||||
getLabel={(option) => option}
|
||||
selectedOptions={values || []}
|
||||
onSearchChange={this.onSearchChange}
|
||||
onCreateOption={(option: string) => {
|
||||
onParamsUpdate(option.trim());
|
||||
}}
|
||||
onChange={onChange}
|
||||
isClearable={false}
|
||||
data-test-subj="filterParamsComboBox phrasesParamsComboxBox"
|
||||
isDisabled={disabled}
|
||||
renderOption={(option, searchValue) => (
|
||||
<EuiFlexGroup gutterSize="s" alignItems="center" responsive={false}>
|
||||
<EuiFlexItem>
|
||||
<TruncatedLabel
|
||||
defaultComboboxWidth={DEFAULT_COMBOBOX_WIDTH}
|
||||
defaultFont={DEFAULT_FONT}
|
||||
comboboxPaddings={COMBOBOX_PADDINGS}
|
||||
comboBoxRef={this.comboBoxRef}
|
||||
label={option.label}
|
||||
search={searchValue}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -29,6 +29,7 @@ interface Props {
|
|||
intl: InjectedIntl;
|
||||
fullWidth?: boolean;
|
||||
compressed?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function isRangeParams(params: any): params is RangeParams {
|
||||
|
@ -66,6 +67,7 @@ function RangeValueInputUI(props: Props) {
|
|||
return (
|
||||
<div>
|
||||
<EuiFormControlLayoutDelimited
|
||||
compressed={props.compressed}
|
||||
fullWidth={props.fullWidth}
|
||||
aria-label={props.intl.formatMessage({
|
||||
id: 'unifiedSearch.filter.filterEditor.rangeInputLabel',
|
||||
|
@ -83,8 +85,10 @@ function RangeValueInputUI(props: Props) {
|
|||
}}
|
||||
placeholder={props.intl.formatMessage({
|
||||
id: 'unifiedSearch.filter.filterEditor.rangeStartInputPlaceholder',
|
||||
defaultMessage: 'Start of the range',
|
||||
defaultMessage: 'Start',
|
||||
})}
|
||||
disabled={props.disabled}
|
||||
dataTestSubj="range-start"
|
||||
/>
|
||||
}
|
||||
endControl={
|
||||
|
@ -99,8 +103,10 @@ function RangeValueInputUI(props: Props) {
|
|||
}}
|
||||
placeholder={props.intl.formatMessage({
|
||||
id: 'unifiedSearch.filter.filterEditor.rangeEndInputPlaceholder',
|
||||
defaultMessage: 'End of the range',
|
||||
defaultMessage: 'End',
|
||||
})}
|
||||
disabled={props.disabled}
|
||||
dataTestSubj="range-end"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
|
|
@ -0,0 +1,81 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
import { TruncatedLabel } from './truncated_label';
|
||||
|
||||
describe('truncated_label', () => {
|
||||
const defaultProps = {
|
||||
defaultFont: '14px Inter',
|
||||
// jest-canvas-mock mocks measureText as the number of string characters, thats why the width is so low
|
||||
width: 30,
|
||||
defaultComboboxWidth: 130,
|
||||
comboboxPaddings: 100,
|
||||
comboBoxRef: React.createRef<HTMLInputElement>(),
|
||||
search: '',
|
||||
label: 'example_field',
|
||||
};
|
||||
it('displays passed label if shorter than passed labelLength', () => {
|
||||
const wrapper = mount(<TruncatedLabel {...defaultProps} />);
|
||||
expect(wrapper.text()).toEqual('example_field');
|
||||
});
|
||||
it('middle truncates label', () => {
|
||||
const wrapper = mount(
|
||||
<TruncatedLabel {...defaultProps} label="example_space.example_field.subcategory.subfield" />
|
||||
);
|
||||
expect(wrapper.text()).toEqual('example_….subcategory.subfield');
|
||||
});
|
||||
describe('with search value passed', () => {
|
||||
it('constructs truncated label when searching for the string of index = 0', () => {
|
||||
const wrapper = mount(
|
||||
<TruncatedLabel
|
||||
{...defaultProps}
|
||||
search="example_space"
|
||||
label="example_space.example_field.subcategory.subfield"
|
||||
/>
|
||||
);
|
||||
expect(wrapper.text()).toEqual('example_space.example_field.s…');
|
||||
expect(wrapper.find('mark').text()).toEqual('example_space');
|
||||
});
|
||||
it('constructs truncated label when searching for the string in the middle', () => {
|
||||
const wrapper = mount(
|
||||
<TruncatedLabel
|
||||
{...defaultProps}
|
||||
search={'ample_field'}
|
||||
label="example_space.example_field.subcategory.subfield"
|
||||
/>
|
||||
);
|
||||
expect(wrapper.text()).toEqual('…ample_field.subcategory.subf…');
|
||||
expect(wrapper.find('mark').text()).toEqual('ample_field');
|
||||
});
|
||||
it('constructs truncated label when searching for the string at the end of the label', () => {
|
||||
const wrapper = mount(
|
||||
<TruncatedLabel
|
||||
{...defaultProps}
|
||||
search={'subf'}
|
||||
label="example_space.example_field.subcategory.subfield"
|
||||
/>
|
||||
);
|
||||
expect(wrapper.text()).toEqual('…le_field.subcategory.subfield');
|
||||
expect(wrapper.find('mark').text()).toEqual('subf');
|
||||
});
|
||||
|
||||
it('constructs truncated label when searching for the string longer than the truncated width and highlights the whole content', () => {
|
||||
const wrapper = mount(
|
||||
<TruncatedLabel
|
||||
{...defaultProps}
|
||||
search={'ample_space.example_field.subcategory.subfie'}
|
||||
label="example_space.example_field.subcategory.subfield"
|
||||
/>
|
||||
);
|
||||
expect(wrapper.text()).toEqual('…ample_space.example_field.su…');
|
||||
expect(wrapper.find('mark').text()).toEqual('…ample_space.example_field.su…');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,142 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React, { RefObject, useMemo } from 'react';
|
||||
import useEffectOnce from 'react-use/lib/useEffectOnce';
|
||||
import { EuiMark } from '@elastic/eui';
|
||||
import { EuiHighlight } from '@elastic/eui';
|
||||
import { throttle } from 'lodash';
|
||||
|
||||
interface TruncatedLabelProps {
|
||||
label: string;
|
||||
search: string;
|
||||
comboBoxRef: RefObject<HTMLInputElement>;
|
||||
defaultFont: string;
|
||||
defaultComboboxWidth: number;
|
||||
comboboxPaddings: number;
|
||||
}
|
||||
|
||||
const createContext = () =>
|
||||
document.createElement('canvas').getContext('2d') as CanvasRenderingContext2D;
|
||||
|
||||
// extracted from getTextWidth for performance
|
||||
const context = createContext();
|
||||
|
||||
const getTextWidth = (text: string, font: string) => {
|
||||
const ctx = context ?? createContext();
|
||||
ctx.font = font;
|
||||
const metrics = ctx.measureText(text);
|
||||
return metrics.width;
|
||||
};
|
||||
|
||||
const truncateLabel = (
|
||||
width: number,
|
||||
font: string,
|
||||
label: string,
|
||||
approximateLength: number,
|
||||
labelFn: (label: string, length: number) => string
|
||||
) => {
|
||||
let output = labelFn(label, approximateLength);
|
||||
|
||||
while (getTextWidth(output, font) > width) {
|
||||
approximateLength = approximateLength - 1;
|
||||
const newOutput = labelFn(label, approximateLength);
|
||||
if (newOutput === output) {
|
||||
break;
|
||||
}
|
||||
output = newOutput;
|
||||
}
|
||||
return output;
|
||||
};
|
||||
|
||||
export const TruncatedLabel = React.memo(function TruncatedLabel({
|
||||
label,
|
||||
comboBoxRef,
|
||||
search,
|
||||
defaultFont,
|
||||
defaultComboboxWidth,
|
||||
comboboxPaddings,
|
||||
}: TruncatedLabelProps) {
|
||||
const [labelProps, setLabelProps] = React.useState<{
|
||||
width: number;
|
||||
font: string;
|
||||
}>({
|
||||
width: defaultComboboxWidth - comboboxPaddings,
|
||||
font: defaultFont,
|
||||
});
|
||||
|
||||
const computeStyles = (_e: UIEvent | undefined, shouldRecomputeAll = false) => {
|
||||
if (comboBoxRef.current) {
|
||||
const current = {
|
||||
...labelProps,
|
||||
width: comboBoxRef.current?.clientWidth - comboboxPaddings,
|
||||
};
|
||||
if (shouldRecomputeAll) {
|
||||
current.font = window.getComputedStyle(comboBoxRef.current).font;
|
||||
}
|
||||
setLabelProps(current);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResize = throttle((_e: UIEvent | undefined, shouldRecomputeAll = false) => {
|
||||
computeStyles(_e, shouldRecomputeAll);
|
||||
}, 50);
|
||||
|
||||
useEffectOnce(() => {
|
||||
if (comboBoxRef.current) {
|
||||
handleResize(undefined, true);
|
||||
}
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
});
|
||||
|
||||
const textWidth = useMemo(() => getTextWidth(label, labelProps.font), [label, labelProps.font]);
|
||||
|
||||
if (textWidth < labelProps.width) {
|
||||
return <EuiHighlight search={search}>{label}</EuiHighlight>;
|
||||
}
|
||||
|
||||
const searchPosition = label.indexOf(search);
|
||||
const approximateLen = Math.round((labelProps.width * label.length) / textWidth);
|
||||
const separator = `…`;
|
||||
let separatorsLength = separator.length;
|
||||
let labelFn;
|
||||
|
||||
if (!search || searchPosition === -1) {
|
||||
labelFn = (text: string, length: number) =>
|
||||
`${text.substr(0, 8)}${separator}${text.substr(text.length - (length - 8))}`;
|
||||
} else if (searchPosition === 0) {
|
||||
// search phrase at the beginning
|
||||
labelFn = (text: string, length: number) => `${text.substr(0, length)}${separator}`;
|
||||
} else if (approximateLen > label.length - searchPosition) {
|
||||
// search phrase close to the end or at the end
|
||||
labelFn = (text: string, length: number) => `${separator}${text.substr(text.length - length)}`;
|
||||
} else {
|
||||
// search phrase is in the middle
|
||||
labelFn = (text: string, length: number) =>
|
||||
`${separator}${text.substr(searchPosition, length)}${separator}`;
|
||||
separatorsLength = 2 * separator.length;
|
||||
}
|
||||
|
||||
const outputLabel = truncateLabel(
|
||||
labelProps.width,
|
||||
labelProps.font,
|
||||
label,
|
||||
approximateLen,
|
||||
labelFn
|
||||
);
|
||||
|
||||
return search.length < outputLabel.length - separatorsLength ? (
|
||||
<EuiHighlight search={search}>{outputLabel}</EuiHighlight>
|
||||
) : (
|
||||
<EuiMark>{outputLabel}</EuiMark>
|
||||
);
|
||||
});
|
|
@ -26,6 +26,7 @@ interface Props {
|
|||
isInvalid?: boolean;
|
||||
compressed?: boolean;
|
||||
disabled?: boolean;
|
||||
dataTestSubj?: string;
|
||||
}
|
||||
|
||||
class ValueInputTypeUI extends Component<Props> {
|
||||
|
@ -38,7 +39,7 @@ class ValueInputTypeUI extends Component<Props> {
|
|||
};
|
||||
|
||||
public render() {
|
||||
const value = this.props.value;
|
||||
const value = this.props.value ?? '';
|
||||
const type = this.props.field?.type ?? 'string';
|
||||
let inputElement: React.ReactNode;
|
||||
switch (type) {
|
||||
|
@ -54,6 +55,7 @@ class ValueInputTypeUI extends Component<Props> {
|
|||
isInvalid={!validateParams(value, this.props.field)}
|
||||
controlOnly={this.props.controlOnly}
|
||||
className={this.props.className}
|
||||
data-test-subj={this.props.dataTestSubj}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
@ -69,6 +71,7 @@ class ValueInputTypeUI extends Component<Props> {
|
|||
onChange={this.onChange}
|
||||
controlOnly={this.props.controlOnly}
|
||||
className={this.props.className}
|
||||
data-test-subj={this.props.dataTestSubj}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
@ -86,6 +89,7 @@ class ValueInputTypeUI extends Component<Props> {
|
|||
isInvalid={this.props.isInvalid}
|
||||
controlOnly={this.props.controlOnly}
|
||||
className={this.props.className}
|
||||
data-test-subj={this.props.dataTestSubj}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
@ -102,6 +106,7 @@ class ValueInputTypeUI extends Component<Props> {
|
|||
controlOnly={this.props.controlOnly}
|
||||
className={this.props.className}
|
||||
compressed={this.props.compressed}
|
||||
data-test-subj={this.props.dataTestSubj}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
@ -130,6 +135,7 @@ class ValueInputTypeUI extends Component<Props> {
|
|||
className={this.props.className}
|
||||
fullWidth={this.props.fullWidth}
|
||||
compressed={this.props.compressed}
|
||||
data-test-subj={this.props.dataTestSubj}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
|
|
@ -71,7 +71,7 @@
|
|||
}
|
||||
|
||||
.globalFilterItem__editorForm {
|
||||
padding: $euiSizeS;
|
||||
padding: $euiSizeM;
|
||||
}
|
||||
|
||||
.globalFilterItem__popover,
|
||||
|
|
|
@ -8,7 +8,14 @@
|
|||
|
||||
import './filter_item.scss';
|
||||
|
||||
import { EuiContextMenu, EuiContextMenuPanel, EuiPopover, EuiPopoverProps } from '@elastic/eui';
|
||||
import {
|
||||
EuiContextMenu,
|
||||
EuiContextMenuPanel,
|
||||
EuiPopover,
|
||||
EuiPopoverProps,
|
||||
euiShadowMedium,
|
||||
useEuiTheme,
|
||||
} from '@elastic/eui';
|
||||
import { InjectedIntl } from '@kbn/i18n-react';
|
||||
import {
|
||||
Filter,
|
||||
|
@ -18,15 +25,11 @@ import {
|
|||
toggleFilterDisabled,
|
||||
} from '@kbn/es-query';
|
||||
import classNames from 'classnames';
|
||||
import React, { MouseEvent, useState, useEffect, HTMLAttributes } from 'react';
|
||||
import React, { MouseEvent, useState, useEffect, HTMLAttributes, useMemo } from 'react';
|
||||
import { IUiSettingsClient } from '@kbn/core/public';
|
||||
|
||||
import { DataView } from '@kbn/data-views-plugin/public';
|
||||
import {
|
||||
getIndexPatternFromFilter,
|
||||
getDisplayValueFromFilter,
|
||||
getFieldDisplayValueFromFilter,
|
||||
} from '@kbn/data-plugin/public';
|
||||
import { css } from '@emotion/react';
|
||||
import { getIndexPatternFromFilter, getDisplayValueFromFilter } from '@kbn/data-plugin/public';
|
||||
import { FilterEditor } from '../filter_editor/filter_editor';
|
||||
import { FilterView } from '../filter_view';
|
||||
import { FilterPanelOption } from '../../types';
|
||||
|
@ -62,13 +65,28 @@ export type FilterLabelStatus =
|
|||
| typeof FILTER_ITEM_WARNING
|
||||
| typeof FILTER_ITEM_ERROR;
|
||||
|
||||
export const FILTER_EDITOR_WIDTH = 800;
|
||||
export const FILTER_EDITOR_WIDTH = 960;
|
||||
|
||||
export function FilterItem(props: FilterItemProps) {
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
|
||||
const [renderedComponent, setRenderedComponent] = useState('menu');
|
||||
const { id, filter, indexPatterns, hiddenPanelOptions, readOnly = false } = props;
|
||||
|
||||
const euiTheme = useEuiTheme();
|
||||
|
||||
/** @todo important style should be remove after fixing elastic/eui/issues/6314. */
|
||||
const popoverDragAndDropStyle = useMemo(
|
||||
() =>
|
||||
css`
|
||||
// Always needed for popover with drag & drop in them
|
||||
transform: none !important;
|
||||
transition: none !important;
|
||||
filter: none !important;
|
||||
${euiShadowMedium(euiTheme)}
|
||||
`,
|
||||
[euiTheme]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPopoverOpen) {
|
||||
setRenderedComponent('menu');
|
||||
|
@ -83,7 +101,7 @@ export function FilterItem(props: FilterItemProps) {
|
|||
}
|
||||
}
|
||||
|
||||
function handleIconClick(e: MouseEvent<HTMLInputElement>) {
|
||||
function handleIconClick() {
|
||||
props.onRemove();
|
||||
setIsPopoverOpen(false);
|
||||
}
|
||||
|
@ -134,17 +152,19 @@ export function FilterItem(props: FilterItemProps) {
|
|||
function getDataTestSubj(labelConfig: LabelOptions) {
|
||||
const dataTestSubjKey = filter.meta.key ? `filter-key-${filter.meta.key}` : '';
|
||||
const valueLabel = isValidLabel(labelConfig) ? labelConfig.title : labelConfig.status;
|
||||
const dataTestSubjValue = valueLabel ? `filter-value-${valueLabel}` : '';
|
||||
const dataTestSubjValue = valueLabel ? `filter-value-${valueLabel.replace(/\s/g, '')}` : '';
|
||||
const dataTestSubjNegated = filter.meta.negate ? 'filter-negated' : '';
|
||||
const dataTestSubjDisabled = `filter-${isDisabled(labelConfig) ? 'disabled' : 'enabled'}`;
|
||||
const dataTestSubjPinned = `filter-${isFilterPinned(filter) ? 'pinned' : 'unpinned'}`;
|
||||
const dataTestSubjId = `filter-id-${id}`;
|
||||
return classNames(
|
||||
'filter',
|
||||
dataTestSubjDisabled,
|
||||
dataTestSubjKey,
|
||||
dataTestSubjValue,
|
||||
dataTestSubjPinned,
|
||||
dataTestSubjNegated
|
||||
dataTestSubjNegated,
|
||||
dataTestSubjId
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -314,10 +334,10 @@ export function FilterItem(props: FilterItemProps) {
|
|||
filter,
|
||||
readOnly,
|
||||
valueLabel: valueLabelConfig.title,
|
||||
fieldLabel: getFieldDisplayValueFromFilter(filter, indexPatterns),
|
||||
filterLabelStatus: valueLabelConfig.status,
|
||||
errorMessage: valueLabelConfig.message,
|
||||
className: getClasses(!!filter.meta.negate, valueLabelConfig),
|
||||
dataViews: indexPatterns,
|
||||
iconOnClick: handleIconClick,
|
||||
onClick: handleBadgeClick,
|
||||
'data-test-subj': getDataTestSubj(valueLabelConfig),
|
||||
|
@ -333,6 +353,9 @@ export function FilterItem(props: FilterItemProps) {
|
|||
},
|
||||
button: <FilterView {...filterViewProps} />,
|
||||
panelPaddingSize: 'none',
|
||||
panelProps: {
|
||||
css: popoverDragAndDropStyle,
|
||||
},
|
||||
};
|
||||
|
||||
return readOnly ? (
|
||||
|
@ -344,7 +367,7 @@ export function FilterItem(props: FilterItemProps) {
|
|||
) : (
|
||||
<EuiContextMenuPanel
|
||||
items={[
|
||||
<div style={{ width: FILTER_EDITOR_WIDTH }}>
|
||||
<div style={{ width: FILTER_EDITOR_WIDTH, maxWidth: '100%' }}>
|
||||
<FilterEditor
|
||||
filter={filter}
|
||||
indexPatterns={indexPatterns}
|
||||
|
|
|
@ -1,75 +0,0 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`alias 1`] = `
|
||||
<div>
|
||||
|
||||
geo.coordinates in US
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`alias with error status 1`] = `
|
||||
<div>
|
||||
<span
|
||||
class="emotion-euiTextColor-danger"
|
||||
>
|
||||
NOT
|
||||
</span>
|
||||
geo.coordinates in US
|
||||
:
|
||||
<span
|
||||
class="globalFilterLabel__value"
|
||||
>
|
||||
Error
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`alias with warning status 1`] = `
|
||||
<div>
|
||||
<span
|
||||
class="emotion-euiTextColor-danger"
|
||||
>
|
||||
NOT
|
||||
</span>
|
||||
geo.coordinates in US
|
||||
:
|
||||
<span
|
||||
class="globalFilterLabel__value"
|
||||
>
|
||||
Warning
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`error 1`] = `
|
||||
<div>
|
||||
|
||||
machine.os
|
||||
:
|
||||
<span
|
||||
class="globalFilterLabel__value"
|
||||
>
|
||||
Error
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`field custom label 1`] = `
|
||||
<div>
|
||||
|
||||
geo.coordinates in US
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`warning 1`] = `
|
||||
<div>
|
||||
|
||||
machine.os
|
||||
:
|
||||
<span
|
||||
class="globalFilterLabel__value"
|
||||
>
|
||||
Warning
|
||||
</span>
|
||||
</div>
|
||||
`;
|
|
@ -1,97 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React, { Fragment } from 'react';
|
||||
import { EuiTextColor } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { Filter, FILTERS } from '@kbn/es-query';
|
||||
import type { FilterLabelStatus } from '../filter_item/filter_item';
|
||||
import { existsOperator, isOneOfOperator } from '../filter_editor';
|
||||
|
||||
export interface FilterLabelProps {
|
||||
filter: Filter;
|
||||
valueLabel?: string;
|
||||
fieldLabel?: string;
|
||||
filterLabelStatus?: FilterLabelStatus;
|
||||
hideAlias?: boolean;
|
||||
}
|
||||
|
||||
// Needed for React.lazy
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default function FilterLabel({
|
||||
filter,
|
||||
valueLabel,
|
||||
fieldLabel,
|
||||
filterLabelStatus,
|
||||
hideAlias,
|
||||
}: FilterLabelProps) {
|
||||
const prefixText = filter.meta.negate
|
||||
? ` ${i18n.translate('unifiedSearch.filter.filterBar.negatedFilterPrefix', {
|
||||
defaultMessage: 'NOT ',
|
||||
})}`
|
||||
: '';
|
||||
const prefix =
|
||||
filter.meta.negate && !filter.meta.disabled ? (
|
||||
<EuiTextColor color="danger">{prefixText}</EuiTextColor>
|
||||
) : (
|
||||
prefixText
|
||||
);
|
||||
|
||||
const getValue = (text?: string) => {
|
||||
return <span className="globalFilterLabel__value">{text}</span>;
|
||||
};
|
||||
|
||||
if (!hideAlias && filter.meta.alias !== null) {
|
||||
return (
|
||||
<Fragment>
|
||||
{prefix}
|
||||
{filter.meta.alias}
|
||||
{filterLabelStatus && <>: {getValue(valueLabel)}</>}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
switch (filter.meta.type) {
|
||||
case FILTERS.EXISTS:
|
||||
return (
|
||||
<Fragment>
|
||||
{prefix}
|
||||
{fieldLabel || filter.meta.key}: {getValue(`${existsOperator.message}`)}
|
||||
</Fragment>
|
||||
);
|
||||
case FILTERS.PHRASES:
|
||||
return (
|
||||
<Fragment>
|
||||
{prefix}
|
||||
{fieldLabel || filter.meta.key}: {getValue(`${isOneOfOperator.message} ${valueLabel}`)}
|
||||
</Fragment>
|
||||
);
|
||||
case FILTERS.QUERY_STRING:
|
||||
return (
|
||||
<Fragment>
|
||||
{prefix}
|
||||
{getValue(`${valueLabel}`)}
|
||||
</Fragment>
|
||||
);
|
||||
case FILTERS.PHRASE:
|
||||
case FILTERS.RANGE:
|
||||
return (
|
||||
<Fragment>
|
||||
{prefix}
|
||||
{fieldLabel || filter.meta.key}: {getValue(valueLabel)}
|
||||
</Fragment>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Fragment>
|
||||
{prefix}
|
||||
{getValue(`${JSON.stringify(filter.query) || filter.meta.value}`)}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -6,12 +6,13 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { EuiBadge, EuiBadgeProps, EuiToolTip, useInnerText } from '@elastic/eui';
|
||||
import { EuiBadgeProps, EuiToolTip, useInnerText } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import React, { FC } from 'react';
|
||||
import { Filter, isFilterPinned } from '@kbn/es-query';
|
||||
import { FilterLabel } from '..';
|
||||
import { DataView } from '@kbn/data-views-plugin/common';
|
||||
import type { FilterLabelStatus } from '../filter_item/filter_item';
|
||||
import { FilterBadge } from '../../filter_badge';
|
||||
|
||||
interface Props {
|
||||
filter: Filter;
|
||||
|
@ -22,6 +23,7 @@ interface Props {
|
|||
errorMessage?: string;
|
||||
hideAlias?: boolean;
|
||||
[propName: string]: any;
|
||||
dataViews: DataView[];
|
||||
}
|
||||
|
||||
export const FilterView: FC<Props> = ({
|
||||
|
@ -34,6 +36,7 @@ export const FilterView: FC<Props> = ({
|
|||
errorMessage,
|
||||
filterLabelStatus,
|
||||
hideAlias,
|
||||
dataViews,
|
||||
...rest
|
||||
}: Props) => {
|
||||
const [ref, innerText] = useInnerText();
|
||||
|
@ -92,15 +95,16 @@ export const FilterView: FC<Props> = ({
|
|||
};
|
||||
|
||||
const FilterPill = () => (
|
||||
<EuiBadge {...badgeProps} {...rest}>
|
||||
<FilterLabel
|
||||
filter={filter}
|
||||
valueLabel={valueLabel}
|
||||
fieldLabel={fieldLabel}
|
||||
filterLabelStatus={filterLabelStatus}
|
||||
hideAlias={hideAlias}
|
||||
/>
|
||||
</EuiBadge>
|
||||
<FilterBadge
|
||||
filter={filter}
|
||||
dataViews={dataViews}
|
||||
valueLabel={valueLabel}
|
||||
filterLabelStatus={filterLabelStatus}
|
||||
hideAlias={hideAlias}
|
||||
{...badgeProps}
|
||||
{...rest}
|
||||
data-test-subj={`filter-badge-'${innerText}' ${rest['data-test-subj']}`}
|
||||
/>
|
||||
);
|
||||
|
||||
return readOnly ? (
|
||||
|
|
|
@ -29,16 +29,6 @@ export const FilterItems = (props: React.ComponentProps<typeof LazyFilterItems>)
|
|||
</React.Suspense>
|
||||
);
|
||||
|
||||
const LazyFilterLabel = React.lazy(() => import('./filter_label/filter_label'));
|
||||
/**
|
||||
* Renders the label for a single filter pill
|
||||
*/
|
||||
export const FilterLabel = (props: React.ComponentProps<typeof LazyFilterLabel>) => (
|
||||
<React.Suspense fallback={<Fallback />}>
|
||||
<LazyFilterLabel {...props} />
|
||||
</React.Suspense>
|
||||
);
|
||||
|
||||
const LazyFilterItem = React.lazy(() => import('./filter_item/filter_item'));
|
||||
/**
|
||||
* Renders a single filter pill
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
*/
|
||||
|
||||
import type { Filter } from '@kbn/es-query';
|
||||
import { BooleanRelation } from '@kbn/es-query';
|
||||
|
||||
export const getFiltersMock = () =>
|
||||
[
|
||||
|
@ -34,6 +35,7 @@ export const getFiltersMock = () =>
|
|||
{
|
||||
meta: {
|
||||
type: 'combined',
|
||||
relation: BooleanRelation.OR,
|
||||
params: [
|
||||
{
|
||||
meta: {
|
||||
|
@ -56,50 +58,56 @@ export const getFiltersMock = () =>
|
|||
store: 'appState',
|
||||
},
|
||||
},
|
||||
[
|
||||
{
|
||||
meta: {
|
||||
index: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f',
|
||||
alias: null,
|
||||
negate: false,
|
||||
disabled: false,
|
||||
type: 'phrase',
|
||||
key: 'category.keyword',
|
||||
params: {
|
||||
query: "Men's Accessories 3",
|
||||
{
|
||||
meta: {
|
||||
type: 'combined',
|
||||
relation: BooleanRelation.AND,
|
||||
params: [
|
||||
{
|
||||
meta: {
|
||||
index: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f',
|
||||
alias: null,
|
||||
negate: false,
|
||||
disabled: false,
|
||||
type: 'phrase',
|
||||
key: 'category.keyword',
|
||||
params: {
|
||||
query: "Men's Accessories 3",
|
||||
},
|
||||
},
|
||||
query: {
|
||||
match_phrase: {
|
||||
'category.keyword': "Men's Accessories 3",
|
||||
},
|
||||
},
|
||||
$state: {
|
||||
store: 'appState',
|
||||
},
|
||||
},
|
||||
},
|
||||
query: {
|
||||
match_phrase: {
|
||||
'category.keyword': "Men's Accessories 3",
|
||||
{
|
||||
meta: {
|
||||
index: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f',
|
||||
alias: null,
|
||||
negate: false,
|
||||
disabled: false,
|
||||
type: 'phrase',
|
||||
key: 'category.keyword',
|
||||
params: {
|
||||
query: "Men's Accessories 4",
|
||||
},
|
||||
},
|
||||
query: {
|
||||
match_phrase: {
|
||||
'category.keyword': "Men's Accessories 4",
|
||||
},
|
||||
},
|
||||
$state: {
|
||||
store: 'appState',
|
||||
},
|
||||
},
|
||||
},
|
||||
$state: {
|
||||
store: 'appState',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
meta: {
|
||||
index: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f',
|
||||
alias: null,
|
||||
negate: false,
|
||||
disabled: false,
|
||||
type: 'phrase',
|
||||
key: 'category.keyword',
|
||||
params: {
|
||||
query: "Men's Accessories 4",
|
||||
},
|
||||
},
|
||||
query: {
|
||||
match_phrase: {
|
||||
'category.keyword': "Men's Accessories 4",
|
||||
},
|
||||
},
|
||||
$state: {
|
||||
store: 'appState',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
meta: {
|
||||
index: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f',
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import React, { Dispatch } from 'react';
|
||||
import type { DataView } from '@kbn/data-views-plugin/common';
|
||||
import type { FiltersBuilderActions } from './filters_builder_reducer';
|
||||
import type { FiltersBuilderActions } from './reducer';
|
||||
|
||||
interface FiltersBuilderContextType {
|
||||
dataView: DataView;
|
||||
|
@ -19,6 +19,7 @@ interface FiltersBuilderContextType {
|
|||
};
|
||||
dropTarget: string;
|
||||
timeRangeForSuggestionsOverride?: boolean;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export const FiltersBuilderContextType = React.createContext<FiltersBuilderContextType>(
|
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
export const delimiterCss = ({
|
||||
padding,
|
||||
left,
|
||||
background,
|
||||
}: {
|
||||
padding: string | null;
|
||||
left: string | null;
|
||||
background: string | null;
|
||||
}) => css`
|
||||
position: relative;
|
||||
|
||||
.filter-builder__delimiter_text {
|
||||
position: absolute;
|
||||
display: block;
|
||||
padding: 0 ${padding};
|
||||
top: 0;
|
||||
left: ${left};
|
||||
background: ${background};
|
||||
}
|
||||
`;
|
|
@ -0,0 +1,147 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React, { useContext } from 'react';
|
||||
import {
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiHorizontalRule,
|
||||
EuiPanel,
|
||||
EuiText,
|
||||
useEuiBackgroundColor,
|
||||
useEuiPaddingSize,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { type Filter, BooleanRelation } from '@kbn/es-query';
|
||||
import { cx } from '@emotion/css';
|
||||
import type { Path } from './types';
|
||||
import { getBooleanRelationType } from '../utils';
|
||||
import { FilterItem } from './filter_item';
|
||||
import { FiltersBuilderContextType } from './context';
|
||||
import { getPathInArray } from './utils';
|
||||
import { delimiterCss } from './filter_group.styles';
|
||||
|
||||
export const strings = {
|
||||
getDelimiterLabel: (booleanRelation: BooleanRelation) =>
|
||||
i18n.translate('unifiedSearch.filter.filtersBuilder.delimiterLabel', {
|
||||
defaultMessage: '{booleanRelation}',
|
||||
values: {
|
||||
booleanRelation,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
export interface FilterGroupProps {
|
||||
filters: Filter[];
|
||||
booleanRelation: BooleanRelation;
|
||||
path: Path;
|
||||
|
||||
/** @internal used for recursive rendering **/
|
||||
renderedLevel?: number;
|
||||
reverseBackground?: boolean;
|
||||
}
|
||||
|
||||
/** @internal **/
|
||||
const Delimiter = ({
|
||||
color,
|
||||
booleanRelation,
|
||||
}: {
|
||||
color: 'subdued' | 'plain';
|
||||
booleanRelation: BooleanRelation;
|
||||
}) => {
|
||||
const xsPadding = useEuiPaddingSize('xs');
|
||||
const mPadding = useEuiPaddingSize('m');
|
||||
const backgroundColor = useEuiBackgroundColor(color);
|
||||
return (
|
||||
<div
|
||||
className={delimiterCss({ padding: xsPadding, left: mPadding, background: backgroundColor })}
|
||||
>
|
||||
<EuiHorizontalRule margin="xs" />
|
||||
<EuiText size="xs" className="filter-builder__delimiter_text">
|
||||
{strings.getDelimiterLabel(booleanRelation)}
|
||||
</EuiText>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const FilterGroup = ({
|
||||
filters,
|
||||
booleanRelation,
|
||||
path,
|
||||
reverseBackground = false,
|
||||
renderedLevel = 0,
|
||||
}: FilterGroupProps) => {
|
||||
const {
|
||||
globalParams: { maxDepth, hideOr },
|
||||
} = useContext(FiltersBuilderContextType);
|
||||
|
||||
const pathInArray = getPathInArray(path);
|
||||
const isDepthReached = maxDepth <= pathInArray.length;
|
||||
const orDisabled = hideOr || (isDepthReached && booleanRelation === BooleanRelation.AND);
|
||||
const andDisabled = isDepthReached && booleanRelation === BooleanRelation.OR;
|
||||
|
||||
const removeDisabled = pathInArray.length <= 1 && filters.length === 1;
|
||||
const shouldNormalizeFirstLevel =
|
||||
!path && filters.length === 1 && getBooleanRelationType(filters[0]);
|
||||
|
||||
if (shouldNormalizeFirstLevel) {
|
||||
reverseBackground = true;
|
||||
renderedLevel -= 1;
|
||||
}
|
||||
|
||||
const color = reverseBackground ? 'plain' : 'subdued';
|
||||
|
||||
const renderedFilters = filters.map((filter, index, arrayRef) => {
|
||||
const showDelimiter = booleanRelation && index + 1 < arrayRef.length;
|
||||
return (
|
||||
<EuiFlexGroup
|
||||
direction="column"
|
||||
gutterSize={shouldNormalizeFirstLevel ? 'none' : 'xs'}
|
||||
responsive={false}
|
||||
>
|
||||
<EuiFlexItem>
|
||||
<FilterItem
|
||||
filter={filter}
|
||||
draggable={arrayRef.length !== 1}
|
||||
path={`${path}${path ? '.' : ''}${index}`}
|
||||
reverseBackground={reverseBackground}
|
||||
disableOr={orDisabled}
|
||||
disableAnd={andDisabled}
|
||||
disableRemove={removeDisabled}
|
||||
color={color}
|
||||
index={index}
|
||||
renderedLevel={renderedLevel}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
|
||||
{showDelimiter && (
|
||||
<EuiFlexItem>
|
||||
<Delimiter color={color} booleanRelation={booleanRelation} />
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
});
|
||||
|
||||
return shouldNormalizeFirstLevel ? (
|
||||
<>{renderedFilters}</>
|
||||
) : (
|
||||
<EuiPanel
|
||||
color={color}
|
||||
hasShadow={false}
|
||||
paddingSize={renderedLevel > 0 ? 'none' : 'xs'}
|
||||
hasBorder
|
||||
className={cx({
|
||||
'filter-builder__panel': true,
|
||||
'filter-builder__panel-nested': renderedLevel > 0,
|
||||
})}
|
||||
>
|
||||
{renderedFilters}
|
||||
</EuiPanel>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
export const strings = {
|
||||
getDeleteFilterGroupButtonIconLabel: () =>
|
||||
i18n.translate('unifiedSearch.filter.filtersBuilder.deleteFilterGroupButtonIcon', {
|
||||
defaultMessage: 'Delete filter group',
|
||||
}),
|
||||
getAddOrFilterGroupButtonIconLabel: () =>
|
||||
i18n.translate('unifiedSearch.filter.filtersBuilder.addOrFilterGroupButtonIcon', {
|
||||
defaultMessage: 'Add filter group with OR',
|
||||
}),
|
||||
getAddOrFilterGroupButtonLabel: () =>
|
||||
i18n.translate('unifiedSearch.filter.filtersBuilder.addOrFilterGroupButtonLabel', {
|
||||
defaultMessage: 'OR',
|
||||
}),
|
||||
getAddAndFilterGroupButtonIconLabel: () =>
|
||||
i18n.translate('unifiedSearch.filter.filtersBuilder.addAndFilterGroupButtonIcon', {
|
||||
defaultMessage: 'Add filter group with AND',
|
||||
}),
|
||||
getAddAndFilterGroupButtonLabel: () =>
|
||||
i18n.translate('unifiedSearch.filter.filtersBuilder.addAndFilterGroupButtonLabel', {
|
||||
defaultMessage: 'AND',
|
||||
}),
|
||||
getDeleteButtonDisabled: () =>
|
||||
i18n.translate('unifiedSearch.filter.filtersBuilder.deleteButtonDisabled', {
|
||||
defaultMessage: 'A minimum of one item is required.',
|
||||
}),
|
||||
getMoreActionsLabel: () =>
|
||||
i18n.translate('unifiedSearch.filter.filtersBuilder.moreActionsLabel', {
|
||||
defaultMessage: 'More actions',
|
||||
}),
|
||||
};
|
|
@ -0,0 +1,79 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React, { FC } from 'react';
|
||||
import { EuiButtonEmpty, EuiButtonIcon, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
|
||||
import { Tooltip } from '../tooltip';
|
||||
import { strings } from './action_strings';
|
||||
import { FilterItemActionsProps } from './types';
|
||||
import { actionButtonCss } from '../filter_item.styles';
|
||||
|
||||
export const FilterItemActions: FC<FilterItemActionsProps & { minimizePaddings?: boolean }> = ({
|
||||
disabled = false,
|
||||
disableRemove = false,
|
||||
hideOr = false,
|
||||
disableOr = false,
|
||||
hideAnd = false,
|
||||
disableAnd = false,
|
||||
minimizePaddings = false,
|
||||
onRemoveFilter,
|
||||
onOrButtonClick,
|
||||
onAddButtonClick,
|
||||
}) => {
|
||||
return (
|
||||
<EuiFlexGroup justifyContent="flexEnd" alignItems="flexEnd" gutterSize="xs" responsive={false}>
|
||||
<EuiFlexItem grow={false}>
|
||||
<Tooltip content={strings.getDeleteButtonDisabled()} show={disableRemove || disabled}>
|
||||
<EuiButtonIcon
|
||||
onClick={onRemoveFilter}
|
||||
iconType="trash"
|
||||
isDisabled={disableRemove || disabled}
|
||||
size="xs"
|
||||
color="danger"
|
||||
aria-label={strings.getDeleteFilterGroupButtonIconLabel()}
|
||||
{...(minimizePaddings ? { className: actionButtonCss } : {})}
|
||||
/>
|
||||
</Tooltip>
|
||||
</EuiFlexItem>
|
||||
{!hideOr && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty
|
||||
onClick={onOrButtonClick}
|
||||
isDisabled={disableOr || disabled}
|
||||
iconType="plusInCircle"
|
||||
size="xs"
|
||||
iconSize="s"
|
||||
flush="right"
|
||||
aria-label={strings.getAddOrFilterGroupButtonIconLabel()}
|
||||
data-test-subj="add-or-filter"
|
||||
{...(minimizePaddings ? { className: actionButtonCss } : {})}
|
||||
>
|
||||
{strings.getAddOrFilterGroupButtonLabel()}
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
{!hideAnd && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty
|
||||
onClick={onAddButtonClick}
|
||||
isDisabled={disableAnd || disabled}
|
||||
iconType="plusInCircle"
|
||||
size="xs"
|
||||
iconSize="s"
|
||||
flush="right"
|
||||
aria-label={strings.getAddAndFilterGroupButtonIconLabel()}
|
||||
data-test-subj="add-and-filter"
|
||||
{...(minimizePaddings ? { className: actionButtonCss } : {})}
|
||||
>
|
||||
{strings.getAddAndFilterGroupButtonLabel()}
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,10 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
export { FilterItemActions } from './actions';
|
||||
export { MinimisedFilterItemActions } from './minimised_actions';
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React, { FC, useState } from 'react';
|
||||
import { EuiButtonIcon, EuiPopover } from '@elastic/eui';
|
||||
import { strings } from './action_strings';
|
||||
import { FilterItemActionsProps } from './types';
|
||||
import { FilterItemActions } from './actions';
|
||||
|
||||
export const MinimisedFilterItemActions: FC<FilterItemActionsProps> = (props) => {
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||
|
||||
const onMoreActionsButtonClick = () => {
|
||||
setIsPopoverOpen((isOpen) => !isOpen);
|
||||
};
|
||||
|
||||
const closePopover = () => setIsPopoverOpen(false);
|
||||
|
||||
const button = (
|
||||
<EuiButtonIcon
|
||||
iconType="boxesHorizontal"
|
||||
color="text"
|
||||
aria-label={strings.getMoreActionsLabel()}
|
||||
onClick={onMoreActionsButtonClick}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiPopover ownFocus={false} button={button} isOpen={isPopoverOpen} closePopover={closePopover}>
|
||||
<FilterItemActions {...props} minimizePaddings={true} />
|
||||
</EuiPopover>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
export interface FilterItemActionsProps {
|
||||
disabled?: boolean;
|
||||
|
||||
disableRemove?: boolean;
|
||||
onRemoveFilter: () => void;
|
||||
|
||||
hideOr?: boolean;
|
||||
disableOr?: boolean;
|
||||
onOrButtonClick: () => void;
|
||||
|
||||
hideAnd?: boolean;
|
||||
disableAnd?: boolean;
|
||||
onAddButtonClick: () => void;
|
||||
}
|
|
@ -0,0 +1,111 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useContext, useRef } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FieldIcon } from '@kbn/react-field';
|
||||
import { KBN_FIELD_TYPES } from '@kbn/field-types';
|
||||
import type { DataView, DataViewField } from '@kbn/data-views-plugin/common';
|
||||
import {
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
useGeneratedHtmlId,
|
||||
EuiComboBox,
|
||||
EuiComboBoxOptionOption,
|
||||
} from '@elastic/eui';
|
||||
import { getFilterableFields } from '../../filter_bar/filter_editor';
|
||||
import { FiltersBuilderContextType } from '../context';
|
||||
import { TruncatedLabel } from '../../filter_bar/filter_editor';
|
||||
|
||||
const DEFAULT_COMBOBOX_WIDTH = 205;
|
||||
const COMBOBOX_PADDINGS = 100;
|
||||
const DEFAULT_FONT = '14px Inter';
|
||||
|
||||
export const strings = {
|
||||
getFieldSelectPlaceholderLabel: () =>
|
||||
i18n.translate('unifiedSearch.filter.filtersBuilder.fieldSelectPlaceholder', {
|
||||
defaultMessage: 'Select a field',
|
||||
}),
|
||||
};
|
||||
|
||||
interface FieldInputProps {
|
||||
dataView: DataView;
|
||||
onHandleField: (field: DataViewField) => void;
|
||||
field?: DataViewField;
|
||||
}
|
||||
|
||||
export function FieldInput({ field, dataView, onHandleField }: FieldInputProps) {
|
||||
const { disabled } = useContext(FiltersBuilderContextType);
|
||||
const fields = dataView ? getFilterableFields(dataView) : [];
|
||||
const id = useGeneratedHtmlId({ prefix: 'fieldInput' });
|
||||
const comboBoxRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const onFieldChange = useCallback(
|
||||
([selectedField]: DataViewField[]) => {
|
||||
onHandleField(selectedField);
|
||||
},
|
||||
[onHandleField]
|
||||
);
|
||||
|
||||
const getLabel = useCallback(
|
||||
(dataViewField: DataViewField) => ({
|
||||
label: dataViewField.customLabel || dataViewField.name,
|
||||
value: dataViewField.type as KBN_FIELD_TYPES,
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const optionFields = fields.map(getLabel);
|
||||
const euiOptions: Array<EuiComboBoxOptionOption<KBN_FIELD_TYPES>> = optionFields;
|
||||
const selectedEuiOptions = (field ? [field] : [])
|
||||
.filter((option) => fields.indexOf(option) !== -1)
|
||||
.map((option) => euiOptions[fields.indexOf(option)]);
|
||||
|
||||
const onComboBoxChange = (newOptions: EuiComboBoxOptionOption[]) => {
|
||||
const newValues = newOptions.map(
|
||||
({ label }) => fields[optionFields.findIndex((optionField) => optionField.label === label)]
|
||||
);
|
||||
onFieldChange(newValues);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={comboBoxRef}>
|
||||
<EuiComboBox
|
||||
id={id}
|
||||
options={euiOptions}
|
||||
selectedOptions={selectedEuiOptions}
|
||||
onChange={onComboBoxChange}
|
||||
isDisabled={disabled}
|
||||
placeholder={strings.getFieldSelectPlaceholderLabel()}
|
||||
sortMatchesBy="startsWith"
|
||||
singleSelection={{ asPlainText: true }}
|
||||
isClearable={false}
|
||||
compressed
|
||||
fullWidth
|
||||
data-test-subj="filterFieldSuggestionList"
|
||||
renderOption={(option, searchValue) => (
|
||||
<EuiFlexGroup gutterSize="s" alignItems="center" responsive={false}>
|
||||
<EuiFlexItem grow={null}>
|
||||
<FieldIcon type={option.value!} fill="none" />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<TruncatedLabel
|
||||
defaultComboboxWidth={DEFAULT_COMBOBOX_WIDTH}
|
||||
defaultFont={DEFAULT_FONT}
|
||||
comboboxPaddings={COMBOBOX_PADDINGS}
|
||||
comboBoxRef={comboBoxRef}
|
||||
label={option.label}
|
||||
search={searchValue}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { EuiThemeComputed } from '@elastic/eui';
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import add from '../assets/add.svg';
|
||||
import or from '../assets/or.svg';
|
||||
|
||||
export const cursorAddCss = css`
|
||||
cursor: url(${add}), auto;
|
||||
`;
|
||||
|
||||
export const cursorOrCss = css`
|
||||
cursor: url(${or}), auto;
|
||||
`;
|
||||
|
||||
export const fieldAndParamCss = (euiTheme: EuiThemeComputed) => css`
|
||||
min-width: calc(${euiTheme.size.xl} * 5);
|
||||
`;
|
||||
|
||||
export const operationCss = (euiTheme: EuiThemeComputed) => css`
|
||||
max-width: calc(${euiTheme.size.xl} * 4.5);
|
||||
// temporary fix to be removed after https://github.com/elastic/eui/issues/2082 is fixed
|
||||
.euiComboBox__inputWrap {
|
||||
padding-right: calc(${euiTheme.size.base}) !important;
|
||||
}
|
||||
`;
|
||||
|
||||
export const getGrabIconCss = (euiTheme: EuiThemeComputed) => css`
|
||||
margin: 0 ${euiTheme.size.xxs};
|
||||
`;
|
||||
|
||||
export const actionButtonCss = css`
|
||||
&.euiButtonEmpty .euiButtonEmpty__content {
|
||||
padding: 0 4px;
|
||||
}
|
||||
`;
|
||||
|
||||
export const disabledDraggableCss = css`
|
||||
&.euiDraggable .euiDraggable__item.euiDraggable__item--isDisabled {
|
||||
cursor: unset;
|
||||
}
|
||||
`;
|
|
@ -0,0 +1,320 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useContext } from 'react';
|
||||
import {
|
||||
EuiDraggable,
|
||||
EuiDroppable,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiFormRow,
|
||||
EuiIcon,
|
||||
EuiPanel,
|
||||
useEuiTheme,
|
||||
useIsWithinBreakpoints,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import type { Filter } from '@kbn/es-query';
|
||||
import { buildEmptyFilter, getFilterParams, BooleanRelation } from '@kbn/es-query';
|
||||
import { DataViewField } from '@kbn/data-views-plugin/common';
|
||||
import { cx } from '@emotion/css';
|
||||
|
||||
import { FieldInput } from './field_input';
|
||||
import { OperatorInput } from './operator_input';
|
||||
import { ParamsEditor } from './params_editor';
|
||||
import { getBooleanRelationType } from '../../utils';
|
||||
import { FiltersBuilderContextType } from '../context';
|
||||
import { FilterGroup } from '../filter_group';
|
||||
import type { Path } from '../types';
|
||||
import { getFieldFromFilter, getOperatorFromFilter } from '../../filter_bar/filter_editor';
|
||||
import { Operator } from '../../filter_bar/filter_editor';
|
||||
import {
|
||||
cursorAddCss,
|
||||
cursorOrCss,
|
||||
fieldAndParamCss,
|
||||
getGrabIconCss,
|
||||
operationCss,
|
||||
disabledDraggableCss,
|
||||
} from './filter_item.styles';
|
||||
import { Tooltip } from './tooltip';
|
||||
import { FilterItemActions, MinimisedFilterItemActions } from './actions';
|
||||
|
||||
export const strings = {
|
||||
getDragFilterAriaLabel: () =>
|
||||
i18n.translate('unifiedSearch.filter.filtersBuilder.dragFilterAriaLabel', {
|
||||
defaultMessage: 'Drag filter',
|
||||
}),
|
||||
getReorderingRequirementsLabel: () =>
|
||||
i18n.translate('unifiedSearch.filter.filtersBuilder.dragHandleDisabled', {
|
||||
defaultMessage: 'Reordering requires more than one item.',
|
||||
}),
|
||||
};
|
||||
|
||||
const MAX_FILTER_NESTING = 5;
|
||||
|
||||
export interface FilterItemProps {
|
||||
path: Path;
|
||||
filter: Filter;
|
||||
disableOr: boolean;
|
||||
disableAnd: boolean;
|
||||
disableRemove: boolean;
|
||||
draggable?: boolean;
|
||||
color: 'plain' | 'subdued';
|
||||
index: number;
|
||||
|
||||
/** @internal used for recursive rendering **/
|
||||
renderedLevel: number;
|
||||
reverseBackground: boolean;
|
||||
}
|
||||
|
||||
const isMaxFilterNesting = (path: string) => {
|
||||
const pathArr = path.split('.');
|
||||
return pathArr.length - 1 === MAX_FILTER_NESTING;
|
||||
};
|
||||
|
||||
export function FilterItem({
|
||||
filter,
|
||||
path,
|
||||
reverseBackground,
|
||||
disableOr,
|
||||
disableAnd,
|
||||
disableRemove,
|
||||
color,
|
||||
index,
|
||||
renderedLevel,
|
||||
draggable = true,
|
||||
}: FilterItemProps) {
|
||||
const {
|
||||
dispatch,
|
||||
dataView,
|
||||
dropTarget,
|
||||
globalParams: { hideOr },
|
||||
timeRangeForSuggestionsOverride,
|
||||
disabled,
|
||||
} = useContext(FiltersBuilderContextType);
|
||||
const conditionalOperationType = getBooleanRelationType(filter);
|
||||
const { euiTheme } = useEuiTheme();
|
||||
let field: DataViewField | undefined;
|
||||
let operator: Operator | undefined;
|
||||
let params: Filter['meta']['params'] | undefined;
|
||||
const isMaxNesting = isMaxFilterNesting(path);
|
||||
if (!conditionalOperationType) {
|
||||
field = getFieldFromFilter(filter, dataView!);
|
||||
if (field) {
|
||||
operator = getOperatorFromFilter(filter);
|
||||
params = getFilterParams(filter);
|
||||
}
|
||||
}
|
||||
|
||||
const onHandleField = useCallback(
|
||||
(selectedField: DataViewField) => {
|
||||
dispatch({
|
||||
type: 'updateFilter',
|
||||
payload: { dest: { path, index }, field: selectedField },
|
||||
});
|
||||
},
|
||||
[dispatch, path, index]
|
||||
);
|
||||
|
||||
const onHandleOperator = useCallback(
|
||||
(selectedOperator: Operator) => {
|
||||
dispatch({
|
||||
type: 'updateFilter',
|
||||
payload: { dest: { path, index }, field, operator: selectedOperator },
|
||||
});
|
||||
},
|
||||
[dispatch, path, index, field]
|
||||
);
|
||||
|
||||
const onHandleParamsChange = useCallback(
|
||||
(selectedParams: unknown) => {
|
||||
dispatch({
|
||||
type: 'updateFilter',
|
||||
payload: { dest: { path, index }, field, operator, params: selectedParams },
|
||||
});
|
||||
},
|
||||
[dispatch, path, index, field, operator]
|
||||
);
|
||||
|
||||
const onHandleParamsUpdate = useCallback(
|
||||
(value: Filter['meta']['params']) => {
|
||||
const paramsValues = Array.isArray(params) ? params : [];
|
||||
dispatch({
|
||||
type: 'updateFilter',
|
||||
payload: { dest: { path, index }, field, operator, params: [...paramsValues, value] },
|
||||
});
|
||||
},
|
||||
[dispatch, path, index, field, operator, params]
|
||||
);
|
||||
|
||||
const onRemoveFilter = useCallback(() => {
|
||||
dispatch({
|
||||
type: 'removeFilter',
|
||||
payload: {
|
||||
dest: { path, index },
|
||||
},
|
||||
});
|
||||
}, [dispatch, path, index]);
|
||||
|
||||
const onAddFilter = useCallback(
|
||||
(booleanRelation: BooleanRelation) => {
|
||||
dispatch({
|
||||
type: 'addFilter',
|
||||
payload: {
|
||||
dest: { path, index: index + 1 },
|
||||
filter: buildEmptyFilter(false, dataView?.id),
|
||||
booleanRelation,
|
||||
dataView,
|
||||
},
|
||||
});
|
||||
},
|
||||
[dispatch, dataView, path, index]
|
||||
);
|
||||
|
||||
const onAddButtonClick = useCallback(() => onAddFilter(BooleanRelation.AND), [onAddFilter]);
|
||||
const onOrButtonClick = useCallback(() => onAddFilter(BooleanRelation.OR), [onAddFilter]);
|
||||
|
||||
const isMobile = useIsWithinBreakpoints(['xs', 's']);
|
||||
const ActionsComponent = isMobile ? MinimisedFilterItemActions : FilterItemActions;
|
||||
return (
|
||||
<div
|
||||
className={cx({
|
||||
'filter-builder__item': true,
|
||||
'filter-builder__item-nested': renderedLevel > 0,
|
||||
})}
|
||||
>
|
||||
{conditionalOperationType ? (
|
||||
<FilterGroup
|
||||
path={path}
|
||||
booleanRelation={conditionalOperationType}
|
||||
filters={Array.isArray(filter) ? filter : filter.meta?.params}
|
||||
reverseBackground={!reverseBackground}
|
||||
renderedLevel={renderedLevel + 1}
|
||||
/>
|
||||
) : (
|
||||
<EuiDroppable
|
||||
droppableId={path}
|
||||
spacing="none"
|
||||
isCombineEnabled={!disableOr || !hideOr}
|
||||
className={cx({ [cursorAddCss]: dropTarget === path })}
|
||||
isDropDisabled={disableAnd}
|
||||
>
|
||||
<EuiDraggable
|
||||
spacing="s"
|
||||
index={index}
|
||||
isDragDisabled={!draggable}
|
||||
draggableId={`${path}`}
|
||||
customDragHandle={true}
|
||||
hasInteractiveChildren={true}
|
||||
disableInteractiveElementBlocking
|
||||
className={cx(disabledDraggableCss)}
|
||||
>
|
||||
{(provided) => (
|
||||
<EuiFlexGroup
|
||||
gutterSize="xs"
|
||||
responsive={false}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
data-test-subj={`filter-${path}`}
|
||||
>
|
||||
<EuiFlexItem>
|
||||
<EuiPanel color={color} paddingSize={'none'} hasShadow={false}>
|
||||
<EuiFlexGroup
|
||||
responsive={false}
|
||||
alignItems="center"
|
||||
gutterSize="s"
|
||||
justifyContent="center"
|
||||
className={cx({
|
||||
[cursorOrCss]: dropTarget === path && !hideOr,
|
||||
})}
|
||||
>
|
||||
<EuiFlexItem
|
||||
grow={false}
|
||||
aria-label={strings.getDragFilterAriaLabel()}
|
||||
{...provided.dragHandleProps}
|
||||
>
|
||||
<Tooltip
|
||||
content={strings.getReorderingRequirementsLabel()}
|
||||
show={!draggable}
|
||||
>
|
||||
<EuiIcon
|
||||
type="grab"
|
||||
size="s"
|
||||
className={getGrabIconCss(euiTheme)}
|
||||
{...(!draggable ? { color: euiTheme.colors.disabled } : {})}
|
||||
/>
|
||||
</Tooltip>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={true}>
|
||||
<EuiFlexGroup
|
||||
gutterSize="s"
|
||||
responsive={false}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
wrap
|
||||
>
|
||||
<EuiFlexItem className={fieldAndParamCss(euiTheme)}>
|
||||
<EuiFormRow>
|
||||
<FieldInput
|
||||
field={field}
|
||||
dataView={dataView}
|
||||
onHandleField={onHandleField}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem className={operationCss(euiTheme)}>
|
||||
<EuiFormRow>
|
||||
<OperatorInput
|
||||
field={field}
|
||||
operator={operator}
|
||||
params={params}
|
||||
onHandleOperator={onHandleOperator}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem className={fieldAndParamCss(euiTheme)}>
|
||||
<EuiFormRow>
|
||||
<div data-test-subj="filterParams">
|
||||
<ParamsEditor
|
||||
dataView={dataView}
|
||||
field={field}
|
||||
operator={operator}
|
||||
params={params}
|
||||
onHandleParamsChange={onHandleParamsChange}
|
||||
onHandleParamsUpdate={onHandleParamsUpdate}
|
||||
timeRangeForSuggestionsOverride={timeRangeForSuggestionsOverride}
|
||||
/>
|
||||
</div>
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<ActionsComponent
|
||||
disabled={disabled}
|
||||
disableRemove={disableRemove}
|
||||
hideOr={hideOr || isMaxNesting}
|
||||
hideAnd={isMaxNesting}
|
||||
disableOr={disableOr}
|
||||
disableAnd={disableAnd}
|
||||
onRemoveFilter={onRemoveFilter}
|
||||
onOrButtonClick={onOrButtonClick}
|
||||
onAddButtonClick={onAddButtonClick}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiPanel>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
)}
|
||||
</EuiDraggable>
|
||||
</EuiDroppable>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -6,5 +6,4 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
/** @internal **/
|
||||
export type Path = string;
|
||||
export { FilterItem } from './filter_item';
|
|
@ -6,11 +6,19 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useContext } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import type { DataViewField } from '@kbn/data-views-plugin/common';
|
||||
import type { Operator } from '../../filter_bar/filter_editor';
|
||||
import { getOperatorOptions, GenericComboBox } from '../../filter_bar/filter_editor';
|
||||
import { FiltersBuilderContextType } from '../context';
|
||||
|
||||
export const strings = {
|
||||
getOperatorSelectPlaceholderSelectLabel: () =>
|
||||
i18n.translate('unifiedSearch.filter.filtersBuilder.operatorSelectPlaceholderSelect', {
|
||||
defaultMessage: 'Select operator',
|
||||
}),
|
||||
};
|
||||
|
||||
interface OperatorInputProps<TParams = unknown> {
|
||||
field: DataViewField | undefined;
|
||||
|
@ -25,6 +33,7 @@ export function OperatorInput<TParams = unknown>({
|
|||
params,
|
||||
onHandleOperator,
|
||||
}: OperatorInputProps<TParams>) {
|
||||
const { disabled } = useContext(FiltersBuilderContextType);
|
||||
const operators = field ? getOperatorOptions(field) : [];
|
||||
|
||||
const onOperatorChange = useCallback(
|
||||
|
@ -40,22 +49,15 @@ export function OperatorInput<TParams = unknown>({
|
|||
<GenericComboBox
|
||||
fullWidth
|
||||
compressed
|
||||
isDisabled={!field}
|
||||
placeholder={
|
||||
field
|
||||
? i18n.translate('unifiedSearch.filter.filtersBuilder.operatorSelectPlaceholderSelect', {
|
||||
defaultMessage: 'Select',
|
||||
})
|
||||
: i18n.translate('unifiedSearch.filter.filtersBuilder.operatorSelectPlaceholderWaiting', {
|
||||
defaultMessage: 'Waiting',
|
||||
})
|
||||
}
|
||||
isDisabled={!field || disabled}
|
||||
placeholder={strings.getOperatorSelectPlaceholderSelectLabel()}
|
||||
options={operators}
|
||||
selectedOptions={operator ? [operator] : []}
|
||||
getLabel={({ message }) => message}
|
||||
onChange={onOperatorChange}
|
||||
singleSelection={{ asPlainText: true }}
|
||||
isClearable={false}
|
||||
data-test-subj="filterOperatorList"
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,73 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useContext } from 'react';
|
||||
import { DataView, DataViewField } from '@kbn/data-views-plugin/common';
|
||||
import { EuiToolTip, EuiFormRow } from '@elastic/eui';
|
||||
import type { Operator } from '../../filter_bar/filter_editor';
|
||||
import { getFieldValidityAndErrorMessage } from '../../filter_bar/filter_editor/lib';
|
||||
import { FiltersBuilderContextType } from '../context';
|
||||
import { ParamsEditorInput } from './params_editor_input';
|
||||
|
||||
interface ParamsEditorProps {
|
||||
dataView: DataView;
|
||||
params: unknown;
|
||||
onHandleParamsChange: (params: unknown) => void;
|
||||
onHandleParamsUpdate: (value: unknown) => void;
|
||||
timeRangeForSuggestionsOverride?: boolean;
|
||||
field?: DataViewField;
|
||||
operator?: Operator;
|
||||
}
|
||||
|
||||
export function ParamsEditor({
|
||||
dataView,
|
||||
field,
|
||||
operator,
|
||||
params,
|
||||
onHandleParamsChange,
|
||||
onHandleParamsUpdate,
|
||||
timeRangeForSuggestionsOverride,
|
||||
}: ParamsEditorProps) {
|
||||
const { disabled } = useContext(FiltersBuilderContextType);
|
||||
const onParamsChange = useCallback(
|
||||
(selectedParams) => {
|
||||
onHandleParamsChange(selectedParams);
|
||||
},
|
||||
[onHandleParamsChange]
|
||||
);
|
||||
|
||||
const onParamsUpdate = useCallback(
|
||||
(value) => {
|
||||
onHandleParamsUpdate(value);
|
||||
},
|
||||
[onHandleParamsUpdate]
|
||||
);
|
||||
|
||||
const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(
|
||||
field!,
|
||||
typeof params === 'string' ? params : undefined
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiFormRow fullWidth isInvalid={isInvalid}>
|
||||
<EuiToolTip position="bottom" content={errorMessage ?? null} display="block">
|
||||
<ParamsEditorInput
|
||||
field={field}
|
||||
params={params}
|
||||
operator={operator}
|
||||
invalid={isInvalid}
|
||||
disabled={disabled}
|
||||
dataView={dataView}
|
||||
onParamsChange={onParamsChange}
|
||||
onParamsUpdate={onParamsUpdate}
|
||||
timeRangeForSuggestionsOverride={timeRangeForSuggestionsOverride}
|
||||
/>
|
||||
</EuiToolTip>
|
||||
</EuiFormRow>
|
||||
);
|
||||
}
|
|
@ -6,72 +6,80 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import React from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { DataView, DataViewField } from '@kbn/data-views-plugin/common';
|
||||
import { EuiFormRow } from '@elastic/eui';
|
||||
import type { Operator } from '../../filter_bar/filter_editor';
|
||||
import { EuiFieldText } from '@elastic/eui';
|
||||
import {
|
||||
PhraseValueInput,
|
||||
PhrasesValuesInput,
|
||||
RangeValueInput,
|
||||
isRangeParams,
|
||||
} from '../../filter_bar/filter_editor';
|
||||
import { getFieldValidityAndErrorMessage } from '../../filter_bar/filter_editor/lib';
|
||||
import type { Operator } from '../../filter_bar/filter_editor';
|
||||
|
||||
interface ParamsEditorProps<TParams = unknown> {
|
||||
export const strings = {
|
||||
getSelectFieldPlaceholderLabel: () =>
|
||||
i18n.translate('unifiedSearch.filter.filtersBuilder.selectFieldPlaceholder', {
|
||||
defaultMessage: 'Please select a field first...',
|
||||
}),
|
||||
getSelectOperatorPlaceholderLabel: () =>
|
||||
i18n.translate('unifiedSearch.filter.filtersBuilder.selectOperatorPlaceholder', {
|
||||
defaultMessage: 'Please select operator first...',
|
||||
}),
|
||||
};
|
||||
|
||||
interface ParamsEditorInputProps {
|
||||
dataView: DataView;
|
||||
params: TParams;
|
||||
onHandleParamsChange: (params: TParams) => void;
|
||||
onHandleParamsUpdate: (value: TParams) => void;
|
||||
params: unknown;
|
||||
onParamsChange: (params: unknown) => void;
|
||||
onParamsUpdate: (value: unknown) => void;
|
||||
timeRangeForSuggestionsOverride?: boolean;
|
||||
field?: DataViewField;
|
||||
operator?: Operator;
|
||||
invalid: boolean;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export function ParamsEditor<TParams = unknown>({
|
||||
const getPlaceholderText = (isFieldSelected: boolean, isOperatorSelected: boolean) => {
|
||||
if (!isFieldSelected) {
|
||||
return strings.getSelectFieldPlaceholderLabel();
|
||||
}
|
||||
|
||||
if (!isOperatorSelected) {
|
||||
return strings.getSelectOperatorPlaceholderLabel();
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
export function ParamsEditorInput({
|
||||
dataView,
|
||||
field,
|
||||
operator,
|
||||
params,
|
||||
onHandleParamsChange,
|
||||
onHandleParamsUpdate,
|
||||
invalid,
|
||||
disabled,
|
||||
onParamsChange,
|
||||
onParamsUpdate,
|
||||
timeRangeForSuggestionsOverride,
|
||||
}: ParamsEditorProps<TParams>) {
|
||||
const onParamsChange = useCallback(
|
||||
(selectedParams) => {
|
||||
onHandleParamsChange(selectedParams);
|
||||
},
|
||||
[onHandleParamsChange]
|
||||
);
|
||||
|
||||
const onParamsUpdate = useCallback(
|
||||
(value) => {
|
||||
onHandleParamsUpdate(value);
|
||||
},
|
||||
[onHandleParamsUpdate]
|
||||
);
|
||||
|
||||
const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(
|
||||
field!,
|
||||
typeof params === 'string' ? params : undefined
|
||||
);
|
||||
|
||||
}: ParamsEditorInputProps) {
|
||||
switch (operator?.type) {
|
||||
case 'exists':
|
||||
return null;
|
||||
case 'phrase':
|
||||
return (
|
||||
<EuiFormRow fullWidth isInvalid={isInvalid} error={errorMessage}>
|
||||
<PhraseValueInput
|
||||
compressed
|
||||
indexPattern={dataView}
|
||||
field={field!}
|
||||
value={typeof params === 'string' ? params : undefined}
|
||||
onChange={onParamsChange}
|
||||
timeRangeForSuggestionsOverride={timeRangeForSuggestionsOverride}
|
||||
fullWidth
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<PhraseValueInput
|
||||
compressed
|
||||
indexPattern={dataView}
|
||||
field={field!}
|
||||
value={params !== undefined ? `${params}` : undefined}
|
||||
onChange={onParamsChange}
|
||||
timeRangeForSuggestionsOverride={timeRangeForSuggestionsOverride}
|
||||
fullWidth
|
||||
invalid={invalid}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
case 'phrases':
|
||||
return (
|
||||
|
@ -84,6 +92,7 @@ export function ParamsEditor<TParams = unknown>({
|
|||
onParamsUpdate={onParamsUpdate}
|
||||
timeRangeForSuggestionsOverride={timeRangeForSuggestionsOverride}
|
||||
fullWidth
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
case 'range':
|
||||
|
@ -94,19 +103,18 @@ export function ParamsEditor<TParams = unknown>({
|
|||
value={isRangeParams(params) ? params : undefined}
|
||||
onChange={onParamsChange}
|
||||
fullWidth
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
default:
|
||||
const placeholderText = getPlaceholderText(Boolean(field), Boolean(operator?.type));
|
||||
return (
|
||||
<PhraseValueInput
|
||||
disabled={!dataView || !operator}
|
||||
indexPattern={dataView}
|
||||
field={field!}
|
||||
value={typeof params === 'string' ? params : undefined}
|
||||
onChange={onParamsChange}
|
||||
timeRangeForSuggestionsOverride={timeRangeForSuggestionsOverride}
|
||||
fullWidth
|
||||
compressed
|
||||
<EuiFieldText
|
||||
compressed={true}
|
||||
disabled={true}
|
||||
placeholder={placeholderText}
|
||||
aria-label={placeholderText}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { EuiToolTip, EuiToolTipProps } from '@elastic/eui';
|
||||
|
||||
export type TooltipProps = Partial<Omit<EuiToolTipProps, 'content'>> & {
|
||||
content: string;
|
||||
show: boolean;
|
||||
};
|
||||
|
||||
export const Tooltip: React.FC<TooltipProps> = ({ children, show, content, ...tooltipProps }) => (
|
||||
<>
|
||||
{show ? (
|
||||
<EuiToolTip content={content} delay="long" {...tooltipProps}>
|
||||
<>{children}</>
|
||||
</EuiToolTip>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</>
|
||||
);
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
export const filtersBuilderCss = (padding: string | null) => css`
|
||||
.filter-builder__panel {
|
||||
&.filter-builder__panel-nested {
|
||||
padding: ${padding} 0;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-builder__item {
|
||||
&.filter-builder__item-nested {
|
||||
padding: 0 ${padding};
|
||||
}
|
||||
}
|
||||
`;
|
|
@ -6,15 +6,16 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useReducer, useCallback, useState, useMemo } from 'react';
|
||||
import React, { useEffect, useReducer, useCallback, useState, useRef } from 'react';
|
||||
import { EuiDragDropContext, DragDropContextProps, useEuiPaddingSize } from '@elastic/eui';
|
||||
import type { DataView } from '@kbn/data-views-plugin/common';
|
||||
import type { Filter } from '@kbn/es-query';
|
||||
import { css } from '@emotion/css';
|
||||
import { FiltersBuilderContextType } from './filters_builder_context';
|
||||
import { ConditionTypes } from '../utils';
|
||||
import { FilterGroup } from './filters_builder_filter_group';
|
||||
import { FiltersBuilderReducer } from './filters_builder_reducer';
|
||||
import { type Filter, BooleanRelation, compareFilters } from '@kbn/es-query';
|
||||
import { FiltersBuilderContextType } from './context';
|
||||
import { FilterGroup } from './filter_group';
|
||||
import { FiltersBuilderReducer } from './reducer';
|
||||
import { getPathInArray } from './utils';
|
||||
import { FilterLocation } from './types';
|
||||
import { filtersBuilderCss } from './filters_builder.styles';
|
||||
|
||||
export interface FiltersBuilderProps {
|
||||
filters: Filter[];
|
||||
|
@ -23,9 +24,10 @@ export interface FiltersBuilderProps {
|
|||
timeRangeForSuggestionsOverride?: boolean;
|
||||
maxDepth?: number;
|
||||
hideOr?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const rootLevelConditionType = ConditionTypes.AND;
|
||||
const rootLevelConditionType = BooleanRelation.AND;
|
||||
const DEFAULT_MAX_DEPTH = 10;
|
||||
|
||||
function FiltersBuilder({
|
||||
|
@ -35,59 +37,70 @@ function FiltersBuilder({
|
|||
timeRangeForSuggestionsOverride,
|
||||
maxDepth = DEFAULT_MAX_DEPTH,
|
||||
hideOr = false,
|
||||
disabled = false,
|
||||
}: FiltersBuilderProps) {
|
||||
const filtersRef = useRef(filters);
|
||||
const [state, dispatch] = useReducer(FiltersBuilderReducer, { filters });
|
||||
const [dropTarget, setDropTarget] = useState('');
|
||||
const mPaddingSize = useEuiPaddingSize('m');
|
||||
|
||||
const filtersBuilderStyles = useMemo(
|
||||
() => css`
|
||||
.filter-builder__panel {
|
||||
&.filter-builder__panel-nested {
|
||||
padding: ${mPaddingSize} 0;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-builder__item {
|
||||
&.filter-builder__item-nested {
|
||||
padding: 0 ${mPaddingSize};
|
||||
}
|
||||
}
|
||||
`,
|
||||
[mPaddingSize]
|
||||
);
|
||||
const sPaddingSize = useEuiPaddingSize('s');
|
||||
useEffect(() => {
|
||||
if (
|
||||
!compareFilters(filters, filtersRef.current, {
|
||||
index: true,
|
||||
state: true,
|
||||
negate: true,
|
||||
disabled: true,
|
||||
alias: true,
|
||||
})
|
||||
) {
|
||||
filtersRef.current = filters;
|
||||
dispatch({ type: 'updateFilters', payload: { filters } });
|
||||
}
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.filters !== filters) {
|
||||
if (state.filters !== filtersRef.current) {
|
||||
filtersRef.current = state.filters;
|
||||
onChange(state.filters);
|
||||
}
|
||||
}, [filters, onChange, state.filters]);
|
||||
}, [onChange, state.filters]);
|
||||
|
||||
const handleMoveFilter = useCallback(
|
||||
(pathFrom: string, pathTo: string, conditionalType: ConditionTypes) => {
|
||||
if (pathFrom === pathTo) {
|
||||
(from: FilterLocation, to: FilterLocation, booleanRelation: BooleanRelation) => {
|
||||
if (from.path === to.path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: 'moveFilter',
|
||||
payload: {
|
||||
pathFrom,
|
||||
pathTo,
|
||||
conditionalType,
|
||||
from,
|
||||
to,
|
||||
booleanRelation,
|
||||
dataView,
|
||||
},
|
||||
});
|
||||
},
|
||||
[]
|
||||
[dataView]
|
||||
);
|
||||
|
||||
const onDragEnd: DragDropContextProps['onDragEnd'] = ({ combine, source, destination }) => {
|
||||
const onDragEnd: DragDropContextProps['onDragEnd'] = (args) => {
|
||||
const { combine, source, destination } = args;
|
||||
if (source && destination) {
|
||||
handleMoveFilter(source.droppableId, destination.droppableId, ConditionTypes.AND);
|
||||
handleMoveFilter(
|
||||
{ path: source.droppableId, index: source.index },
|
||||
{ path: destination.droppableId, index: destination.index },
|
||||
BooleanRelation.AND
|
||||
);
|
||||
}
|
||||
|
||||
if (source && combine) {
|
||||
handleMoveFilter(source.droppableId, combine.droppableId, ConditionTypes.OR);
|
||||
const path = getPathInArray(combine.droppableId);
|
||||
handleMoveFilter(
|
||||
{ path: source.droppableId, index: source.index },
|
||||
{ path: combine.droppableId, index: path.at(-1) ?? 0 },
|
||||
BooleanRelation.OR
|
||||
);
|
||||
}
|
||||
setDropTarget('');
|
||||
};
|
||||
|
@ -103,7 +116,7 @@ function FiltersBuilder({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className={filtersBuilderStyles}>
|
||||
<div className={filtersBuilderCss(sPaddingSize)}>
|
||||
<FiltersBuilderContextType.Provider
|
||||
value={{
|
||||
globalParams: { hideOr, maxDepth },
|
||||
|
@ -111,10 +124,11 @@ function FiltersBuilder({
|
|||
dispatch,
|
||||
dropTarget,
|
||||
timeRangeForSuggestionsOverride,
|
||||
disabled,
|
||||
}}
|
||||
>
|
||||
<EuiDragDropContext onDragEnd={onDragEnd} onDragUpdate={onDragActive}>
|
||||
<FilterGroup filters={state.filters} conditionType={rootLevelConditionType} path={''} />
|
||||
<FilterGroup filters={state.filters} booleanRelation={rootLevelConditionType} path={''} />
|
||||
</EuiDragDropContext>
|
||||
</FiltersBuilderContextType.Provider>
|
||||
</div>
|
||||
|
|
|
@ -1,149 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React, { useContext, useMemo } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import {
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiHorizontalRule,
|
||||
EuiPanel,
|
||||
EuiText,
|
||||
useEuiBackgroundColor,
|
||||
useEuiPaddingSize,
|
||||
} from '@elastic/eui';
|
||||
import { Filter } from '@kbn/es-query';
|
||||
import { css, cx } from '@emotion/css';
|
||||
import type { Path } from './filters_builder_types';
|
||||
import { ConditionTypes, getConditionalOperationType } from '../utils';
|
||||
import { FilterItem } from './filters_builder_filter_item';
|
||||
import { FiltersBuilderContextType } from './filters_builder_context';
|
||||
import { getPathInArray } from './filters_builder_utils';
|
||||
|
||||
export interface FilterGroupProps {
|
||||
filters: Filter[];
|
||||
conditionType: ConditionTypes;
|
||||
path: Path;
|
||||
|
||||
/** @internal used for recursive rendering **/
|
||||
renderedLevel?: number;
|
||||
reverseBackground?: boolean;
|
||||
}
|
||||
|
||||
/** @internal **/
|
||||
const Delimiter = ({
|
||||
color,
|
||||
conditionType,
|
||||
}: {
|
||||
color: 'subdued' | 'plain';
|
||||
conditionType: ConditionTypes;
|
||||
}) => {
|
||||
const xsPadding = useEuiPaddingSize('xs');
|
||||
const mPadding = useEuiPaddingSize('m');
|
||||
const backgroundColor = useEuiBackgroundColor(color);
|
||||
|
||||
const delimiterStyles = useMemo(
|
||||
() => css`
|
||||
position: relative;
|
||||
|
||||
.filter-builder__delimiter_text {
|
||||
position: absolute;
|
||||
display: block;
|
||||
padding: ${xsPadding};
|
||||
top: 0;
|
||||
left: ${mPadding};
|
||||
background: ${backgroundColor};
|
||||
}
|
||||
`,
|
||||
[backgroundColor, mPadding, xsPadding]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={delimiterStyles}>
|
||||
<EuiHorizontalRule margin="s" />
|
||||
<EuiText size="xs" className="filter-builder__delimiter_text">
|
||||
{i18n.translate('unifiedSearch.filter.filtersBuilder.delimiterLabel', {
|
||||
defaultMessage: '{conditionType}',
|
||||
values: {
|
||||
conditionType,
|
||||
},
|
||||
})}
|
||||
</EuiText>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const FilterGroup = ({
|
||||
filters,
|
||||
conditionType,
|
||||
path,
|
||||
reverseBackground = false,
|
||||
renderedLevel = 0,
|
||||
}: FilterGroupProps) => {
|
||||
const {
|
||||
globalParams: { maxDepth, hideOr },
|
||||
} = useContext(FiltersBuilderContextType);
|
||||
|
||||
const pathInArray = getPathInArray(path);
|
||||
const isDepthReached = maxDepth <= pathInArray.length;
|
||||
const orDisabled = hideOr || (isDepthReached && conditionType === ConditionTypes.AND);
|
||||
const andDisabled = isDepthReached && conditionType === ConditionTypes.OR;
|
||||
const removeDisabled = pathInArray.length <= 1 && filters.length === 1;
|
||||
const shouldNormalizeFirstLevel =
|
||||
!path && filters.length === 1 && getConditionalOperationType(filters[0]);
|
||||
|
||||
if (shouldNormalizeFirstLevel) {
|
||||
reverseBackground = true;
|
||||
renderedLevel -= 1;
|
||||
}
|
||||
|
||||
const color = reverseBackground ? 'plain' : 'subdued';
|
||||
|
||||
const renderedFilters = filters.map((filter, index, acc) => (
|
||||
<EuiFlexGroup direction="column" gutterSize="xs">
|
||||
<EuiFlexItem>
|
||||
<FilterItem
|
||||
filter={filter}
|
||||
path={`${path}${path ? '.' : ''}${index}`}
|
||||
reverseBackground={reverseBackground}
|
||||
disableOr={orDisabled}
|
||||
disableAnd={andDisabled}
|
||||
disableRemove={removeDisabled}
|
||||
color={color}
|
||||
index={index}
|
||||
renderedLevel={renderedLevel}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
|
||||
{conditionType && index + 1 < acc.length ? (
|
||||
<EuiFlexItem>
|
||||
{conditionType === ConditionTypes.OR && (
|
||||
<Delimiter color={color} conditionType={conditionType} />
|
||||
)}
|
||||
</EuiFlexItem>
|
||||
) : null}
|
||||
</EuiFlexGroup>
|
||||
));
|
||||
|
||||
return shouldNormalizeFirstLevel ? (
|
||||
<>{renderedFilters}</>
|
||||
) : (
|
||||
<EuiPanel
|
||||
color={color}
|
||||
hasShadow={false}
|
||||
paddingSize="none"
|
||||
hasBorder
|
||||
className={cx({
|
||||
'filter-builder__panel': true,
|
||||
'filter-builder__panel-nested': renderedLevel > 0,
|
||||
})}
|
||||
>
|
||||
{renderedFilters}
|
||||
</EuiPanel>
|
||||
);
|
||||
};
|
|
@ -1,317 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useContext, useMemo } from 'react';
|
||||
import {
|
||||
EuiButtonIcon,
|
||||
EuiDraggable,
|
||||
EuiDroppable,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiFormRow,
|
||||
EuiIcon,
|
||||
EuiPanel,
|
||||
useEuiTheme,
|
||||
} from '@elastic/eui';
|
||||
import { buildEmptyFilter, FieldFilter, Filter, getFilterParams } from '@kbn/es-query';
|
||||
import { DataViewField } from '@kbn/data-views-plugin/common';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { cx, css } from '@emotion/css';
|
||||
|
||||
import add from '../assets/add.svg';
|
||||
import or from '../assets/or.svg';
|
||||
|
||||
import { FieldInput } from './filters_builder_filter_item_field_input';
|
||||
import { OperatorInput } from './filters_builder_filter_item_operator_input';
|
||||
import { ParamsEditor } from './filters_builder_filter_item_params_editor';
|
||||
import { ConditionTypes, getConditionalOperationType } from '../../utils';
|
||||
import { FiltersBuilderContextType } from '../filters_builder_context';
|
||||
import { FilterGroup } from '../filters_builder_filter_group';
|
||||
import type { Path } from '../filters_builder_types';
|
||||
import { getFieldFromFilter, getOperatorFromFilter } from '../../filter_bar/filter_editor';
|
||||
import { Operator } from '../../filter_bar/filter_editor';
|
||||
|
||||
export interface FilterItemProps {
|
||||
path: Path;
|
||||
filter: Filter;
|
||||
disableOr: boolean;
|
||||
disableAnd: boolean;
|
||||
disableRemove: boolean;
|
||||
color: 'plain' | 'subdued';
|
||||
index: number;
|
||||
|
||||
/** @internal used for recursive rendering **/
|
||||
renderedLevel: number;
|
||||
reverseBackground: boolean;
|
||||
}
|
||||
|
||||
const cursorAddStyles = css`
|
||||
cursor: url(${add}), auto;
|
||||
`;
|
||||
|
||||
const cursorOrStyles = css`
|
||||
cursor: url(${or}), auto;
|
||||
`;
|
||||
|
||||
export function FilterItem({
|
||||
filter,
|
||||
path,
|
||||
reverseBackground,
|
||||
disableOr,
|
||||
disableAnd,
|
||||
disableRemove,
|
||||
color,
|
||||
index,
|
||||
renderedLevel,
|
||||
}: FilterItemProps) {
|
||||
const {
|
||||
dispatch,
|
||||
dataView,
|
||||
dropTarget,
|
||||
globalParams: { hideOr },
|
||||
timeRangeForSuggestionsOverride,
|
||||
} = useContext(FiltersBuilderContextType);
|
||||
const conditionalOperationType = getConditionalOperationType(filter);
|
||||
const { euiTheme } = useEuiTheme();
|
||||
|
||||
const grabIconStyles = useMemo(
|
||||
() => css`
|
||||
margin: 0 ${euiTheme.size.xxs};
|
||||
`,
|
||||
[euiTheme.size.xxs]
|
||||
);
|
||||
|
||||
let field: DataViewField | undefined;
|
||||
let operator: Operator | undefined;
|
||||
let params: Filter['meta']['params'] | undefined;
|
||||
|
||||
if (!conditionalOperationType) {
|
||||
field = getFieldFromFilter(filter as FieldFilter, dataView);
|
||||
operator = getOperatorFromFilter(filter);
|
||||
params = getFilterParams(filter);
|
||||
}
|
||||
|
||||
const onHandleField = useCallback(
|
||||
(selectedField: DataViewField) => {
|
||||
dispatch({
|
||||
type: 'updateFilter',
|
||||
payload: { path, field: selectedField },
|
||||
});
|
||||
},
|
||||
[dispatch, path]
|
||||
);
|
||||
|
||||
const onHandleOperator = useCallback(
|
||||
(selectedOperator: Operator) => {
|
||||
dispatch({
|
||||
type: 'updateFilter',
|
||||
payload: { path, field, operator: selectedOperator },
|
||||
});
|
||||
},
|
||||
[dispatch, path, field]
|
||||
);
|
||||
|
||||
const onHandleParamsChange = useCallback(
|
||||
(selectedParams: string) => {
|
||||
dispatch({
|
||||
type: 'updateFilter',
|
||||
payload: { path, field, operator, params: selectedParams },
|
||||
});
|
||||
},
|
||||
[dispatch, path, field, operator]
|
||||
);
|
||||
|
||||
const onHandleParamsUpdate = useCallback(
|
||||
(value: Filter['meta']['params']) => {
|
||||
dispatch({
|
||||
type: 'updateFilter',
|
||||
payload: { path, params: [value, ...(params || [])] },
|
||||
});
|
||||
},
|
||||
[dispatch, path, params]
|
||||
);
|
||||
|
||||
const onRemoveFilter = useCallback(() => {
|
||||
dispatch({
|
||||
type: 'removeFilter',
|
||||
payload: {
|
||||
path,
|
||||
},
|
||||
});
|
||||
}, [dispatch, path]);
|
||||
|
||||
const onAddFilter = useCallback(
|
||||
(conditionalType: ConditionTypes) => {
|
||||
dispatch({
|
||||
type: 'addFilter',
|
||||
payload: {
|
||||
path,
|
||||
filter: buildEmptyFilter(false, dataView.id),
|
||||
conditionalType,
|
||||
},
|
||||
});
|
||||
},
|
||||
[dispatch, dataView.id, path]
|
||||
);
|
||||
|
||||
const onAddButtonClick = useCallback(() => onAddFilter(ConditionTypes.AND), [onAddFilter]);
|
||||
const onOrButtonClick = useCallback(() => onAddFilter(ConditionTypes.OR), [onAddFilter]);
|
||||
|
||||
if (!dataView) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx({
|
||||
'filter-builder__item': true,
|
||||
'filter-builder__item-nested': renderedLevel > 0,
|
||||
})}
|
||||
>
|
||||
{conditionalOperationType ? (
|
||||
<FilterGroup
|
||||
path={path}
|
||||
conditionType={conditionalOperationType}
|
||||
filters={Array.isArray(filter) ? filter : filter.meta?.params}
|
||||
reverseBackground={!reverseBackground}
|
||||
renderedLevel={renderedLevel + 1}
|
||||
/>
|
||||
) : (
|
||||
<EuiDroppable
|
||||
droppableId={path}
|
||||
spacing="none"
|
||||
isCombineEnabled={!disableOr || !hideOr}
|
||||
className={cx({ [cursorAddStyles]: dropTarget === path })}
|
||||
isDropDisabled={disableAnd}
|
||||
>
|
||||
<EuiDraggable
|
||||
spacing="s"
|
||||
key={JSON.stringify(filter)}
|
||||
index={index}
|
||||
draggableId={`${path}`}
|
||||
customDragHandle={true}
|
||||
hasInteractiveChildren={true}
|
||||
>
|
||||
{(provided) => (
|
||||
<EuiFlexGroup
|
||||
gutterSize="xs"
|
||||
responsive={false}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<EuiFlexItem>
|
||||
<EuiPanel color={color} paddingSize={'none'} hasShadow={false}>
|
||||
<EuiFlexGroup
|
||||
responsive={false}
|
||||
alignItems="baseline"
|
||||
gutterSize="s"
|
||||
justifyContent="center"
|
||||
className={cx({
|
||||
[cursorOrStyles]: dropTarget === path && !hideOr,
|
||||
})}
|
||||
>
|
||||
<EuiFlexItem grow={false} {...provided.dragHandleProps}>
|
||||
<EuiIcon type="grab" size="s" className={grabIconStyles} />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={true}>
|
||||
<EuiFlexGroup gutterSize="s" alignItems="center" justifyContent="center">
|
||||
<EuiFlexItem grow={4}>
|
||||
<EuiFormRow fullWidth>
|
||||
<FieldInput
|
||||
field={field}
|
||||
dataView={dataView}
|
||||
onHandleField={onHandleField}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={2}>
|
||||
<EuiFormRow fullWidth>
|
||||
<OperatorInput
|
||||
field={field}
|
||||
operator={operator}
|
||||
params={params}
|
||||
onHandleOperator={onHandleOperator}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={4}>
|
||||
<EuiFormRow fullWidth>
|
||||
<ParamsEditor
|
||||
dataView={dataView}
|
||||
field={field}
|
||||
operator={operator}
|
||||
params={params}
|
||||
onHandleParamsChange={onHandleParamsChange}
|
||||
onHandleParamsUpdate={onHandleParamsUpdate}
|
||||
timeRangeForSuggestionsOverride={timeRangeForSuggestionsOverride}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiFlexGroup justifyContent="flexEnd" alignItems="flexEnd" gutterSize="s">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonIcon
|
||||
onClick={onRemoveFilter}
|
||||
iconType="trash"
|
||||
isDisabled={disableRemove}
|
||||
size="s"
|
||||
color="danger"
|
||||
aria-label={i18n.translate(
|
||||
'unifiedSearch.filter.filtersBuilder.deleteFilterGroupButtonIcon',
|
||||
{
|
||||
defaultMessage: 'Delete filter group',
|
||||
}
|
||||
)}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
{!hideOr ? (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonIcon
|
||||
onClick={onOrButtonClick}
|
||||
isDisabled={disableOr}
|
||||
iconType="returnKey"
|
||||
size="s"
|
||||
aria-label={i18n.translate(
|
||||
'unifiedSearch.filter.filtersBuilder.addOrFilterGroupButtonIcon',
|
||||
{
|
||||
defaultMessage: 'Add filter group with OR',
|
||||
}
|
||||
)}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
) : null}
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonIcon
|
||||
display="base"
|
||||
onClick={onAddButtonClick}
|
||||
isDisabled={disableAnd}
|
||||
iconType="plus"
|
||||
size="s"
|
||||
aria-label={i18n.translate(
|
||||
'unifiedSearch.filter.filtersBuilder.addAndFilterGroupButtonIcon',
|
||||
{
|
||||
defaultMessage: 'Add filter group with AND',
|
||||
}
|
||||
)}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiPanel>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
)}
|
||||
</EuiDraggable>
|
||||
</EuiDroppable>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,51 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import type { DataView, DataViewField } from '@kbn/data-views-plugin/common';
|
||||
import { useGeneratedHtmlId } from '@elastic/eui';
|
||||
import { getFilterableFields, GenericComboBox } from '../../filter_bar/filter_editor';
|
||||
|
||||
interface FieldInputProps {
|
||||
dataView: DataView;
|
||||
onHandleField: (field: DataViewField) => void;
|
||||
field?: DataViewField;
|
||||
}
|
||||
|
||||
export function FieldInput({ field, dataView, onHandleField }: FieldInputProps) {
|
||||
const fields = dataView ? getFilterableFields(dataView) : [];
|
||||
const id = useGeneratedHtmlId({ prefix: 'fieldInput' });
|
||||
|
||||
const onFieldChange = useCallback(
|
||||
([selectedField]: DataViewField[]) => {
|
||||
onHandleField(selectedField);
|
||||
},
|
||||
[onHandleField]
|
||||
);
|
||||
|
||||
const getLabel = useCallback((view: DataViewField) => view.customLabel || view.name, []);
|
||||
|
||||
return (
|
||||
<GenericComboBox
|
||||
id={id}
|
||||
isDisabled={!dataView}
|
||||
placeholder={i18n.translate('unifiedSearch.filter.filtersBuilder.fieldSelectPlaceholder', {
|
||||
defaultMessage: 'Select a field first',
|
||||
})}
|
||||
options={fields}
|
||||
selectedOptions={field ? [field] : []}
|
||||
getLabel={getLabel}
|
||||
onChange={onFieldChange}
|
||||
singleSelection={{ asPlainText: true }}
|
||||
isClearable={false}
|
||||
compressed
|
||||
fullWidth
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -1,360 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { DataViewField } from '@kbn/data-views-plugin/common';
|
||||
import type { Filter, FilterItem } from '@kbn/es-query';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { buildCombinedFilter, isCombinedFilter } from '@kbn/es-query';
|
||||
import { ConditionTypes, getConditionalOperationType } from '../utils';
|
||||
import type { Operator } from '../filter_bar/filter_editor';
|
||||
|
||||
const PATH_SEPARATOR = '.';
|
||||
|
||||
/**
|
||||
* The method returns the filter nesting identification number as an array.
|
||||
* @param {string} path - variable is used to identify the filter and its nesting in the filter group.
|
||||
*/
|
||||
export const getPathInArray = (path: string) => path.split(PATH_SEPARATOR).map((i) => +i);
|
||||
|
||||
const getGroupedFilters = (filter: FilterItem) =>
|
||||
Array.isArray(filter) ? filter : filter?.meta?.params;
|
||||
|
||||
const doForFilterByPath = <T>(
|
||||
filters: FilterItem[],
|
||||
path: string,
|
||||
action: (filter: FilterItem) => T
|
||||
) => {
|
||||
const pathArray = getPathInArray(path);
|
||||
let f = filters[pathArray[0]];
|
||||
for (let i = 1, depth = pathArray.length; i < depth; i++) {
|
||||
f = getGroupedFilters(f)[+pathArray[i]];
|
||||
}
|
||||
return action(f);
|
||||
};
|
||||
|
||||
const getContainerMetaByPath = (filters: FilterItem[], pathInArray: number[]) => {
|
||||
let targetArray: FilterItem[] = filters;
|
||||
let parentFilter: FilterItem | undefined;
|
||||
let parentConditionType = ConditionTypes.AND;
|
||||
|
||||
if (pathInArray.length > 1) {
|
||||
parentFilter = getFilterByPath(filters, getParentFilterPath(pathInArray));
|
||||
parentConditionType = getConditionalOperationType(parentFilter) ?? parentConditionType;
|
||||
targetArray = getGroupedFilters(parentFilter);
|
||||
}
|
||||
|
||||
return {
|
||||
parentFilter,
|
||||
targetArray,
|
||||
parentConditionType,
|
||||
};
|
||||
};
|
||||
|
||||
const getParentFilterPath = (pathInArray: number[]) =>
|
||||
pathInArray.slice(0, -1).join(PATH_SEPARATOR);
|
||||
|
||||
/**
|
||||
* The method corrects the positions of the filters after removing some filter from the filters.
|
||||
* @param {FilterItem[]} filters - an array of filters that may contain filters that are incorrectly nested for later display in the UI.
|
||||
*/
|
||||
export const normalizeFilters = (filters: FilterItem[]) => {
|
||||
const doRecursive = (f: FilterItem, parent: FilterItem) => {
|
||||
if (Array.isArray(f)) {
|
||||
return normalizeArray(f, parent);
|
||||
} else if (isCombinedFilter(f)) {
|
||||
return normalizeCombined(f);
|
||||
}
|
||||
return f;
|
||||
};
|
||||
|
||||
const normalizeArray = (filtersArray: FilterItem[], parent: FilterItem): FilterItem[] => {
|
||||
const partiallyNormalized = filtersArray
|
||||
.map((item) => {
|
||||
const normalized = doRecursive(item, filtersArray);
|
||||
|
||||
if (Array.isArray(normalized)) {
|
||||
if (normalized.length === 1) {
|
||||
return normalized[0];
|
||||
}
|
||||
if (normalized.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}, [])
|
||||
.filter(Boolean) as FilterItem[];
|
||||
|
||||
return Array.isArray(parent) ? partiallyNormalized.flat() : partiallyNormalized;
|
||||
};
|
||||
|
||||
const normalizeCombined = (combinedFilter: Filter): FilterItem => {
|
||||
const combinedFilters = getGroupedFilters(combinedFilter);
|
||||
if (combinedFilters.length < 2) {
|
||||
return combinedFilters[0];
|
||||
}
|
||||
|
||||
return {
|
||||
...combinedFilter,
|
||||
meta: {
|
||||
...combinedFilter.meta,
|
||||
params: doRecursive(combinedFilters, combinedFilter),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
return normalizeArray(filters, filters) as Filter[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Find filter by path.
|
||||
* @param {FilterItem[]} filters - filters in which the search for the desired filter will occur.
|
||||
* @param {string} path - path to filter.
|
||||
*/
|
||||
export const getFilterByPath = (filters: FilterItem[], path: string) =>
|
||||
doForFilterByPath(filters, path, (f) => f);
|
||||
|
||||
/**
|
||||
* Method to add a filter to a specified location in a filter group.
|
||||
* @param {Filter[]} filters - array of filters where the new filter will be added.
|
||||
* @param {FilterItem} filter - new filter.
|
||||
* @param {string} path - path to filter.
|
||||
* @param {ConditionTypes} conditionalType - OR/AND relationships between filters.
|
||||
*/
|
||||
export const addFilter = (
|
||||
filters: Filter[],
|
||||
filter: FilterItem,
|
||||
path: string,
|
||||
conditionalType: ConditionTypes
|
||||
) => {
|
||||
const newFilters = cloneDeep(filters);
|
||||
const pathInArray = getPathInArray(path);
|
||||
const { targetArray, parentConditionType } = getContainerMetaByPath(newFilters, pathInArray);
|
||||
const selector = pathInArray[pathInArray.length - 1];
|
||||
|
||||
if (parentConditionType !== conditionalType) {
|
||||
if (conditionalType === ConditionTypes.OR) {
|
||||
targetArray.splice(selector, 1, buildCombinedFilter([targetArray[selector], filter]));
|
||||
}
|
||||
if (conditionalType === ConditionTypes.AND) {
|
||||
targetArray.splice(selector, 1, [targetArray[selector], filter]);
|
||||
}
|
||||
} else {
|
||||
targetArray.splice(selector + 1, 0, filter);
|
||||
}
|
||||
|
||||
return newFilters;
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove filter from specified location.
|
||||
* @param {Filter[]} filters - array of filters.
|
||||
* @param {string} path - path to filter.
|
||||
*/
|
||||
export const removeFilter = (filters: Filter[], path: string) => {
|
||||
const newFilters = cloneDeep(filters);
|
||||
const pathInArray = getPathInArray(path);
|
||||
const { targetArray } = getContainerMetaByPath(newFilters, pathInArray);
|
||||
const selector = pathInArray[pathInArray.length - 1];
|
||||
|
||||
targetArray.splice(selector, 1);
|
||||
|
||||
return normalizeFilters(newFilters);
|
||||
};
|
||||
|
||||
/**
|
||||
* Moving the filter on drag and drop.
|
||||
* @param {Filter[]} filters - array of filters.
|
||||
* @param {string} from - filter path before moving.
|
||||
* @param {string} to - filter path where the filter will be moved.
|
||||
* @param {ConditionTypes} conditionalType - OR/AND relationships between filters.
|
||||
*/
|
||||
export const moveFilter = (
|
||||
filters: Filter[],
|
||||
from: string,
|
||||
to: string,
|
||||
conditionalType: ConditionTypes
|
||||
) => {
|
||||
const addFilterThenRemoveFilter = (
|
||||
source: Filter[],
|
||||
addedFilter: FilterItem,
|
||||
pathFrom: string,
|
||||
pathTo: string,
|
||||
conditional: ConditionTypes
|
||||
) => {
|
||||
const newFiltersWithFilter = addFilter(source, addedFilter, pathTo, conditional);
|
||||
return removeFilter(newFiltersWithFilter, pathFrom);
|
||||
};
|
||||
|
||||
const removeFilterThenAddFilter = (
|
||||
source: Filter[],
|
||||
removableFilter: FilterItem,
|
||||
pathFrom: string,
|
||||
pathTo: string,
|
||||
conditional: ConditionTypes
|
||||
) => {
|
||||
const newFiltersWithoutFilter = removeFilter(source, pathFrom);
|
||||
return addFilter(newFiltersWithoutFilter, removableFilter, pathTo, conditional);
|
||||
};
|
||||
|
||||
const newFilters = cloneDeep(filters);
|
||||
const movingFilter = getFilterByPath(newFilters, from);
|
||||
|
||||
const pathInArrayTo = getPathInArray(to);
|
||||
const pathInArrayFrom = getPathInArray(from);
|
||||
|
||||
if (pathInArrayTo.length === pathInArrayFrom.length) {
|
||||
const filterPositionTo = pathInArrayTo.at(-1);
|
||||
const filterPositionFrom = pathInArrayFrom.at(-1);
|
||||
|
||||
const { parentConditionType } = getContainerMetaByPath(newFilters, pathInArrayTo);
|
||||
const filterMovementDirection = Number(filterPositionTo) - Number(filterPositionFrom);
|
||||
|
||||
if (filterMovementDirection === -1 && parentConditionType === conditionalType) {
|
||||
return filters;
|
||||
}
|
||||
|
||||
if (filterMovementDirection >= -1) {
|
||||
return addFilterThenRemoveFilter(newFilters, movingFilter, from, to, conditionalType);
|
||||
} else {
|
||||
return removeFilterThenAddFilter(newFilters, movingFilter, from, to, conditionalType);
|
||||
}
|
||||
}
|
||||
|
||||
if (pathInArrayTo.length > pathInArrayFrom.length) {
|
||||
return addFilterThenRemoveFilter(newFilters, movingFilter, from, to, conditionalType);
|
||||
} else {
|
||||
return removeFilterThenAddFilter(newFilters, movingFilter, from, to, conditionalType);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Method to update values inside filter.
|
||||
* @param {Filter[]} filters - filter array
|
||||
* @param {string} path - path to filter
|
||||
* @param {DataViewField} field - DataViewField property inside a filter
|
||||
* @param {Operator} operator - defines a relation by property and value
|
||||
* @param {Filter['meta']['params']} params - filter value
|
||||
*/
|
||||
export const updateFilter = (
|
||||
filters: Filter[],
|
||||
path: string,
|
||||
field?: DataViewField,
|
||||
operator?: Operator,
|
||||
params?: Filter['meta']['params']
|
||||
) => {
|
||||
const newFilters = [...filters];
|
||||
const changedFilter = getFilterByPath(newFilters, path) as Filter;
|
||||
let filter = Object.assign({}, changedFilter);
|
||||
|
||||
if (field && operator && params) {
|
||||
if (Array.isArray(params)) {
|
||||
filter = updateWithIsOneOfOperator(filter, operator, params);
|
||||
} else {
|
||||
filter = updateWithIsOperator(filter, operator, params);
|
||||
}
|
||||
} else if (field && operator) {
|
||||
if (operator.type === 'exists') {
|
||||
filter = updateWithExistsOperator(filter, operator);
|
||||
} else {
|
||||
filter = updateOperator(filter, operator);
|
||||
}
|
||||
} else {
|
||||
filter = updateField(filter, field);
|
||||
}
|
||||
|
||||
const pathInArray = getPathInArray(path);
|
||||
const { targetArray } = getContainerMetaByPath(newFilters, pathInArray);
|
||||
const selector = pathInArray[pathInArray.length - 1];
|
||||
targetArray.splice(selector, 1, filter);
|
||||
|
||||
return newFilters;
|
||||
};
|
||||
|
||||
function updateField(filter: Filter, field?: DataViewField) {
|
||||
return {
|
||||
...filter,
|
||||
meta: {
|
||||
...filter.meta,
|
||||
key: field?.name,
|
||||
params: { query: undefined },
|
||||
value: undefined,
|
||||
type: undefined,
|
||||
},
|
||||
query: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function updateOperator(filter: Filter, operator?: Operator) {
|
||||
return {
|
||||
...filter,
|
||||
meta: {
|
||||
...filter.meta,
|
||||
negate: operator?.negate,
|
||||
type: operator?.type,
|
||||
params: { ...filter.meta.params, query: undefined },
|
||||
value: undefined,
|
||||
},
|
||||
query: { match_phrase: { field: filter.meta.key } },
|
||||
};
|
||||
}
|
||||
|
||||
function updateWithExistsOperator(filter: Filter, operator?: Operator) {
|
||||
return {
|
||||
...filter,
|
||||
meta: {
|
||||
...filter.meta,
|
||||
negate: operator?.negate,
|
||||
type: operator?.type,
|
||||
params: undefined,
|
||||
value: 'exists',
|
||||
},
|
||||
query: { exists: { field: filter.meta.key } },
|
||||
};
|
||||
}
|
||||
|
||||
function updateWithIsOperator(
|
||||
filter: Filter,
|
||||
operator?: Operator,
|
||||
params?: Filter['meta']['params']
|
||||
) {
|
||||
return {
|
||||
...filter,
|
||||
meta: {
|
||||
...filter.meta,
|
||||
negate: operator?.negate,
|
||||
type: operator?.type,
|
||||
params: { ...filter.meta.params, query: params },
|
||||
},
|
||||
query: { match_phrase: { ...filter!.query?.match_phrase, [filter.meta.key!]: params } },
|
||||
};
|
||||
}
|
||||
|
||||
function updateWithIsOneOfOperator(
|
||||
filter: Filter,
|
||||
operator?: Operator,
|
||||
params?: Array<Filter['meta']['params']>
|
||||
) {
|
||||
return {
|
||||
...filter,
|
||||
meta: {
|
||||
...filter.meta,
|
||||
negate: operator?.negate,
|
||||
type: operator?.type,
|
||||
params,
|
||||
},
|
||||
query: {
|
||||
bool: {
|
||||
minimum_should_match: 1,
|
||||
...filter!.query?.should,
|
||||
should: params?.map((param) => {
|
||||
return { match_phrase: { [filter.meta.key!]: param } };
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
|
@ -7,28 +7,33 @@
|
|||
*/
|
||||
|
||||
import type { Reducer } from 'react';
|
||||
import type { Filter } from '@kbn/es-query';
|
||||
import type { DataViewField } from '@kbn/data-views-plugin/common';
|
||||
import type { Path } from './filters_builder_types';
|
||||
import type { ConditionTypes } from '../utils';
|
||||
import { addFilter, moveFilter, removeFilter, updateFilter } from './filters_builder_utils';
|
||||
import type { Filter, BooleanRelation } from '@kbn/es-query';
|
||||
import type { DataView, DataViewField } from '@kbn/data-views-plugin/common';
|
||||
import { addFilter, moveFilter, removeFilter, updateFilters } from './utils';
|
||||
import type { Operator } from '../filter_bar/filter_editor';
|
||||
import { FilterLocation } from './types';
|
||||
|
||||
/** @internal **/
|
||||
export interface FiltersBuilderState {
|
||||
filters: Filter[];
|
||||
}
|
||||
|
||||
/** @internal **/
|
||||
export interface UpdateFiltersPayload {
|
||||
filters: Filter[];
|
||||
}
|
||||
|
||||
/** @internal **/
|
||||
export interface AddFilterPayload {
|
||||
path: Path;
|
||||
dest: FilterLocation;
|
||||
filter: Filter;
|
||||
conditionalType: ConditionTypes;
|
||||
booleanRelation: BooleanRelation;
|
||||
dataView: DataView;
|
||||
}
|
||||
|
||||
/** @internal **/
|
||||
export interface UpdateFilterPayload {
|
||||
path: string;
|
||||
dest: FilterLocation;
|
||||
field?: DataViewField;
|
||||
operator?: Operator;
|
||||
params?: Filter['meta']['params'];
|
||||
|
@ -36,18 +41,20 @@ export interface UpdateFilterPayload {
|
|||
|
||||
/** @internal **/
|
||||
export interface RemoveFilterPayload {
|
||||
path: Path;
|
||||
dest: FilterLocation;
|
||||
}
|
||||
|
||||
/** @internal **/
|
||||
export interface MoveFilterPayload {
|
||||
pathFrom: Path;
|
||||
pathTo: Path;
|
||||
conditionalType: ConditionTypes;
|
||||
from: FilterLocation;
|
||||
to: FilterLocation;
|
||||
booleanRelation: BooleanRelation;
|
||||
dataView: DataView;
|
||||
}
|
||||
|
||||
/** @internal **/
|
||||
export type FiltersBuilderActions =
|
||||
| { type: 'updateFilters'; payload: UpdateFiltersPayload }
|
||||
| { type: 'addFilter'; payload: AddFilterPayload }
|
||||
| { type: 'removeFilter'; payload: RemoveFilterPayload }
|
||||
| { type: 'moveFilter'; payload: MoveFilterPayload }
|
||||
|
@ -58,36 +65,44 @@ export const FiltersBuilderReducer: Reducer<FiltersBuilderState, FiltersBuilderA
|
|||
action
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case 'updateFilters':
|
||||
return {
|
||||
...state,
|
||||
filters: action.payload.filters,
|
||||
};
|
||||
case 'addFilter':
|
||||
return {
|
||||
...state,
|
||||
filters: addFilter(
|
||||
state.filters,
|
||||
action.payload.filter,
|
||||
action.payload.path,
|
||||
action.payload.conditionalType
|
||||
action.payload.dest,
|
||||
action.payload.booleanRelation,
|
||||
action.payload.dataView
|
||||
),
|
||||
};
|
||||
case 'removeFilter':
|
||||
return {
|
||||
...state,
|
||||
filters: removeFilter(state.filters, action.payload.path),
|
||||
filters: removeFilter(state.filters, action.payload.dest),
|
||||
};
|
||||
case 'moveFilter':
|
||||
return {
|
||||
...state,
|
||||
filters: moveFilter(
|
||||
state.filters,
|
||||
action.payload.pathFrom,
|
||||
action.payload.pathTo,
|
||||
action.payload.conditionalType
|
||||
action.payload.from,
|
||||
action.payload.to,
|
||||
action.payload.booleanRelation,
|
||||
action.payload.dataView
|
||||
),
|
||||
};
|
||||
case 'updateFilter':
|
||||
return {
|
||||
...state,
|
||||
filters: updateFilter(
|
||||
filters: updateFilters(
|
||||
state.filters,
|
||||
action.payload.path,
|
||||
action.payload.dest,
|
||||
action.payload.field,
|
||||
action.payload.operator,
|
||||
action.payload.params
|
16
src/plugins/unified_search/public/filters_builder/types.ts
Normal file
16
src/plugins/unified_search/public/filters_builder/types.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
/** @internal **/
|
||||
export type Path = string;
|
||||
|
||||
/** @internal **/
|
||||
export interface FilterLocation {
|
||||
path: Path;
|
||||
index: number;
|
||||
}
|
|
@ -6,8 +6,8 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { buildEmptyFilter, Filter, FilterItem } from '@kbn/es-query';
|
||||
import { ConditionTypes } from '../utils';
|
||||
import { buildEmptyFilter, type Filter, BooleanRelation } from '@kbn/es-query';
|
||||
import { DataView } from '@kbn/data-views-plugin/common';
|
||||
import {
|
||||
getFilterByPath,
|
||||
getPathInArray,
|
||||
|
@ -15,17 +15,17 @@ import {
|
|||
removeFilter,
|
||||
moveFilter,
|
||||
normalizeFilters,
|
||||
} from './filters_builder_utils';
|
||||
import { getConditionalOperationType } from '../utils';
|
||||
} from './filters_builder';
|
||||
import { getBooleanRelationType } from '../../utils';
|
||||
|
||||
import {
|
||||
getDataAfterNormalized,
|
||||
getDataThatNeedNotNormalized,
|
||||
getDataThatNeedsNormalized,
|
||||
getFiltersMock,
|
||||
} from './__mock__/filters';
|
||||
} from '../__mock__/filters';
|
||||
|
||||
describe('filters_builder_utils', () => {
|
||||
describe('filters_builder', () => {
|
||||
let filters: Filter[];
|
||||
beforeAll(() => {
|
||||
filters = getFiltersMock();
|
||||
|
@ -126,69 +126,75 @@ describe('filters_builder_utils', () => {
|
|||
}
|
||||
`);
|
||||
expect(getFilterByPath(filters, '1.1')).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"$state": Object {
|
||||
"store": "appState",
|
||||
},
|
||||
"meta": Object {
|
||||
"alias": null,
|
||||
"disabled": false,
|
||||
"index": "ff959d40-b880-11e8-a6d9-e546fe2bba5f",
|
||||
"key": "category.keyword",
|
||||
"negate": false,
|
||||
"params": Object {
|
||||
"query": "Men's Accessories 3",
|
||||
Object {
|
||||
"meta": Object {
|
||||
"params": Array [
|
||||
Object {
|
||||
"$state": Object {
|
||||
"store": "appState",
|
||||
},
|
||||
"meta": Object {
|
||||
"alias": null,
|
||||
"disabled": false,
|
||||
"index": "ff959d40-b880-11e8-a6d9-e546fe2bba5f",
|
||||
"key": "category.keyword",
|
||||
"negate": false,
|
||||
"params": Object {
|
||||
"query": "Men's Accessories 3",
|
||||
},
|
||||
"type": "phrase",
|
||||
},
|
||||
"query": Object {
|
||||
"match_phrase": Object {
|
||||
"category.keyword": "Men's Accessories 3",
|
||||
},
|
||||
},
|
||||
},
|
||||
"type": "phrase",
|
||||
},
|
||||
"query": Object {
|
||||
"match_phrase": Object {
|
||||
"category.keyword": "Men's Accessories 3",
|
||||
Object {
|
||||
"$state": Object {
|
||||
"store": "appState",
|
||||
},
|
||||
"meta": Object {
|
||||
"alias": null,
|
||||
"disabled": false,
|
||||
"index": "ff959d40-b880-11e8-a6d9-e546fe2bba5f",
|
||||
"key": "category.keyword",
|
||||
"negate": false,
|
||||
"params": Object {
|
||||
"query": "Men's Accessories 4",
|
||||
},
|
||||
"type": "phrase",
|
||||
},
|
||||
"query": Object {
|
||||
"match_phrase": Object {
|
||||
"category.keyword": "Men's Accessories 4",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
"relation": "AND",
|
||||
"type": "combined",
|
||||
},
|
||||
Object {
|
||||
"$state": Object {
|
||||
"store": "appState",
|
||||
},
|
||||
"meta": Object {
|
||||
"alias": null,
|
||||
"disabled": false,
|
||||
"index": "ff959d40-b880-11e8-a6d9-e546fe2bba5f",
|
||||
"key": "category.keyword",
|
||||
"negate": false,
|
||||
"params": Object {
|
||||
"query": "Men's Accessories 4",
|
||||
},
|
||||
"type": "phrase",
|
||||
},
|
||||
"query": Object {
|
||||
"match_phrase": Object {
|
||||
"category.keyword": "Men's Accessories 4",
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getConditionalOperationType', () => {
|
||||
describe('getBooleanRelationType', () => {
|
||||
let filter: Filter;
|
||||
let filtersWithOrRelationships: FilterItem;
|
||||
let groupOfFilters: FilterItem;
|
||||
let filtersWithOrRelationships: Filter;
|
||||
let groupOfFilters: Filter;
|
||||
|
||||
beforeAll(() => {
|
||||
filter = filters[0];
|
||||
filtersWithOrRelationships = filters[1];
|
||||
groupOfFilters = filters[1].meta.params;
|
||||
groupOfFilters = filters[1].meta.params[1];
|
||||
});
|
||||
|
||||
test('should return correct ConditionalOperationType', () => {
|
||||
expect(getConditionalOperationType(filter)).toBeUndefined();
|
||||
expect(getConditionalOperationType(filtersWithOrRelationships)).toBe(ConditionTypes.OR);
|
||||
expect(getConditionalOperationType(groupOfFilters)).toBe(ConditionTypes.AND);
|
||||
expect(getBooleanRelationType(filter)).toBeUndefined();
|
||||
expect(getBooleanRelationType(filtersWithOrRelationships)).toBe(BooleanRelation.OR);
|
||||
expect(getBooleanRelationType(groupOfFilters)).toBe(BooleanRelation.AND);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -204,7 +210,15 @@ describe('filters_builder_utils', () => {
|
|||
const emptyFilter = buildEmptyFilter(false);
|
||||
|
||||
test('should add filter into filters after zero element', () => {
|
||||
const enlargedFilters = addFilter(filters, emptyFilter, '0', ConditionTypes.AND);
|
||||
const enlargedFilters = addFilter(
|
||||
filters,
|
||||
emptyFilter,
|
||||
{ index: 1, path: '0' },
|
||||
BooleanRelation.AND,
|
||||
{
|
||||
id: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f',
|
||||
} as DataView
|
||||
);
|
||||
expect(getFilterByPath(enlargedFilters, '1')).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"$state": Object {
|
||||
|
@ -225,7 +239,7 @@ describe('filters_builder_utils', () => {
|
|||
test('should remove filter from filters', () => {
|
||||
const path = '1.1';
|
||||
const filterBeforeRemoved = getFilterByPath(filters, path);
|
||||
const filtersAfterRemoveFilter = removeFilter(filters, path);
|
||||
const filtersAfterRemoveFilter = removeFilter(filters, { index: 1, path });
|
||||
const filterObtainedAfterFilterRemovalFromFilters = getFilterByPath(
|
||||
filtersAfterRemoveFilter,
|
||||
path
|
||||
|
@ -238,7 +252,15 @@ describe('filters_builder_utils', () => {
|
|||
describe('moveFilter', () => {
|
||||
test('should move filter from "0" path to "2" path into filters', () => {
|
||||
const filterBeforeMoving = getFilterByPath(filters, '0');
|
||||
const filtersAfterMovingFilter = moveFilter(filters, '0', '2', ConditionTypes.AND);
|
||||
const filtersAfterMovingFilter = moveFilter(
|
||||
filters,
|
||||
{ path: '0', index: 1 },
|
||||
{ path: '2', index: 3 },
|
||||
BooleanRelation.AND,
|
||||
{
|
||||
id: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f',
|
||||
} as DataView
|
||||
);
|
||||
const filterObtainedAfterFilterMovingFilters = getFilterByPath(filtersAfterMovingFilter, '2');
|
||||
expect(filterBeforeMoving).toEqual(filterObtainedAfterFilterMovingFilters);
|
||||
});
|
|
@ -0,0 +1,194 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { DataView, DataViewField } from '@kbn/data-views-plugin/common';
|
||||
import { Filter, updateFilter } from '@kbn/es-query';
|
||||
import { BooleanRelation } from '@kbn/es-query';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { buildCombinedFilter, isCombinedFilter } from '@kbn/es-query';
|
||||
import { getBooleanRelationType } from '../../utils';
|
||||
import type { Operator } from '../../filter_bar/filter_editor';
|
||||
import { FilterLocation, Path } from '../types';
|
||||
|
||||
const PATH_SEPARATOR = '.';
|
||||
|
||||
export const getPathInArray = (path: Path) => path.split(PATH_SEPARATOR).map(Number);
|
||||
|
||||
const getGroupedFilters = (filter: Filter): Filter[] =>
|
||||
Array.isArray(filter) ? filter : filter?.meta?.params ?? [];
|
||||
|
||||
const doForFilterByPath = <T>(filters: Filter[], path: Path, action: (filter: Filter) => T) => {
|
||||
const [first, ...restPath] = getPathInArray(path);
|
||||
|
||||
const foundFilter = restPath.reduce((filter, filterLocation) => {
|
||||
return getGroupedFilters(filter)[Number(filterLocation)];
|
||||
}, filters[first]);
|
||||
|
||||
return action(foundFilter);
|
||||
};
|
||||
|
||||
const getContainerMetaByPath = (filters: Filter[], pathInArray: number[]) => {
|
||||
if (pathInArray.length <= 1) {
|
||||
return {
|
||||
parentFilter: undefined,
|
||||
targetArray: filters,
|
||||
parentConditionType: BooleanRelation.AND,
|
||||
};
|
||||
}
|
||||
|
||||
const parentFilter = getFilterByPath(filters, getParentFilterPath(pathInArray));
|
||||
const targetArray = getGroupedFilters(parentFilter);
|
||||
|
||||
return {
|
||||
parentFilter,
|
||||
targetArray: Array.isArray(targetArray) ? targetArray : targetArray ? [targetArray] : [],
|
||||
parentConditionType: getBooleanRelationType(parentFilter) ?? BooleanRelation.AND,
|
||||
};
|
||||
};
|
||||
|
||||
const getParentFilterPath = (pathInArray: number[]) =>
|
||||
pathInArray.slice(0, -1).join(PATH_SEPARATOR);
|
||||
|
||||
export const normalizeFilters = (filters: Filter[]) => {
|
||||
const normalizeRecursively = (
|
||||
f: Filter | Filter[],
|
||||
parent: Filter[] | Filter
|
||||
): Filter | Filter[] | undefined => {
|
||||
if (Array.isArray(f)) {
|
||||
return normalizeArray(f, parent);
|
||||
} else if (isCombinedFilter(f)) {
|
||||
return normalizeCombined(f);
|
||||
}
|
||||
return f;
|
||||
};
|
||||
|
||||
const normalizeArray = (filtersArray: Filter[], parent: Filter[] | Filter): Filter[] => {
|
||||
const partiallyNormalized = filtersArray
|
||||
.map((item: Filter) => {
|
||||
const normalized = normalizeRecursively(item, filtersArray);
|
||||
|
||||
if (Array.isArray(normalized)) {
|
||||
if (normalized.length === 1) {
|
||||
return normalized[0];
|
||||
}
|
||||
if (normalized.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}, [])
|
||||
.filter(Boolean) as Filter[];
|
||||
return Array.isArray(parent) ? partiallyNormalized.flat() : partiallyNormalized;
|
||||
};
|
||||
|
||||
const normalizeCombined = (combinedFilter: Filter) => {
|
||||
const combinedFilters = getGroupedFilters(combinedFilter);
|
||||
const nonEmptyCombinedFilters = combinedFilters.filter(Boolean);
|
||||
if (nonEmptyCombinedFilters.length < 2) {
|
||||
return nonEmptyCombinedFilters[0];
|
||||
}
|
||||
|
||||
return combinedFilter
|
||||
? {
|
||||
...combinedFilter,
|
||||
meta: {
|
||||
...combinedFilter.meta,
|
||||
params: normalizeRecursively(nonEmptyCombinedFilters, combinedFilter),
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
};
|
||||
|
||||
return normalizeArray(filters, filters) as Filter[];
|
||||
};
|
||||
|
||||
export const getFilterByPath = (filters: Filter[], path: Path) =>
|
||||
doForFilterByPath(filters, path, (f) => f);
|
||||
|
||||
export const addFilter = (
|
||||
filters: Filter[],
|
||||
filter: Filter,
|
||||
dest: FilterLocation,
|
||||
booleanRelation: BooleanRelation,
|
||||
dataView: DataView
|
||||
) => {
|
||||
const newFilters = cloneDeep(filters);
|
||||
const pathInArray = getPathInArray(dest.path);
|
||||
const { targetArray, parentConditionType } = getContainerMetaByPath(newFilters, pathInArray);
|
||||
const selector = pathInArray.at(-1) ?? 0;
|
||||
|
||||
if (booleanRelation && parentConditionType !== booleanRelation) {
|
||||
targetArray.splice(
|
||||
selector,
|
||||
1,
|
||||
buildCombinedFilter(booleanRelation, [targetArray[selector], filter], dataView)
|
||||
);
|
||||
} else {
|
||||
targetArray.splice(dest.index, 0, filter);
|
||||
}
|
||||
return newFilters;
|
||||
};
|
||||
|
||||
const removeFilterWithoutNormalization = (filters: Filter[], dest: FilterLocation) => {
|
||||
const newFilters = cloneDeep(filters);
|
||||
const pathInArray = getPathInArray(dest.path);
|
||||
const meta = getContainerMetaByPath(newFilters, pathInArray);
|
||||
const target: Array<Filter | undefined> = meta.targetArray;
|
||||
target[dest.index] = undefined;
|
||||
|
||||
return newFilters;
|
||||
};
|
||||
|
||||
export const removeFilter = (filters: Filter[], dest: FilterLocation) => {
|
||||
const newFilters = removeFilterWithoutNormalization(filters, dest);
|
||||
return normalizeFilters(newFilters);
|
||||
};
|
||||
|
||||
export const moveFilter = (
|
||||
filters: Filter[],
|
||||
from: FilterLocation,
|
||||
to: FilterLocation,
|
||||
booleanRelation: BooleanRelation,
|
||||
dataView: DataView
|
||||
) => {
|
||||
const newFilters = cloneDeep(filters);
|
||||
const movingFilter = getFilterByPath(newFilters, from.path);
|
||||
const filtersWithoutRemoved = removeFilterWithoutNormalization(newFilters, from);
|
||||
|
||||
const updatedFilters = addFilter(
|
||||
filtersWithoutRemoved,
|
||||
movingFilter,
|
||||
to,
|
||||
booleanRelation,
|
||||
dataView
|
||||
);
|
||||
|
||||
return normalizeFilters(updatedFilters);
|
||||
};
|
||||
|
||||
export const updateFilters = (
|
||||
filters: Filter[],
|
||||
dest: FilterLocation,
|
||||
field?: DataViewField,
|
||||
operator?: Operator,
|
||||
params?: Filter['meta']['params']
|
||||
) => {
|
||||
const newFilters = [...filters];
|
||||
const updatedFilter = updateFilter(
|
||||
getFilterByPath(newFilters, dest.path),
|
||||
field?.name,
|
||||
operator,
|
||||
params
|
||||
);
|
||||
const pathInArray = getPathInArray(dest.path);
|
||||
const { targetArray } = getContainerMetaByPath(newFilters, pathInArray);
|
||||
const selector = pathInArray[pathInArray.length - 1];
|
||||
targetArray.splice(selector, 1, updatedFilter);
|
||||
|
||||
return newFilters;
|
||||
};
|
|
@ -6,4 +6,4 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
export { FilterItem } from './filters_builder_filter_item';
|
||||
export * from './filters_builder';
|
|
@ -19,7 +19,8 @@ export type {
|
|||
} from './types';
|
||||
export { SearchBar } from './search_bar';
|
||||
export type { FilterItemsProps } from './filter_bar';
|
||||
export { FilterLabel, FilterItem, FilterItems } from './filter_bar';
|
||||
export { FilterItem, FilterItems } from './filter_bar';
|
||||
export { FilterBadgeGroup } from './filter_badge';
|
||||
export { DataViewsList } from './dataview_picker/dataview_list';
|
||||
export { DataViewSelector } from './dataview_picker/data_view_selector';
|
||||
export { DataViewPicker } from './dataview_picker';
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { euiShadowMedium, UseEuiTheme } from '@elastic/eui';
|
||||
import { css } from '@emotion/react';
|
||||
|
||||
/** @todo important style should be remove after fixing elastic/eui/issues/6314. */
|
||||
export const popoverDragAndDropCss = (euiTheme: UseEuiTheme) =>
|
||||
css`
|
||||
// Always needed for popover with drag & drop in them
|
||||
transform: none !important;
|
||||
transition: none !important;
|
||||
filter: none !important;
|
||||
${euiShadowMedium(euiTheme)}
|
||||
`;
|
|
@ -7,17 +7,26 @@
|
|||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import {
|
||||
EuiFlexItem,
|
||||
EuiButtonIcon,
|
||||
EuiPopover,
|
||||
EuiButtonIconProps,
|
||||
EuiToolTip,
|
||||
useEuiTheme,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { Filter } from '@kbn/es-query';
|
||||
import type { DataView } from '@kbn/data-views-plugin/public';
|
||||
import { FilterEditorWrapper } from './filter_editor_wrapper';
|
||||
import { popoverDragAndDropCss } from './add_filter_popover.styles';
|
||||
|
||||
export const strings = {
|
||||
getAddFilterButtonLabel: () =>
|
||||
i18n.translate('unifiedSearch.filter.filterBar.addFilterButtonLabel', {
|
||||
defaultMessage: 'Add filter',
|
||||
}),
|
||||
};
|
||||
|
||||
interface AddFilterPopoverProps {
|
||||
indexPatterns?: Array<DataView | string>;
|
||||
|
@ -36,18 +45,15 @@ export const AddFilterPopover = React.memo(function AddFilterPopover({
|
|||
buttonProps,
|
||||
isDisabled,
|
||||
}: AddFilterPopoverProps) {
|
||||
const euiTheme = useEuiTheme();
|
||||
const [isAddFilterPopoverOpen, setIsAddFilterPopoverOpen] = useState(false);
|
||||
|
||||
const buttonIconLabel = i18n.translate('unifiedSearch.filter.filterBar.addFilterButtonLabel', {
|
||||
defaultMessage: 'Add filter',
|
||||
});
|
||||
|
||||
const button = (
|
||||
<EuiToolTip delay="long" content={buttonIconLabel}>
|
||||
<EuiToolTip delay="long" content={strings.getAddFilterButtonLabel()}>
|
||||
<EuiButtonIcon
|
||||
display="base"
|
||||
iconType="plusInCircleFilled"
|
||||
aria-label={buttonIconLabel}
|
||||
aria-label={strings.getAddFilterButtonLabel()}
|
||||
data-test-subj="addFilter"
|
||||
onClick={() => setIsAddFilterPopoverOpen((isOpen) => !isOpen)}
|
||||
size="m"
|
||||
|
@ -67,7 +73,10 @@ export const AddFilterPopover = React.memo(function AddFilterPopover({
|
|||
closePopover={() => setIsAddFilterPopoverOpen(false)}
|
||||
anchorPosition="downLeft"
|
||||
panelPaddingSize="none"
|
||||
panelProps={{ 'data-test-subj': 'addFilterPopover' }}
|
||||
panelProps={{
|
||||
'data-test-subj': 'addFilterPopover',
|
||||
css: popoverDragAndDropCss(euiTheme),
|
||||
}}
|
||||
initialFocus=".filterEditor__hiddenItem"
|
||||
ownFocus
|
||||
repositionOnScroll
|
||||
|
|
|
@ -16,9 +16,16 @@ import {
|
|||
EuiButtonIcon,
|
||||
} from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import React, { useState } from 'react';
|
||||
import { DocLinksStart } from '@kbn/core/public';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
export const strings = {
|
||||
getSwitchLanguageButtonText: () =>
|
||||
i18n.translate('unifiedSearch.switchLanguage.buttonText', {
|
||||
defaultMessage: 'Switch language button.',
|
||||
}),
|
||||
};
|
||||
|
||||
export interface QueryLanguageSwitcherProps {
|
||||
language: string;
|
||||
|
@ -51,9 +58,7 @@ export const QueryLanguageSwitcher = React.memo(function QueryLanguageSwitcher({
|
|||
onClick={() => setIsPopoverOpen(!isPopoverOpen)}
|
||||
className="euiFormControlLayout__append kqlQueryBar__languageSwitcherButton"
|
||||
data-test-subj={'switchQueryLanguageButton'}
|
||||
aria-label={i18n.translate('unifiedSearch.switchLanguage.buttonText', {
|
||||
defaultMessage: 'Switch language button.',
|
||||
})}
|
||||
aria-label={strings.getSwitchLanguageButtonText()}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -14,6 +14,26 @@ import { IStorageWrapper } from '@kbn/kibana-utils-plugin/public';
|
|||
|
||||
const NO_DATA_POPOVER_STORAGE_KEY = 'data.noDataPopover';
|
||||
|
||||
export const strings = {
|
||||
getNoDataPopoverContent: () =>
|
||||
i18n.translate('unifiedSearch.noDataPopover.content', {
|
||||
defaultMessage:
|
||||
"This time range doesn't contain any data. Increase or adjust the time range to see more fields and create charts.",
|
||||
}),
|
||||
getNoDataPopoverSubtitle: () =>
|
||||
i18n.translate('unifiedSearch.noDataPopover.subtitle', { defaultMessage: 'Tip' }),
|
||||
|
||||
getNoDataPopoverTitle: () =>
|
||||
i18n.translate('unifiedSearch.noDataPopover.title', {
|
||||
defaultMessage: 'Empty dataset',
|
||||
}),
|
||||
|
||||
getNoDataPopoverDismissAction: () =>
|
||||
i18n.translate('unifiedSearch.noDataPopover.dismissAction', {
|
||||
defaultMessage: "Don't show again",
|
||||
}),
|
||||
};
|
||||
|
||||
export function NoDataPopover({
|
||||
showNoDataPopover,
|
||||
storage,
|
||||
|
@ -42,12 +62,7 @@ export function NoDataPopover({
|
|||
}}
|
||||
content={
|
||||
<EuiText size="s">
|
||||
<p style={{ maxWidth: 300 }}>
|
||||
{i18n.translate('unifiedSearch.noDataPopover.content', {
|
||||
defaultMessage:
|
||||
"This time range doesn't contain any data. Increase or adjust the time range to see more fields and create charts.",
|
||||
})}
|
||||
</p>
|
||||
<p style={{ maxWidth: 300 }}>{strings.getNoDataPopoverContent()}</p>
|
||||
</EuiText>
|
||||
}
|
||||
minWidth={300}
|
||||
|
@ -56,10 +71,8 @@ export function NoDataPopover({
|
|||
step={1}
|
||||
stepsTotal={1}
|
||||
isStepOpen={noDataPopoverVisible}
|
||||
subtitle={i18n.translate('unifiedSearch.noDataPopover.subtitle', { defaultMessage: 'Tip' })}
|
||||
title={i18n.translate('unifiedSearch.noDataPopover.title', {
|
||||
defaultMessage: 'Empty dataset',
|
||||
})}
|
||||
subtitle={strings.getNoDataPopoverSubtitle()}
|
||||
title={strings.getNoDataPopoverTitle()}
|
||||
footerAction={
|
||||
<EuiButtonEmpty
|
||||
size="xs"
|
||||
|
@ -72,9 +85,7 @@ export function NoDataPopover({
|
|||
setNoDataPopoverVisible(false);
|
||||
}}
|
||||
>
|
||||
{i18n.translate('unifiedSearch.noDataPopover.dismissAction', {
|
||||
defaultMessage: "Don't show again",
|
||||
})}
|
||||
{strings.getNoDataPopoverDismissAction()}
|
||||
</EuiButtonEmpty>
|
||||
}
|
||||
>
|
||||
|
|
|
@ -15,6 +15,7 @@ import {
|
|||
useGeneratedHtmlId,
|
||||
EuiButtonIconProps,
|
||||
EuiToolTip,
|
||||
useEuiTheme,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import type { Filter, Query, TimeRange } from '@kbn/es-query';
|
||||
|
@ -22,6 +23,14 @@ import type { DataView } from '@kbn/data-views-plugin/public';
|
|||
import type { SavedQueryService, SavedQuery } from '@kbn/data-plugin/public';
|
||||
import { QueryBarMenuPanels, QueryBarMenuPanelsProps } from './query_bar_menu_panels';
|
||||
import { FilterEditorWrapper } from './filter_editor_wrapper';
|
||||
import { popoverDragAndDropCss } from './add_filter_popover.styles';
|
||||
|
||||
export const strings = {
|
||||
getFilterSetButtonLabel: () =>
|
||||
i18n.translate('unifiedSearch.filter.options.filterSetButtonLabel', {
|
||||
defaultMessage: 'Saved query menu',
|
||||
}),
|
||||
};
|
||||
|
||||
export interface QueryBarMenuProps {
|
||||
language: string;
|
||||
|
@ -79,6 +88,7 @@ export function QueryBarMenu({
|
|||
isDisabled,
|
||||
}: QueryBarMenuProps) {
|
||||
const [renderedComponent, setRenderedComponent] = useState('menu');
|
||||
const euiTheme = useEuiTheme();
|
||||
|
||||
useEffect(() => {
|
||||
if (openQueryBarMenu) {
|
||||
|
@ -97,12 +107,8 @@ export function QueryBarMenu({
|
|||
toggleFilterBarMenuPopover(false);
|
||||
};
|
||||
|
||||
const buttonLabel = i18n.translate('unifiedSearch.filter.options.filterSetButtonLabel', {
|
||||
defaultMessage: 'Saved query menu',
|
||||
});
|
||||
|
||||
const button = (
|
||||
<EuiToolTip delay="long" content={buttonLabel}>
|
||||
<EuiToolTip delay="long" content={strings.getFilterSetButtonLabel()}>
|
||||
<EuiButtonIcon
|
||||
size="m"
|
||||
display="empty"
|
||||
|
@ -111,7 +117,7 @@ export function QueryBarMenu({
|
|||
{...buttonProps}
|
||||
style={{ borderTopRightRadius: 0, borderBottomRightRadius: 0 }}
|
||||
iconType="filter"
|
||||
aria-label={buttonLabel}
|
||||
aria-label={strings.getFilterSetButtonLabel()}
|
||||
data-test-subj="showQueryBarMenu"
|
||||
/>
|
||||
</EuiToolTip>
|
||||
|
@ -185,6 +191,9 @@ export function QueryBarMenu({
|
|||
anchorPosition="downLeft"
|
||||
repositionOnScroll
|
||||
data-test-subj="queryBarMenuPopover"
|
||||
panelProps={{
|
||||
css: popoverDragAndDropCss(euiTheme),
|
||||
}}
|
||||
>
|
||||
{renderComponent()}
|
||||
</EuiPopover>
|
||||
|
|
|
@ -7,7 +7,6 @@
|
|||
*/
|
||||
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { isEqual } from 'lodash';
|
||||
import {
|
||||
EuiContextMenuPanelDescriptor,
|
||||
|
@ -26,6 +25,7 @@ import {
|
|||
pinFilter,
|
||||
unpinFilter,
|
||||
} from '@kbn/es-query';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { METRIC_TYPE } from '@kbn/analytics';
|
||||
import { useKibana } from '@kbn/kibana-react-plugin/public';
|
||||
import { KIBANA_USER_QUERY_LANGUAGE_KEY, UI_SETTINGS } from '@kbn/data-plugin/common';
|
||||
|
@ -44,6 +44,98 @@ const MAP_ITEMS_TO_FILTER_OPTION: Record<string, FilterPanelOption> = {
|
|||
'filter-sets-removeAllFilters': 'deleteFilter',
|
||||
};
|
||||
|
||||
export const strings = {
|
||||
getLuceneLanguageName: () =>
|
||||
i18n.translate('unifiedSearch.query.queryBar.luceneLanguageName', {
|
||||
defaultMessage: 'Lucene',
|
||||
}),
|
||||
getKqlLanguageName: () =>
|
||||
i18n.translate('unifiedSearch.query.queryBar.kqlLanguageName', {
|
||||
defaultMessage: 'KQL',
|
||||
}),
|
||||
getOptionsAddFilterButtonLabel: () =>
|
||||
i18n.translate('unifiedSearch.filter.options.addFilterButtonLabel', {
|
||||
defaultMessage: 'Add filter',
|
||||
}),
|
||||
getOptionsApplyAllFiltersButtonLabel: () =>
|
||||
i18n.translate('unifiedSearch.filter.options.applyAllFiltersButtonLabel', {
|
||||
defaultMessage: 'Apply to all',
|
||||
}),
|
||||
getLoadOtherFilterSetLabel: () =>
|
||||
i18n.translate('unifiedSearch.filter.options.loadOtherFilterSetLabel', {
|
||||
defaultMessage: 'Load other saved query',
|
||||
}),
|
||||
getLoadCurrentFilterSetLabel: () =>
|
||||
i18n.translate('unifiedSearch.filter.options.loadCurrentFilterSetLabel', {
|
||||
defaultMessage: 'Load saved query',
|
||||
}),
|
||||
getSaveAsNewFilterSetLabel: () =>
|
||||
i18n.translate('unifiedSearch.filter.options.saveAsNewFilterSetLabel', {
|
||||
defaultMessage: 'Save as new',
|
||||
}),
|
||||
getSaveFilterSetLabel: () =>
|
||||
i18n.translate('unifiedSearch.filter.options.saveFilterSetLabel', {
|
||||
defaultMessage: 'Save saved query',
|
||||
}),
|
||||
getClearllFiltersButtonLabel: () =>
|
||||
i18n.translate('unifiedSearch.filter.options.clearllFiltersButtonLabel', {
|
||||
defaultMessage: 'Clear all',
|
||||
}),
|
||||
getSavedQueryLabel: () =>
|
||||
i18n.translate('unifiedSearch.search.searchBar.savedQuery', {
|
||||
defaultMessage: 'Saved query',
|
||||
}),
|
||||
getSavedQueryPopoverSaveChangesButtonAriaLabel: (title?: string) =>
|
||||
i18n.translate('unifiedSearch.search.searchBar.savedQueryPopoverSaveChangesButtonAriaLabel', {
|
||||
defaultMessage: 'Save changes to {title}',
|
||||
values: { title },
|
||||
}),
|
||||
getSavedQueryPopoverSaveChangesButtonText: () =>
|
||||
i18n.translate('unifiedSearch.search.searchBar.savedQueryPopoverSaveChangesButtonText', {
|
||||
defaultMessage: 'Save changes',
|
||||
}),
|
||||
getSavedQueryPopoverSaveAsNewButtonAriaLabel: () =>
|
||||
i18n.translate('unifiedSearch.search.searchBar.savedQueryPopoverSaveAsNewButtonAriaLabel', {
|
||||
defaultMessage: 'Save as new saved query',
|
||||
}),
|
||||
getSavedQueryPopoverSaveAsNewButtonText: () =>
|
||||
i18n.translate('unifiedSearch.search.searchBar.savedQueryPopoverSaveAsNewButtonText', {
|
||||
defaultMessage: 'Save as new',
|
||||
}),
|
||||
getSaveCurrentFilterSetLabel: () =>
|
||||
i18n.translate('unifiedSearch.filter.options.saveCurrentFilterSetLabel', {
|
||||
defaultMessage: 'Save current saved query',
|
||||
}),
|
||||
getApplyAllFiltersButtonLabel: () =>
|
||||
i18n.translate('unifiedSearch.filter.options.applyAllFiltersButtonLabel', {
|
||||
defaultMessage: 'Apply to all',
|
||||
}),
|
||||
getEnableAllFiltersButtonLabel: () =>
|
||||
i18n.translate('unifiedSearch.filter.options.enableAllFiltersButtonLabel', {
|
||||
defaultMessage: 'Enable all',
|
||||
}),
|
||||
getDisableAllFiltersButtonLabel: () =>
|
||||
i18n.translate('unifiedSearch.filter.options.disableAllFiltersButtonLabel', {
|
||||
defaultMessage: 'Disable all',
|
||||
}),
|
||||
getInvertNegatedFiltersButtonLabel: () =>
|
||||
i18n.translate('unifiedSearch.filter.options.invertNegatedFiltersButtonLabel', {
|
||||
defaultMessage: 'Invert inclusion',
|
||||
}),
|
||||
getPinAllFiltersButtonLabel: () =>
|
||||
i18n.translate('unifiedSearch.filter.options.pinAllFiltersButtonLabel', {
|
||||
defaultMessage: 'Pin all',
|
||||
}),
|
||||
getUnpinAllFiltersButtonLabel: () =>
|
||||
i18n.translate('unifiedSearch.filter.options.unpinAllFiltersButtonLabel', {
|
||||
defaultMessage: 'Unpin all',
|
||||
}),
|
||||
getFilterLanguageLabel: () =>
|
||||
i18n.translate('unifiedSearch.filter.options.filterLanguageLabel', {
|
||||
defaultMessage: 'Filter language',
|
||||
}),
|
||||
};
|
||||
|
||||
export interface QueryBarMenuPanelsProps {
|
||||
filters?: Filter[];
|
||||
savedQuery?: SavedQuery;
|
||||
|
@ -226,27 +318,19 @@ export function QueryBarMenuPanels({
|
|||
});
|
||||
};
|
||||
|
||||
const luceneLabel = i18n.translate('unifiedSearch.query.queryBar.luceneLanguageName', {
|
||||
defaultMessage: 'Lucene',
|
||||
});
|
||||
const kqlLabel = i18n.translate('unifiedSearch.query.queryBar.kqlLanguageName', {
|
||||
defaultMessage: 'KQL',
|
||||
});
|
||||
const luceneLabel = strings.getLuceneLanguageName();
|
||||
const kqlLabel = strings.getKqlLanguageName();
|
||||
|
||||
const filtersRelatedPanels = [
|
||||
{
|
||||
name: i18n.translate('unifiedSearch.filter.options.addFilterButtonLabel', {
|
||||
defaultMessage: 'Add filter',
|
||||
}),
|
||||
name: strings.getOptionsAddFilterButtonLabel(),
|
||||
icon: 'plus',
|
||||
onClick: () => {
|
||||
setRenderedComponent('addFilter');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: i18n.translate('unifiedSearch.filter.options.applyAllFiltersButtonLabel', {
|
||||
defaultMessage: 'Apply to all',
|
||||
}),
|
||||
name: strings.getOptionsApplyAllFiltersButtonLabel(),
|
||||
icon: 'filter',
|
||||
panel: 2,
|
||||
disabled: !Boolean(filters && filters.length > 0),
|
||||
|
@ -257,12 +341,8 @@ export function QueryBarMenuPanels({
|
|||
const queryAndFiltersRelatedPanels = [
|
||||
{
|
||||
name: savedQuery
|
||||
? i18n.translate('unifiedSearch.filter.options.loadOtherFilterSetLabel', {
|
||||
defaultMessage: 'Load other saved query',
|
||||
})
|
||||
: i18n.translate('unifiedSearch.filter.options.loadCurrentFilterSetLabel', {
|
||||
defaultMessage: 'Load saved query',
|
||||
}),
|
||||
? strings.getLoadOtherFilterSetLabel()
|
||||
: strings.getLoadCurrentFilterSetLabel(),
|
||||
panel: 4,
|
||||
width: 350,
|
||||
icon: 'filter',
|
||||
|
@ -270,13 +350,7 @@ export function QueryBarMenuPanels({
|
|||
disabled: !savedQueries.length,
|
||||
},
|
||||
{
|
||||
name: savedQuery
|
||||
? i18n.translate('unifiedSearch.filter.options.saveAsNewFilterSetLabel', {
|
||||
defaultMessage: 'Save as new',
|
||||
})
|
||||
: i18n.translate('unifiedSearch.filter.options.saveFilterSetLabel', {
|
||||
defaultMessage: 'Save saved query',
|
||||
}),
|
||||
name: savedQuery ? strings.getSaveAsNewFilterSetLabel() : strings.getSaveFilterSetLabel(),
|
||||
icon: 'save',
|
||||
disabled:
|
||||
!Boolean(showSaveQuery) || !hasFiltersOrQuery || (savedQuery && !savedQueryHasChanged),
|
||||
|
@ -295,9 +369,7 @@ export function QueryBarMenuPanels({
|
|||
if (showFilterBar || showQueryInput) {
|
||||
items.push(
|
||||
{
|
||||
name: i18n.translate('unifiedSearch.filter.options.clearllFiltersButtonLabel', {
|
||||
defaultMessage: 'Clear all',
|
||||
}),
|
||||
name: strings.getClearllFiltersButtonLabel(),
|
||||
disabled: !hasFiltersOrQuery && !Boolean(savedQuery),
|
||||
icon: 'crossInACircleFilled',
|
||||
'data-test-subj': 'filter-sets-removeAllFilters',
|
||||
|
@ -341,11 +413,7 @@ export function QueryBarMenuPanels({
|
|||
data-test-subj="savedQueryTitle"
|
||||
>
|
||||
<strong>
|
||||
{savedQuery
|
||||
? savedQuery.attributes.title
|
||||
: i18n.translate('unifiedSearch.search.searchBar.savedQuery', {
|
||||
defaultMessage: 'Saved query',
|
||||
})}
|
||||
{savedQuery ? savedQuery.attributes.title : strings.getSavedQueryLabel()}
|
||||
</strong>
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
|
@ -364,41 +432,22 @@ export function QueryBarMenuPanels({
|
|||
size="s"
|
||||
fill
|
||||
onClick={handleSave}
|
||||
aria-label={i18n.translate(
|
||||
'unifiedSearch.search.searchBar.savedQueryPopoverSaveChangesButtonAriaLabel',
|
||||
{
|
||||
defaultMessage: 'Save changes to {title}',
|
||||
values: { title: savedQuery?.attributes.title },
|
||||
}
|
||||
aria-label={strings.getSavedQueryPopoverSaveChangesButtonAriaLabel(
|
||||
savedQuery?.attributes.title
|
||||
)}
|
||||
data-test-subj="saved-query-management-save-changes-button"
|
||||
>
|
||||
{i18n.translate(
|
||||
'unifiedSearch.search.searchBar.savedQueryPopoverSaveChangesButtonText',
|
||||
{
|
||||
defaultMessage: 'Save changes',
|
||||
}
|
||||
)}
|
||||
{strings.getSavedQueryPopoverSaveChangesButtonText()}
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
size="s"
|
||||
onClick={handleSaveAsNew}
|
||||
aria-label={i18n.translate(
|
||||
'unifiedSearch.search.searchBar.savedQueryPopoverSaveAsNewButtonAriaLabel',
|
||||
{
|
||||
defaultMessage: 'Save as new saved query',
|
||||
}
|
||||
)}
|
||||
aria-label={strings.getSavedQueryPopoverSaveAsNewButtonAriaLabel()}
|
||||
data-test-subj="saved-query-management-save-as-new-button"
|
||||
>
|
||||
{i18n.translate(
|
||||
'unifiedSearch.search.searchBar.savedQueryPopoverSaveAsNewButtonText',
|
||||
{
|
||||
defaultMessage: 'Save as new',
|
||||
}
|
||||
)}
|
||||
{strings.getSavedQueryPopoverSaveAsNewButtonText()}
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
|
@ -411,23 +460,17 @@ export function QueryBarMenuPanels({
|
|||
},
|
||||
{
|
||||
id: 1,
|
||||
title: i18n.translate('unifiedSearch.filter.options.saveCurrentFilterSetLabel', {
|
||||
defaultMessage: 'Save current saved query',
|
||||
}),
|
||||
title: strings.getSaveCurrentFilterSetLabel(),
|
||||
disabled: !Boolean(showSaveQuery),
|
||||
content: <div style={{ padding: 16 }}>{saveAsNewQueryFormComponent}</div>,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
initialFocusedItemIndex: 1,
|
||||
title: i18n.translate('unifiedSearch.filter.options.applyAllFiltersButtonLabel', {
|
||||
defaultMessage: 'Apply to all',
|
||||
}),
|
||||
title: strings.getApplyAllFiltersButtonLabel(),
|
||||
items: [
|
||||
{
|
||||
name: i18n.translate('unifiedSearch.filter.options.enableAllFiltersButtonLabel', {
|
||||
defaultMessage: 'Enable all',
|
||||
}),
|
||||
name: strings.getEnableAllFiltersButtonLabel(),
|
||||
icon: 'eye',
|
||||
'data-test-subj': 'filter-sets-enableAllFilters',
|
||||
onClick: () => {
|
||||
|
@ -436,9 +479,7 @@ export function QueryBarMenuPanels({
|
|||
},
|
||||
},
|
||||
{
|
||||
name: i18n.translate('unifiedSearch.filter.options.disableAllFiltersButtonLabel', {
|
||||
defaultMessage: 'Disable all',
|
||||
}),
|
||||
name: strings.getDisableAllFiltersButtonLabel(),
|
||||
'data-test-subj': 'filter-sets-disableAllFilters',
|
||||
icon: 'eyeClosed',
|
||||
onClick: () => {
|
||||
|
@ -447,9 +488,7 @@ export function QueryBarMenuPanels({
|
|||
},
|
||||
},
|
||||
{
|
||||
name: i18n.translate('unifiedSearch.filter.options.invertNegatedFiltersButtonLabel', {
|
||||
defaultMessage: 'Invert inclusion',
|
||||
}),
|
||||
name: strings.getInvertNegatedFiltersButtonLabel(),
|
||||
'data-test-subj': 'filter-sets-invertAllFilters',
|
||||
icon: 'invert',
|
||||
onClick: () => {
|
||||
|
@ -458,9 +497,7 @@ export function QueryBarMenuPanels({
|
|||
},
|
||||
},
|
||||
{
|
||||
name: i18n.translate('unifiedSearch.filter.options.pinAllFiltersButtonLabel', {
|
||||
defaultMessage: 'Pin all',
|
||||
}),
|
||||
name: strings.getPinAllFiltersButtonLabel(),
|
||||
'data-test-subj': 'filter-sets-pinAllFilters',
|
||||
icon: 'pin',
|
||||
onClick: () => {
|
||||
|
@ -469,9 +506,7 @@ export function QueryBarMenuPanels({
|
|||
},
|
||||
},
|
||||
{
|
||||
name: i18n.translate('unifiedSearch.filter.options.unpinAllFiltersButtonLabel', {
|
||||
defaultMessage: 'Unpin all',
|
||||
}),
|
||||
name: strings.getUnpinAllFiltersButtonLabel(),
|
||||
'data-test-subj': 'filter-sets-unpinAllFilters',
|
||||
icon: 'pin',
|
||||
onClick: () => {
|
||||
|
@ -483,9 +518,7 @@ export function QueryBarMenuPanels({
|
|||
},
|
||||
{
|
||||
id: 3,
|
||||
title: i18n.translate('unifiedSearch.filter.options.filterLanguageLabel', {
|
||||
defaultMessage: 'Filter language',
|
||||
}),
|
||||
title: strings.getFilterLanguageLabel(),
|
||||
content: (
|
||||
<QueryLanguageSwitcher
|
||||
language={language}
|
||||
|
@ -500,9 +533,7 @@ export function QueryBarMenuPanels({
|
|||
},
|
||||
{
|
||||
id: 4,
|
||||
title: i18n.translate('unifiedSearch.filter.options.loadCurrentFilterSetLabel', {
|
||||
defaultMessage: 'Load saved query',
|
||||
}),
|
||||
title: strings.getLoadCurrentFilterSetLabel(),
|
||||
width: 400,
|
||||
content: <div>{manageFilterSetComponent}</div>,
|
||||
},
|
||||
|
|
|
@ -27,8 +27,8 @@ import {
|
|||
useIsWithinBreakpoints,
|
||||
EuiSuperUpdateButton,
|
||||
} from '@elastic/eui';
|
||||
import { TimeHistoryContract, getQueryLog } from '@kbn/data-plugin/public';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { TimeHistoryContract, getQueryLog } from '@kbn/data-plugin/public';
|
||||
import { DataView } from '@kbn/data-views-plugin/public';
|
||||
import type { PersistedLog } from '@kbn/data-plugin/public';
|
||||
import { useKibana } from '@kbn/kibana-react-plugin/public';
|
||||
|
@ -48,6 +48,21 @@ import type { SuggestionsListSize } from '../typeahead/suggestions_component';
|
|||
import { TextBasedLanguagesEditor } from './text_based_languages_editor';
|
||||
import './query_bar.scss';
|
||||
|
||||
export const strings = {
|
||||
getNeedsUpdatingLabel: () =>
|
||||
i18n.translate('unifiedSearch.queryBarTopRow.submitButton.update', {
|
||||
defaultMessage: 'Needs updating',
|
||||
}),
|
||||
getRefreshQueryLabel: () =>
|
||||
i18n.translate('unifiedSearch.queryBarTopRow.submitButton.refresh', {
|
||||
defaultMessage: 'Refresh query',
|
||||
}),
|
||||
getRunQueryLabel: () =>
|
||||
i18n.translate('unifiedSearch.queryBarTopRow.submitButton.run', {
|
||||
defaultMessage: 'Run query',
|
||||
}),
|
||||
};
|
||||
|
||||
const SuperDatePicker = React.memo(
|
||||
EuiSuperDatePicker as any
|
||||
) as unknown as typeof EuiSuperDatePicker;
|
||||
|
@ -405,19 +420,9 @@ export const QueryBarTopRow = React.memo(
|
|||
if (!shouldRenderUpdatebutton() && !shouldRenderDatePicker()) {
|
||||
return null;
|
||||
}
|
||||
const buttonLabelUpdate = i18n.translate('unifiedSearch.queryBarTopRow.submitButton.update', {
|
||||
defaultMessage: 'Needs updating',
|
||||
});
|
||||
const buttonLabelRefresh = i18n.translate(
|
||||
'unifiedSearch.queryBarTopRow.submitButton.refresh',
|
||||
{
|
||||
defaultMessage: 'Refresh query',
|
||||
}
|
||||
);
|
||||
|
||||
const buttonLabelRun = i18n.translate('unifiedSearch.queryBarTopRow.submitButton.run', {
|
||||
defaultMessage: 'Run query',
|
||||
});
|
||||
const buttonLabelUpdate = strings.getNeedsUpdatingLabel();
|
||||
const buttonLabelRefresh = strings.getRefreshQueryLabel();
|
||||
const buttonLabelRun = strings.getRunQueryLabel();
|
||||
|
||||
const iconDirty = Boolean(isQueryLangSelected) ? 'play' : 'kqlFunction';
|
||||
const tooltipDirty = Boolean(isQueryLangSelected) ? buttonLabelRun : buttonLabelUpdate;
|
||||
|
|
|
@ -7,8 +7,6 @@
|
|||
*/
|
||||
|
||||
import React, { PureComponent } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { METRIC_TYPE } from '@kbn/analytics';
|
||||
|
||||
|
@ -26,6 +24,7 @@ import {
|
|||
PopoverAnchorPosition,
|
||||
toSentenceCase,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { compact, debounce, isEmpty, isEqual, isFunction, partition } from 'lodash';
|
||||
import { CoreStart, DocLinksStart, Toast } from '@kbn/core/public';
|
||||
|
@ -50,6 +49,36 @@ import { AutocompleteService, QuerySuggestion, QuerySuggestionTypes } from '../a
|
|||
import { getTheme } from '../services';
|
||||
import './query_string_input.scss';
|
||||
|
||||
export const strings = {
|
||||
getSearchInputPlaceholderForText: () =>
|
||||
i18n.translate('unifiedSearch.query.queryBar.searchInputPlaceholderForText', {
|
||||
defaultMessage: 'Filter your data',
|
||||
}),
|
||||
getSearchInputPlaceholder: (language: string) =>
|
||||
i18n.translate('unifiedSearch.query.queryBar.searchInputPlaceholder', {
|
||||
defaultMessage: 'Filter your data using {language} syntax',
|
||||
values: { language },
|
||||
}),
|
||||
getQueryBarComboboxAriaLabel: (pageType: string) =>
|
||||
i18n.translate('unifiedSearch.query.queryBar.comboboxAriaLabel', {
|
||||
defaultMessage: 'Search and filter the {pageType} page',
|
||||
values: { pageType },
|
||||
}),
|
||||
getQueryBarSearchInputAriaLabel: (pageType: string) =>
|
||||
i18n.translate('unifiedSearch.query.queryBar.searchInputAriaLabel', {
|
||||
defaultMessage: 'Start typing to search and filter the {pageType} page',
|
||||
values: { pageType },
|
||||
}),
|
||||
getQueryBarClearInputLabel: () =>
|
||||
i18n.translate('unifiedSearch.query.queryBar.clearInputLabel', {
|
||||
defaultMessage: 'Clear input',
|
||||
}),
|
||||
getKQLNestedQuerySyntaxInfoTitle: () =>
|
||||
i18n.translate('unifiedSearch.query.queryBar.KQLNestedQuerySyntaxInfoTitle', {
|
||||
defaultMessage: 'KQL nested query syntax',
|
||||
}),
|
||||
};
|
||||
|
||||
export interface QueryStringInputDependencies {
|
||||
unifiedSearch: {
|
||||
autocomplete: ReturnType<AutocompleteService['start']>;
|
||||
|
@ -484,9 +513,7 @@ export default class QueryStringInputUI extends PureComponent<QueryStringInputPr
|
|||
|
||||
if (notifications && docLinks) {
|
||||
const toast = notifications.toasts.add({
|
||||
title: i18n.translate('unifiedSearch.query.queryBar.KQLNestedQuerySyntaxInfoTitle', {
|
||||
defaultMessage: 'KQL nested query syntax',
|
||||
}),
|
||||
title: strings.getKQLNestedQuerySyntaxInfoTitle(),
|
||||
text: toMountPoint(
|
||||
<div>
|
||||
<p>
|
||||
|
@ -707,22 +734,13 @@ export default class QueryStringInputUI extends PureComponent<QueryStringInputPr
|
|||
};
|
||||
|
||||
getSearchInputPlaceholder = () => {
|
||||
let placeholder = '';
|
||||
if (!this.props.query.language || this.props.query.language === 'text') {
|
||||
placeholder = i18n.translate('unifiedSearch.query.queryBar.searchInputPlaceholderForText', {
|
||||
defaultMessage: 'Filter your data',
|
||||
});
|
||||
} else {
|
||||
const language =
|
||||
this.props.query.language === 'kuery' ? 'KQL' : toSentenceCase(this.props.query.language);
|
||||
|
||||
placeholder = i18n.translate('unifiedSearch.query.queryBar.searchInputPlaceholder', {
|
||||
defaultMessage: 'Filter your data using {language} syntax',
|
||||
values: { language },
|
||||
});
|
||||
return strings.getSearchInputPlaceholderForText();
|
||||
}
|
||||
const language =
|
||||
this.props.query.language === 'kuery' ? 'KQL' : toSentenceCase(this.props.query.language);
|
||||
|
||||
return placeholder;
|
||||
return strings.getSearchInputPlaceholder(language);
|
||||
};
|
||||
|
||||
public render() {
|
||||
|
@ -766,10 +784,7 @@ export default class QueryStringInputUI extends PureComponent<QueryStringInputPr
|
|||
<div
|
||||
{...ariaCombobox}
|
||||
style={{ position: 'relative', width: '100%' }}
|
||||
aria-label={i18n.translate('unifiedSearch.query.queryBar.comboboxAriaLabel', {
|
||||
defaultMessage: 'Search and filter the {pageType} page',
|
||||
values: { pageType: this.props.appName },
|
||||
})}
|
||||
aria-label={strings.getQueryBarComboboxAriaLabel(this.props.appName)}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={this.state.isSuggestionsVisible}
|
||||
data-skip-axe="aria-required-children"
|
||||
|
@ -795,10 +810,7 @@ export default class QueryStringInputUI extends PureComponent<QueryStringInputPr
|
|||
inputRef={this.assignInputRef}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
aria-label={i18n.translate('unifiedSearch.query.queryBar.searchInputAriaLabel', {
|
||||
defaultMessage: 'Start typing to search and filter the {pageType} page',
|
||||
values: { pageType: this.props.appName },
|
||||
})}
|
||||
aria-label={strings.getQueryBarSearchInputAriaLabel(this.props.appName)}
|
||||
aria-autocomplete="list"
|
||||
aria-controls={this.state.isSuggestionsVisible ? 'kbnTypeahead__items' : undefined}
|
||||
aria-activedescendant={
|
||||
|
@ -826,9 +838,7 @@ export default class QueryStringInputUI extends PureComponent<QueryStringInputPr
|
|||
<button
|
||||
type="button"
|
||||
className="euiFormControlLayoutClearButton"
|
||||
title={i18n.translate('unifiedSearch.query.queryBar.clearInputLabel', {
|
||||
defaultMessage: 'Clear input',
|
||||
})}
|
||||
title={strings.getQueryBarClearInputLabel()}
|
||||
onClick={() => {
|
||||
this.onQueryStringChange('');
|
||||
if (this.props.autoSubmit) {
|
||||
|
|
|
@ -6,21 +6,14 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { isCombinedFilter, FilterItem } from '@kbn/es-query';
|
||||
|
||||
export enum ConditionTypes {
|
||||
OR = 'OR',
|
||||
AND = 'AND',
|
||||
}
|
||||
import { type Filter, isCombinedFilter, CombinedFilter } from '@kbn/es-query';
|
||||
|
||||
/**
|
||||
* Defines a conditional operation type (AND/OR) from the filter otherwise returns undefined.
|
||||
* @param {FilterItem} filter
|
||||
* Defines a boolean relation type (AND/OR) from the filter otherwise returns undefined.
|
||||
* @param {Filter} filter
|
||||
*/
|
||||
export const getConditionalOperationType = (filter: FilterItem) => {
|
||||
if (Array.isArray(filter)) {
|
||||
return ConditionTypes.AND;
|
||||
} else if (isCombinedFilter(filter)) {
|
||||
return ConditionTypes.OR;
|
||||
export const getBooleanRelationType = (filter: Filter | CombinedFilter) => {
|
||||
if (isCombinedFilter(filter)) {
|
||||
return filter.meta.relation;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -9,4 +9,4 @@
|
|||
export { onRaf } from './on_raf';
|
||||
export { shallowEqual } from './shallow_equal';
|
||||
|
||||
export { ConditionTypes, getConditionalOperationType } from './combined_filter';
|
||||
export { getBooleanRelationType } from './combined_filter';
|
||||
|
|
|
@ -24,16 +24,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
await PageObjects.discover.openAddFilterPanel();
|
||||
await a11y.testAppSnapshot();
|
||||
await PageObjects.discover.closeAddFilterPanel();
|
||||
await filterBar.addFilter('OriginCityName', 'is', 'Rome');
|
||||
});
|
||||
|
||||
it('a11y test on filter panel with custom label', async () => {
|
||||
await filterBar.clickEditFilter('OriginCityName', 'Rome');
|
||||
await testSubjects.click('createCustomLabel');
|
||||
await a11y.testAppSnapshot();
|
||||
await filterBar.addFilter({ field: 'OriginCityName', operation: 'is', value: 'Rome' });
|
||||
});
|
||||
|
||||
it('a11y test on Edit filter as Query DSL panel', async () => {
|
||||
await filterBar.clickEditFilter('OriginCityName', 'Rome');
|
||||
await testSubjects.click('editQueryDSL');
|
||||
await a11y.testAppSnapshot();
|
||||
await browser.pressKeys(browser.keys.ESCAPE);
|
||||
|
@ -41,7 +36,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
|
||||
// the following tests are for the new saved query panel which also has filter panel options
|
||||
it('a11y test on saved query panel- on more than one filters', async () => {
|
||||
await filterBar.addFilter('DestCountry', 'is', 'AU');
|
||||
await filterBar.addFilter({ field: 'DestCountry', operation: 'is', value: 'AU' });
|
||||
await testSubjects.click('queryBarMenuPopover');
|
||||
await a11y.testAppSnapshot();
|
||||
});
|
||||
|
|
|
@ -45,7 +45,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
await PageObjects.common.navigateToApp('discover');
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
for (const [columnName, value] of TEST_FILTER_COLUMN_NAMES) {
|
||||
await filterBar.addFilter(columnName, 'is', value);
|
||||
await filterBar.addFilter({ field: columnName, operation: 'is', value });
|
||||
}
|
||||
});
|
||||
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue