diff --git a/packages/kbn-es-query/index.ts b/packages/kbn-es-query/index.ts index 43c660544bc90..a43306211b5d4 100644 --- a/packages/kbn-es-query/index.ts +++ b/packages/kbn-es-query/index.ts @@ -107,6 +107,7 @@ export { extractTimeFilter, extractTimeRange, convertRangeFilterToTimeRange, + BooleanRelation, } from './src/filters'; export { diff --git a/packages/kbn-es-query/src/es_query/handle_combined_filter.test.ts b/packages/kbn-es-query/src/es_query/handle_combined_filter.test.ts index 0cac2cb12f607..ec026cf15d9ca 100644 --- a/packages/kbn-es-query/src/es_query/handle_combined_filter.test.ts +++ b/packages/kbn-es-query/src/es_query/handle_combined_filter.test.ts @@ -10,8 +10,9 @@ import { fields } from '../filters/stubs'; import { DataViewBase } from './types'; import { handleCombinedFilter } from './handle_combined_filter'; import { - buildExistsFilter, + BooleanRelation, buildCombinedFilter, + buildExistsFilter, buildPhraseFilter, buildPhrasesFilter, buildRangeFilter, @@ -30,581 +31,616 @@ describe('#handleCombinedFilter', function () { 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 [], - }, - } - `); - }); + describe('AND relation', () => { + it('Generates an empty bool should clause with no filters', () => { + const filter = buildCombinedFilter(BooleanRelation.AND, []); + const result = handleCombinedFilter(filter); + expect(result.query).toMatchInlineSnapshot(` + Object { + "bool": Object { + "filter": Array [], + "must": Array [], + "must_not": Array [], + "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 [], + 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); + const result = handleCombinedFilter(filter); + expect(result.query).toMatchInlineSnapshot(` + Object { + "bool": Object { + "filter": Array [ + Object { + "match_phrase": Object { + "extension": "value", + }, }, - }, - Object { - "bool": Object { - "filter": Array [ - Object { - "range": Object { - "bytes": Object { - "gte": 10, - }, - }, + 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 [], + 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), - [ + 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(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", - }, + ]; + const filter = buildCombinedFilter(BooleanRelation.AND, filters); + const result = handleCombinedFilter(filter); + expect(result.query).toMatchInlineSnapshot(` + 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", + }, }, - }, - Object { - "bool": Object { - "filter": Array [ - Object { - "range": Object { - "bytes": Object { - "gte": 10, + ], + "must": Array [], + "must_not": Array [ + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match_phrase": Object { + "extension": "tar", }, }, - }, - Object { - "exists": Object { - "field": "machine.os", + Object { + "match_phrase": Object { + "extension": "gz", + }, }, - }, - ], - "must": Array [], - "must_not": Array [], - "should": 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 [], + it('Handles disabled sub-filters', () => { + const disabledFilter = buildPhraseFilter(getField('ssl'), false, indexPattern); + disabledFilter.meta.disabled = true; + const filters = [ + buildPhraseFilter(getField('extension'), 'value', indexPattern), + disabledFilter, + buildExistsFilter(getField('machine.os'), indexPattern), + ]; + const filter = buildCombinedFilter(BooleanRelation.AND, filters); + const result = handleCombinedFilter(filter); + expect(result.query).toMatchInlineSnapshot(` + Object { + "bool": Object { + "filter": Array [ + Object { + "match_phrase": Object { + "extension": "value", + }, }, - }, - 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 [], - }, - }, - ], - }, + 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.AND, filters); + const { query, ...rest } = handleCombinedFilter(filter); + expect(rest).toMatchInlineSnapshot(` + Object { + "$state": Object { + "store": "appState", + }, + "meta": Object { + "alias": undefined, + "disabled": false, + "index": undefined, + "negate": false, + "params": Array [ + Object { + "meta": Object { + "index": "logstash-*", + }, + "query": 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 { + "meta": Object { + "field": "bytes", + "index": "logstash-*", + "params": Object {}, + }, + "query": 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", - }, + Object { + "meta": Object { + "index": "logstash-*", + }, + "query": Object { + "exists": Object { + "field": "machine.os", }, - ], - "must": Array [], - "must_not": Array [], - "should": Array [], + }, }, - }, - ], - }, - } - `); + ], + "relation": "AND", + "type": "combined", + }, + } + `); + }); }); - it('Handles negated sub-filters', () => { - const negatedFilter = buildPhrasesFilter(getField('extension'), ['tar', 'gz'], indexPattern); - negatedFilter.meta.negate = true; + describe('OR relation', () => { + it('Generates an empty bool should clause with no filters', () => { + const filter = buildCombinedFilter(BooleanRelation.OR, []); + const result = handleCombinedFilter(filter); + expect(result.query).toMatchInlineSnapshot(` + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [], + }, + } + `); + }); - 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", + 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); + 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", - }, + ], + "must": Array [], + "must_not": Array [], + "should": Array [], + }, + }, + Object { + "bool": Object { + "filter": Array [ + Object { + "range": Object { + "bytes": Object { + "gte": 10, }, - Object { - "match_phrase": Object { - "extension": "gz", + }, + }, + ], + "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); + const result = handleCombinedFilter(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 [], + ], + "should": Array [], + }, }, - }, - Object { - "bool": Object { - "filter": Array [ - Object { - "range": Object { - "bytes": Object { - "gte": 10, + Object { + "bool": Object { + "filter": Array [ + 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", + Object { + "bool": Object { + "filter": Array [ + Object { + "exists": Object { + "field": "machine.os", + }, }, - }, - ], - "must": Array [], - "must_not": Array [], - "should": Array [], + ], + "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", + it('Handles disabled sub-filters', () => { + const disabledFilter = buildPhraseFilter(getField('ssl'), false, indexPattern); + disabledFilter.meta.disabled = true; + const filters = [ + buildPhraseFilter(getField('extension'), 'value', indexPattern), + disabledFilter, + buildExistsFilter(getField('machine.os'), indexPattern), + ]; + const filter = buildCombinedFilter(BooleanRelation.OR, 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 [], + ], + "must": Array [], + "must_not": Array [], + "should": Array [], + }, + }, + Object { + "bool": Object { + "filter": Array [], + "must": Array [], + "must_not": Array [], + "should": Array [], + }, }, - }, - Object { - "bool": Object { - "filter": Array [ - Object { - "range": Object { - "bytes": Object { - "gte": 10, + 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); + const { query, ...rest } = handleCombinedFilter(filter); + expect(rest).toMatchInlineSnapshot(` + Object { + "$state": Object { + "store": "appState", + }, + "meta": Object { + "alias": undefined, + "disabled": false, + "index": undefined, + "negate": false, + "params": Array [ + Object { + "meta": Object { + "index": "logstash-*", + }, + "query": Object { + "match_phrase": Object { + "extension": "value", }, - ], - "must": Array [], - "must_not": Array [], - "should": Array [], + }, }, - }, - Object { - "bool": Object { - "filter": Array [ - Object { - "exists": Object { - "field": "machine.os", + Object { + "meta": Object { + "field": "bytes", + "index": "logstash-*", + "params": Object {}, + }, + "query": Object { + "range": Object { + "bytes": Object { + "gte": 10, }, }, - ], - "must": Array [], - "must_not": Array [], - "should": Array [], + }, + }, + Object { + "meta": Object { + "index": "logstash-*", + }, + "query": Object { + "exists": Object { + "field": "machine.os", + }, + }, }, - }, - ], - }, - } - `); + ], + "relation": "OR", + "type": "combined", + }, + } + `); + }); }); - 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), + 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), + ]), + buildExistsFilter(getField('machine.os'), 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", + buildPhrasesFilter(getField('machine.os.keyword'), ['foo', 'bar'], indexPattern), + ]; + const filter = buildCombinedFilter(BooleanRelation.AND, filters); + const result = handleCombinedFilter(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 { - "match_phrase": Object { - "ssl": false, + Object { + "bool": Object { + "filter": Array [ + Object { + "match_phrase": Object { + "ssl": false, + }, + }, + ], + "must": Array [], + "must_not": Array [], + "should": Array [], + }, }, - }, - Object { - "bool": Object { - "minimum_should_match": 1, - "should": Array [ - Object { - "bool": Object { - "filter": Array [ - Object { - "match_phrase": Object { - "extension": "value", + Object { + "bool": Object { + "filter": 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 { + "range": Object { + "bytes": Object { + "gte": 10, + }, }, }, - }, - ], - "must": Array [], - "must_not": Array [], - "should": Array [], + ], + "must": Array [], + "must_not": Array [], + "should": Array [], + }, }, - }, - ], - }, - }, - Object { - "exists": Object { - "field": "machine.os", + ], + "must": Array [], + "must_not": Array [], + "should": Array [], + }, }, - }, - ], - "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 { + "bool": Object { + "filter": Array [ + Object { + "exists": Object { + "field": "machine.os", + }, }, - }, - Object { - "match_phrase": Object { - "machine.os.keyword": "bar", - }, - }, - ], + ], + "must": Array [], + "must_not": Array [], + "should": Array [], + }, }, - }, - ], - "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", + 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", + }, + }, + ], }, }, - }, - ], - "type": "combined", - }, - } - `); + ], + "must": Array [], + "must_not": Array [], + "should": Array [], + }, + } + `); + }); }); }); diff --git a/packages/kbn-es-query/src/es_query/handle_combined_filter.ts b/packages/kbn-es-query/src/es_query/handle_combined_filter.ts index a9daf3fc4f33b..4055d2473449d 100644 --- a/packages/kbn-es-query/src/es_query/handle_combined_filter.ts +++ b/packages/kbn-es-query/src/es_query/handle_combined_filter.ts @@ -6,9 +6,10 @@ * Side Public License, v 1. */ -import { Filter, FilterItem, isCombinedFilter } from '../filters'; +import { Filter, isCombinedFilter } from '../filters'; import { DataViewBase } from './types'; import { buildQueryFromFilters, EsQueryFiltersConfig } from './from_filters'; +import { BooleanRelation } from '../filters/build_filters'; /** @internal */ export const handleCombinedFilter = ( @@ -18,24 +19,15 @@ export const handleCombinedFilter = ( ): 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((result, filter) => { - if (Array.isArray(filter)) return [...result, ...flattenFilters(filter)]; - return [...result, filter]; - }, []); -} + if (filter.meta.relation === BooleanRelation.AND) { + const bool = buildQueryFromFilters(filter.meta.params, inputDataViews, options); + return { ...filter, query: { bool } }; + } + + const should = params.map((subFilter) => ({ + bool: buildQueryFromFilters([subFilter], inputDataViews, options), + })); + const bool = { should, minimum_should_match: 1 }; + return { ...filter, query: { bool } }; +}; diff --git a/packages/kbn-es-query/src/filters/build_filters/combined_filter.ts b/packages/kbn-es-query/src/filters/build_filters/combined_filter.ts index 4054f25ce45f6..5b15de56e7466 100644 --- a/packages/kbn-es-query/src/filters/build_filters/combined_filter.ts +++ b/packages/kbn-es-query/src/filters/build_filters/combined_filter.ts @@ -10,18 +10,20 @@ import { Filter, FilterMeta, FILTERS } from './types'; import { buildEmptyFilter } from './build_empty_filter'; /** - * 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[]; } /** @@ -39,18 +41,25 @@ export function isCombinedFilter(filter: Filter): filter is CombinedFilter { } /** - * 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 { +export function buildCombinedFilter( + relation: BooleanRelation, + filters: Filter[], + alias?: string | null +): CombinedFilter { const filter = buildEmptyFilter(false); return { ...filter, meta: { ...filter.meta, type: FILTERS.COMBINED, + relation, + alias, params: filters, }, }; diff --git a/packages/kbn-es-query/src/filters/index.ts b/packages/kbn-es-query/src/filters/index.ts index 93efb9b1cd61f..22a60b8e7090b 100644 --- a/packages/kbn-es-query/src/filters/index.ts +++ b/packages/kbn-es-query/src/filters/index.ts @@ -57,6 +57,7 @@ export { isScriptedPhraseFilter, isScriptedRangeFilter, getFilterParams, + BooleanRelation, } from './build_filters'; export type { @@ -79,7 +80,6 @@ export type { QueryStringFilter, CombinedFilter, CombinedFilterMeta, - FilterItem, } from './build_filters'; export { FilterStateStore, FILTERS } from './build_filters/types'; diff --git a/src/plugins/data/public/query/filter_manager/lib/map_filter.ts b/src/plugins/data/public/query/filter_manager/lib/map_filter.ts index 73144e14bc150..6691a337a95d0 100644 --- a/src/plugins/data/public/query/filter_manager/lib/map_filter.ts +++ b/src/plugins/data/public/query/filter_manager/lib/map_filter.ts @@ -9,6 +9,7 @@ import { 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, diff --git a/src/plugins/data/public/query/filter_manager/lib/mappers/map_combined.test.ts b/src/plugins/data/public/query/filter_manager/lib/mappers/map_combined.test.ts new file mode 100644 index 0000000000000..e5fb14218da6b --- /dev/null +++ b/src/plugins/data/public/query/filter_manager/lib/mappers/map_combined.test.ts @@ -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 { + 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]); + 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", + } + `); + }); + }); +}); diff --git a/src/plugins/data/public/query/filter_manager/lib/mappers/map_combined.ts b/src/plugins/data/public/query/filter_manager/lib/mappers/map_combined.ts new file mode 100644 index 0000000000000..d667cc91d4a16 --- /dev/null +++ b/src/plugins/data/public/query/filter_manager/lib/mappers/map_combined.ts @@ -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), + }; +}; diff --git a/src/plugins/unified_search/public/filter_badge/filter_badge.tsx b/src/plugins/unified_search/public/filter_badge/filter_badge.tsx new file mode 100644 index 0000000000000..82bb6ac0ee799 --- /dev/null +++ b/src/plugins/unified_search/public/filter_badge/filter_badge.tsx @@ -0,0 +1,108 @@ +/* + * 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, { useMemo } from 'react'; +import { EuiBadge, EuiFlexGroup, EuiIcon, EuiTextColor, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/css'; +import type { DataView } from '@kbn/data-views-plugin/common'; +import type { Filter } from '@kbn/es-query'; +import { BooleanRelation, isCombinedFilter } from '@kbn/es-query'; +import { i18n } from '@kbn/i18n'; +import { FilterBadgeGroup } from './filter_badge_group'; +import { FilterLabelStatus } from '../filter_bar/filter_item/filter_item'; + +export interface FilterBadgeProps { + filter: Filter; + dataViews: DataView[]; + valueLabel?: string; + hideAlias?: boolean; + filterLabelStatus?: FilterLabelStatus; +} + +const rootLevelConditionType = BooleanRelation.AND; + +function FilterBadge({ + filter, + dataViews, + valueLabel, + hideAlias, + filterLabelStatus, + ...rest +}: FilterBadgeProps) { + const { euiTheme } = useEuiTheme(); + + const badgePading = useMemo( + () => css` + padding: calc(${euiTheme.size.xs} + ${euiTheme.size.xxs}); + `, + [euiTheme.size.xs, euiTheme.size.xxs] + ); + + const marginLeftLabel = useMemo( + () => css` + margin-left: ${euiTheme.size.xs}; + `, + [euiTheme.size.xs] + ); + + if (!dataViews.length) { + return null; + } + + const prefixText = filter.meta.negate + ? ` ${i18n.translate('unifiedSearch.filter.filterBar.negatedFilterPrefix', { + defaultMessage: 'NOT ', + })}` + : ''; + + const prefix = + filter.meta.negate && !filter.meta.disabled ? ( + {prefixText} + ) : ( + prefixText + ); + + const getValue = (text?: string) => { + return {text}; + }; + + return ( + + {!hideAlias && filter.meta.alias !== null ? ( + <> + + + {prefix} + {filter.meta.alias} + {filterLabelStatus && <>: {getValue(valueLabel)}} + + + ) : ( + + + + )} + + ); +} + +// React.lazy support +// eslint-disable-next-line import/no-default-export +export default FilterBadge; diff --git a/src/plugins/unified_search/public/filter_badge/filter_badge_expression.tsx b/src/plugins/unified_search/public/filter_badge/filter_badge_expression.tsx new file mode 100644 index 0000000000000..ad39318342e22 --- /dev/null +++ b/src/plugins/unified_search/public/filter_badge/filter_badge_expression.tsx @@ -0,0 +1,80 @@ +/* + * 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, { useMemo } from 'react'; +import type { DataView } from '@kbn/data-views-plugin/common'; +import type { Filter } from '@kbn/es-query'; +import type { BooleanRelation } from '@kbn/es-query'; +import { EuiTextColor, useEuiPaddingCSS, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/css'; +import { FilterBadgeGroup } from './filter_badge_group'; +import type { LabelOptions } from './filter_badge_utils'; +import { FILTER_LABLE_STATUS, getValueLabel } from './filter_badge_utils'; +import { FilterContent } from './filter_badge_expression_filter_content'; +import { getBooleanRelationType } from '../utils'; + +export interface FilterBadgeExpressionProps { + filter: Filter; + dataViews: DataView[]; + booleanRelation?: BooleanRelation; + isRootLevel?: boolean; +} + +export function FilterExpressionBadge({ + filter, + dataViews, + isRootLevel, +}: FilterBadgeExpressionProps) { + const conditionalOperationType = getBooleanRelationType(filter); + + const paddingLeft = useEuiPaddingCSS('left').xs; + const paddingRight = useEuiPaddingCSS('right').xs; + + const { euiTheme } = useEuiTheme(); + + const bracketСolor = useMemo( + () => css` + color: ${euiTheme.colors.primary}; + `, + [euiTheme.colors.primary] + ); + + let label: LabelOptions = { + title: '', + message: '', + status: FILTER_LABLE_STATUS.FILTER_ITEM_OK, + }; + + if (!conditionalOperationType) { + label = getValueLabel(filter, dataViews); + } + + return conditionalOperationType ? ( + <> + {!isRootLevel ? ( + + ( + + ) : null} + + {!isRootLevel ? ( + + ) + + ) : null} + + ) : ( + + + + ); +} diff --git a/src/plugins/unified_search/public/filter_badge/filter_badge_expression_filter_content.tsx b/src/plugins/unified_search/public/filter_badge/filter_badge_expression_filter_content.tsx new file mode 100644 index 0000000000000..cf15686ea8730 --- /dev/null +++ b/src/plugins/unified_search/public/filter_badge/filter_badge_expression_filter_content.tsx @@ -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 { EuiTextColor } from '@elastic/eui'; +import type { Filter } from '@kbn/es-query'; +import { FILTERS } from '@kbn/es-query'; +import { i18n } from '@kbn/i18n'; +import type { LabelOptions } from './filter_badge_utils'; +import { existsOperator, isOneOfOperator } from '../filter_bar/filter_editor'; + +const FilterBadgeExpressionValueRightPart = ({ value }: { value: string | number }) => { + return ( + {value} + ); +}; + +const FilterBadgeExpressionValueLeftPart = ({ filter }: { filter: Filter }) => { + return ( + <> + + {filter.meta.key}: + + ); +}; + +const Prefix = ({ prefix }: { prefix?: boolean }) => + prefix ? ( + + {i18n.translate('unifiedSearch.filter.filterBar.negatedFilterPrefix', { + defaultMessage: 'NOT ', + })} + + ) : null; + +export const FilterContent = ({ filter, label }: { filter: Filter; label: LabelOptions }) => { + switch (filter.meta.type) { + case FILTERS.EXISTS: + return ( + <> + + + + ); + case FILTERS.PHRASES: + return ( + <> + + + + ); + case FILTERS.QUERY_STRING: + return ( + <> + {' '} + + + ); + case FILTERS.PHRASE: + case FILTERS.RANGE: + return ( + <> + + + + ); + default: + return ( + <> + + + + ); + } +}; diff --git a/src/plugins/unified_search/public/filter_badge/filter_badge_group.tsx b/src/plugins/unified_search/public/filter_badge/filter_badge_group.tsx new file mode 100644 index 0000000000000..388980f8a3b6c --- /dev/null +++ b/src/plugins/unified_search/public/filter_badge/filter_badge_group.tsx @@ -0,0 +1,53 @@ +/* + * 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, { useMemo } from 'react'; +import type { DataView } from '@kbn/data-views-plugin/common'; +import type { Filter, BooleanRelation } from '@kbn/es-query'; +import { EuiTextColor, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/css'; +import { FilterExpressionBadge } from './filter_badge_expression'; + +export interface FilterBadgeGroupProps { + filters: Filter[]; + dataViews: DataView[]; + booleanRelation?: BooleanRelation; + isRootLevel?: boolean; +} + +const BooleanRelationDelimiter = ({ conditional }: { conditional: BooleanRelation }) => { + const { euiTheme } = useEuiTheme(); + const bracketСolor = useMemo( + () => css` + color: ${euiTheme.colors.primary}; + `, + [euiTheme.colors.primary] + ); + + return {conditional}; +}; + +export function FilterBadgeGroup({ + filters, + dataViews, + booleanRelation, + isRootLevel, +}: FilterBadgeGroupProps) { + return ( + <> + {filters.map((filter, index, acc) => ( + <> + + {booleanRelation && index + 1 < acc.length ? ( + + ) : null} + + ))} + + ); +} diff --git a/src/plugins/unified_search/public/filter_badge/filter_badge_utils.ts b/src/plugins/unified_search/public/filter_badge/filter_badge_utils.ts new file mode 100644 index 0000000000000..468fefe3c0bb2 --- /dev/null +++ b/src/plugins/unified_search/public/filter_badge/filter_badge_utils.ts @@ -0,0 +1,78 @@ +/* + * 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 { getDisplayValueFromFilter, getIndexPatternFromFilter } from '@kbn/data-plugin/public'; +import type { DataView } from '@kbn/data-views-plugin/common'; +import type { Filter } from '@kbn/es-query'; +import { i18n } from '@kbn/i18n'; + +export enum FILTER_LABLE_STATUS { + FILTER_ITEM_OK = '', + FILTER_ITEM_WARNING = 'warn', + FILTER_ITEM_ERROR = 'error', +} + +export interface LabelOptions { + title: string; + status: FILTER_LABLE_STATUS; + message?: string; +} + +/** + * Checks if filter field exists in any of the index patterns provided, + * Because if so, a filter for the wrong index pattern may still be applied. + * This function makes this behavior explicit, but it needs to be revised. + */ +function isFilterApplicable(filter: Filter, dataViews: DataView[]) { + // Any filter is applicable if no index patterns were provided to FilterBar. + if (!dataViews.length) return true; + + const ip = getIndexPatternFromFilter(filter, dataViews); + if (ip) return true; + + const allFields = dataViews.map((dataView) => { + return dataView.fields.map((field) => field.name); + }); + const flatFields = allFields.flat(); + return flatFields.includes(filter.meta?.key || ''); +} + +export function getValueLabel(filter: Filter, dataViews: DataView[]): LabelOptions { + const label: LabelOptions = { + title: '', + message: '', + status: FILTER_LABLE_STATUS.FILTER_ITEM_OK, + }; + + if (filter.meta?.isMultiIndex) { + return label; + } + + if (isFilterApplicable(filter, dataViews)) { + try { + label.title = getDisplayValueFromFilter(filter, dataViews); + } catch (e) { + label.status = FILTER_LABLE_STATUS.FILTER_ITEM_WARNING; + label.title = i18n.translate('unifiedSearch.filter.filterBar.labelWarningText', { + defaultMessage: `Warning`, + }); + label.message = e.message; + } + } else { + label.status = FILTER_LABLE_STATUS.FILTER_ITEM_WARNING; + label.title = i18n.translate('unifiedSearch.filter.filterBar.labelWarningText', { + defaultMessage: `Warning`, + }); + label.message = i18n.translate('unifiedSearch.filter.filterBar.labelWarningInfo', { + defaultMessage: `Field {fieldName} does not exist in current view`, + values: { fieldName: filter.meta.key }, + }); + } + + return label; +} diff --git a/src/plugins/unified_search/public/filter_badge/index.ts b/src/plugins/unified_search/public/filter_badge/index.ts new file mode 100644 index 0000000000000..5691d02580676 --- /dev/null +++ b/src/plugins/unified_search/public/filter_badge/index.ts @@ -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 `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); diff --git a/src/plugins/unified_search/public/filter_bar/filter_editor/filter_editor.tsx b/src/plugins/unified_search/public/filter_bar/filter_editor/filter_editor.tsx index 1b336acd024ae..f2a76a959b3af 100644 --- a/src/plugins/unified_search/public/filter_bar/filter_editor/filter_editor.tsx +++ b/src/plugins/unified_search/public/filter_bar/filter_editor/filter_editor.tsx @@ -29,26 +29,30 @@ import { buildCustomFilter, cleanFilter, getFilterParams, + isCombinedFilter, + buildCombinedFilter, + BooleanRelation, } from '@kbn/es-query'; -import { get } from 'lodash'; import React, { Component } from 'react'; 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 { css, cx } from '@emotion/css'; import { GenericComboBox, GenericComboBoxProps } 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'; + +/** The default max-height of the Add/Edit Filter popover used to show "+n More" filters (e.g. `+5 More`) */ +export const DEFAULT_MAX_HEIGHT = '227px'; + +const filtersBuilderMaxHeight = css` + max-height: ${DEFAULT_MAX_HEIGHT}; +`; export interface FilterEditorProps { filter: Filter; @@ -62,13 +66,11 @@ export interface FilterEditorProps { interface State { selectedIndexPattern?: DataView; - selectedField?: DataViewField; - selectedOperator?: Operator; - params: any; useCustomLabel: boolean; customLabel: string | null; queryDsl: string; isCustomEditorOpen: boolean; + filters: Filter[]; } const panelTitleAdd = i18n.translate('unifiedSearch.filter.filterEditor.addFilterPopupTitle', { @@ -90,13 +92,11 @@ class FilterEditorUI extends Component { super(props); this.state = { selectedIndexPattern: this.getIndexPatternFromFilter(), - selectedField: this.getFieldFromFilter(), - selectedOperator: this.getSelectedOperator(), - params: getFilterParams(props.filter), useCustomLabel: props.filter.meta.alias !== null, customLabel: props.filter.meta.alias || '', queryDsl: JSON.stringify(cleanFilter(props.filter), null, 2), isCustomEditorOpen: this.isUnknownFilterType(), + filters: [props.filter], }; } @@ -133,7 +133,9 @@ class FilterEditorUI extends Component {
{this.renderIndexPatternInput()} - {this.state.isCustomEditorOpen ? this.renderCustomEditor() : this.renderRegularEditor()} + {this.state.isCustomEditorOpen + ? this.renderCustomEditor() + : this.renderFiltersBuilderEditor()} @@ -250,87 +252,19 @@ class FilterEditorUI extends Component { ); } - private renderRegularEditor() { - return ( -
- - {this.renderFieldInput()} - - {this.renderOperatorInput()} - - - -
{this.renderParamsEditor()}
-
- ); - } - - private renderFieldInput() { - const { selectedIndexPattern, selectedField } = this.state; - const fields = selectedIndexPattern ? getFilterableFields(selectedIndexPattern) : []; - - return ( - - field.customLabel || field.name} - onChange={this.onFieldChange} - singleSelection={{ asPlainText: true }} - isClearable={false} - data-test-subj="filterFieldSuggestionList" - /> - - ); - } + private renderFiltersBuilderEditor() { + const { selectedIndexPattern, filters } = this.state; - private renderOperatorInput() { - const { selectedField, selectedOperator } = this.state; - const operators = selectedField ? getOperatorOptions(selectedField) : []; return ( - - message} - onChange={this.onOperatorChange} - singleSelection={{ asPlainText: true }} - isClearable={false} - data-test-subj="filterOperatorList" +
+ { + this.setState({ filters: filtersBuilder }); + }} /> - +
); } @@ -357,74 +291,6 @@ class FilterEditorUI extends Component { ); } - 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 ( - - - - ); - case 'phrases': - return ( - - - - ); - case 'range': - return ( - - ); - } - } - private toggleCustomEditor = () => { const isCustomEditorOpen = !this.state.isCustomEditorOpen; this.setState({ isCustomEditorOpen }); @@ -432,30 +298,19 @@ class FilterEditorUI extends Component { 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, + filters, } = this.state; if (isCustomEditorOpen) { @@ -467,29 +322,30 @@ class FilterEditorUI extends Component { } } - return isFilterValid(indexPattern, field, operator, params); - } + const mappedFilter = (filter: Filter) => { + return filter.meta.params.map((item: Filter) => validationCheck(item)); + }; - private onIndexPatternChange = ([selectedIndexPattern]: DataView[]) => { - const selectedField = undefined; - const selectedOperator = undefined; - const params = undefined; - this.setState({ selectedIndexPattern, selectedField, selectedOperator, params }); - }; + function validationCheck(filter: Filter) { + if (isCombinedFilter(filter)) { + return mappedFilter(filter); + } else { + return isFilterValid( + indexPattern, + getFieldFromFilter(filter as FieldFilter, indexPattern!), + getOperatorFromFilter(filter), + getFilterParams(filter) + ); + } + } + const flattenedFillters = filters.map((filter) => validationCheck(filter)).flat(Infinity); - private onFieldChange = ([selectedField]: DataViewField[]) => { - const selectedOperator = undefined; - const params = undefined; - this.setState({ selectedField, selectedOperator, params }); - }; + return flattenedFillters.every((checkedFilter) => Boolean(checkedFilter)); + } - 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 onIndexPatternChange = ([selectedIndexPattern]: DataView[]) => { + const filters = [this.props.filter]; + this.setState({ selectedIndexPattern, filters }); }; private onCustomLabelSwitchChange = (event: EuiSwitchEvent) => { @@ -503,14 +359,6 @@ class FilterEditorUI extends Component { 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 }); }; @@ -518,9 +366,6 @@ class FilterEditorUI extends Component { private onSubmit = () => { const { selectedIndexPattern: indexPattern, - selectedField: field, - selectedOperator: operator, - params, useCustomLabel, customLabel, isCustomEditorOpen, @@ -539,18 +384,33 @@ class FilterEditorUI extends Component { 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 ?? '', - alias, - $state.store - ); - this.props.onSubmit(filter); + } else if (indexPattern) { + const mappedFilter = (filter: Filter) => { + return filter.meta.params.map((item: Filter) => builderFilter(item)); + }; + + const builderFilter = (filter: Filter) => { + if (isCombinedFilter(filter)) { + return buildCombinedFilter(filter.meta.relation, mappedFilter(filter)); + } else { + return buildFilter( + indexPattern, + getFieldFromFilter(filter as FieldFilter, indexPattern)!, + getOperatorFromFilter(filter)?.type!, + getOperatorFromFilter(filter)?.negate!, + filter.meta.disabled ?? false, + getFilterParams(filter) ?? '', + alias, + filter?.$state?.store + ); + } + }; + const filters = this.state.filters.map((filter: Filter) => builderFilter(filter)); + const builedFilter = + filters.length === 1 + ? filters[0] + : buildCombinedFilter(BooleanRelation.AND, filters, alias); + this.props.onSubmit(builedFilter); } }; } @@ -559,12 +419,4 @@ function IndexPatternComboBox(props: GenericComboBoxProps) { return GenericComboBox(props); } -function FieldComboBox(props: GenericComboBoxProps) { - return GenericComboBox(props); -} - -function OperatorComboBox(props: GenericComboBoxProps) { - return GenericComboBox(props); -} - export const FilterEditor = injectI18n(FilterEditorUI); diff --git a/src/plugins/unified_search/public/filter_bar/filter_editor/lib/filter_editor_utils.test.ts b/src/plugins/unified_search/public/filter_bar/filter_editor/lib/filter_editor_utils.test.ts index 07ce05d039582..303f5b10e8e6d 100644 --- a/src/plugins/unified_search/public/filter_bar/filter_editor/lib/filter_editor_utils.test.ts +++ b/src/plugins/unified_search/public/filter_bar/filter_editor/lib/filter_editor_utils.test.ts @@ -194,9 +194,9 @@ describe('Filter editor utils', () => { expect(isValid).toBe(false); }); - it('should return true for exists filter without params', () => { + it('should return false for exists filter without params', () => { const isValid = isFilterValid(stubIndexPattern, stubFields[0], existsOperator); - expect(isValid).toBe(true); + expect(isValid).toBe(false); }); }); }); diff --git a/src/plugins/unified_search/public/filter_bar/filter_editor/lib/filter_editor_utils.ts b/src/plugins/unified_search/public/filter_bar/filter_editor/lib/filter_editor_utils.ts index b59ddcc424ff8..4f0a6f54d4e6e 100644 --- a/src/plugins/unified_search/public/filter_bar/filter_editor/lib/filter_editor_utils.ts +++ b/src/plugins/unified_search/public/filter_bar/filter_editor/lib/filter_editor_utils.ts @@ -63,7 +63,7 @@ export function isFilterValid( operator?: Operator, params?: any ) { - if (!indexPattern || !field || !operator) { + if (!indexPattern || !field || !operator || !params) { return false; } switch (operator.type) { diff --git a/src/plugins/unified_search/public/filter_bar/filter_item/filter_item.tsx b/src/plugins/unified_search/public/filter_bar/filter_item/filter_item.tsx index 3f70a57708b46..ac66a5b934a9b 100644 --- a/src/plugins/unified_search/public/filter_bar/filter_item/filter_item.tsx +++ b/src/plugins/unified_search/public/filter_bar/filter_item/filter_item.tsx @@ -83,7 +83,7 @@ export function FilterItem(props: FilterItemProps) { } } - function handleIconClick(e: MouseEvent) { + function handleIconClick() { props.onRemove(); setIsPopoverOpen(false); } @@ -318,6 +318,7 @@ export function FilterItem(props: FilterItemProps) { filterLabelStatus: valueLabelConfig.status, errorMessage: valueLabelConfig.message, className: getClasses(!!filter.meta.negate, valueLabelConfig), + dataViews: indexPatterns, iconOnClick: handleIconClick, onClick: handleBadgeClick, 'data-test-subj': getDataTestSubj(valueLabelConfig), diff --git a/src/plugins/unified_search/public/filter_bar/filter_view/index.tsx b/src/plugins/unified_search/public/filter_bar/filter_view/index.tsx index ffffee70534bd..4664bb56b2fac 100644 --- a/src/plugins/unified_search/public/filter_bar/filter_view/index.tsx +++ b/src/plugins/unified_search/public/filter_bar/filter_view/index.tsx @@ -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 = ({ @@ -34,6 +36,7 @@ export const FilterView: FC = ({ errorMessage, filterLabelStatus, hideAlias, + dataViews, ...rest }: Props) => { const [ref, innerText] = useInnerText(); @@ -92,15 +95,15 @@ export const FilterView: FC = ({ }; const FilterPill = () => ( - - - + ); return readOnly ? ( diff --git a/src/plugins/unified_search/public/filters_builder/__mock__/filters.ts b/src/plugins/unified_search/public/filters_builder/__mock__/filters.ts index fae7dd7e93502..8b68d22b5123b 100644 --- a/src/plugins/unified_search/public/filters_builder/__mock__/filters.ts +++ b/src/plugins/unified_search/public/filters_builder/__mock__/filters.ts @@ -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", - }, - }, - query: { - match_phrase: { - 'category.keyword': "Men's Accessories 3", - }, - }, - $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", + { + 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 4", + { + 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', diff --git a/src/plugins/unified_search/public/filters_builder/filters_builder.tsx b/src/plugins/unified_search/public/filters_builder/filters_builder.tsx index c7251bb78518c..0b9ca7c50f5e3 100644 --- a/src/plugins/unified_search/public/filters_builder/filters_builder.tsx +++ b/src/plugins/unified_search/public/filters_builder/filters_builder.tsx @@ -9,10 +9,9 @@ import React, { useEffect, useReducer, useCallback, useState, useMemo } 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 { type Filter, BooleanRelation } 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'; @@ -25,7 +24,7 @@ export interface FiltersBuilderProps { hideOr?: boolean; } -const rootLevelConditionType = ConditionTypes.AND; +const rootLevelConditionType = BooleanRelation.AND; const DEFAULT_MAX_DEPTH = 10; function FiltersBuilder({ @@ -64,7 +63,7 @@ function FiltersBuilder({ }, [filters, onChange, state.filters]); const handleMoveFilter = useCallback( - (pathFrom: string, pathTo: string, conditionalType: ConditionTypes) => { + (pathFrom: string, pathTo: string, booleanRelation: BooleanRelation) => { if (pathFrom === pathTo) { return null; } @@ -74,7 +73,7 @@ function FiltersBuilder({ payload: { pathFrom, pathTo, - conditionalType, + booleanRelation, }, }); }, @@ -83,11 +82,11 @@ function FiltersBuilder({ const onDragEnd: DragDropContextProps['onDragEnd'] = ({ combine, source, destination }) => { if (source && destination) { - handleMoveFilter(source.droppableId, destination.droppableId, ConditionTypes.AND); + handleMoveFilter(source.droppableId, destination.droppableId, BooleanRelation.AND); } if (source && combine) { - handleMoveFilter(source.droppableId, combine.droppableId, ConditionTypes.OR); + handleMoveFilter(source.droppableId, combine.droppableId, BooleanRelation.OR); } setDropTarget(''); }; @@ -114,7 +113,7 @@ function FiltersBuilder({ }} > - +
diff --git a/src/plugins/unified_search/public/filters_builder/filters_builder_filter_group.tsx b/src/plugins/unified_search/public/filters_builder/filters_builder_filter_group.tsx index adb77f7b9d2ea..cfbc561e0ec36 100644 --- a/src/plugins/unified_search/public/filters_builder/filters_builder_filter_group.tsx +++ b/src/plugins/unified_search/public/filters_builder/filters_builder_filter_group.tsx @@ -17,17 +17,17 @@ import { useEuiBackgroundColor, useEuiPaddingSize, } from '@elastic/eui'; -import { Filter } from '@kbn/es-query'; +import { type Filter, BooleanRelation } from '@kbn/es-query'; import { css, cx } from '@emotion/css'; import type { Path } from './filters_builder_types'; -import { ConditionTypes, getConditionalOperationType } from '../utils'; +import { getBooleanRelationType } 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; + booleanRelation: BooleanRelation; path: Path; /** @internal used for recursive rendering **/ @@ -38,10 +38,10 @@ export interface FilterGroupProps { /** @internal **/ const Delimiter = ({ color, - conditionType, + booleanRelation, }: { color: 'subdued' | 'plain'; - conditionType: ConditionTypes; + booleanRelation: BooleanRelation; }) => { const xsPadding = useEuiPaddingSize('xs'); const mPadding = useEuiPaddingSize('m'); @@ -68,9 +68,9 @@ const Delimiter = ({ {i18n.translate('unifiedSearch.filter.filtersBuilder.delimiterLabel', { - defaultMessage: '{conditionType}', + defaultMessage: '{booleanRelation}', values: { - conditionType, + booleanRelation, }, })} @@ -80,7 +80,7 @@ const Delimiter = ({ export const FilterGroup = ({ filters, - conditionType, + booleanRelation, path, reverseBackground = false, renderedLevel = 0, @@ -91,11 +91,12 @@ export const FilterGroup = ({ const pathInArray = getPathInArray(path); const isDepthReached = maxDepth <= pathInArray.length; - const orDisabled = hideOr || (isDepthReached && conditionType === ConditionTypes.AND); - const andDisabled = isDepthReached && conditionType === ConditionTypes.OR; + 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 && getConditionalOperationType(filters[0]); + !path && filters.length === 1 && getBooleanRelationType(filters[0]); if (shouldNormalizeFirstLevel) { reverseBackground = true; @@ -120,10 +121,10 @@ export const FilterGroup = ({ /> - {conditionType && index + 1 < acc.length ? ( + {booleanRelation && index + 1 < acc.length ? ( - {conditionType === ConditionTypes.OR && ( - + {booleanRelation === BooleanRelation.OR && ( + )} ) : null} @@ -136,7 +137,7 @@ export const FilterGroup = ({ 0 ? 'none' : 'xs'} hasBorder className={cx({ 'filter-builder__panel': true, diff --git a/src/plugins/unified_search/public/filters_builder/filters_builder_filter_item/filters_builder_filter_item.tsx b/src/plugins/unified_search/public/filters_builder/filters_builder_filter_item/filters_builder_filter_item.tsx index 30b73d397b674..caf3ee4a7eb8f 100644 --- a/src/plugins/unified_search/public/filters_builder/filters_builder_filter_item/filters_builder_filter_item.tsx +++ b/src/plugins/unified_search/public/filters_builder/filters_builder_filter_item/filters_builder_filter_item.tsx @@ -18,7 +18,8 @@ import { EuiPanel, useEuiTheme, } from '@elastic/eui'; -import { buildEmptyFilter, FieldFilter, Filter, getFilterParams } from '@kbn/es-query'; +import type { FieldFilter, Filter } from '@kbn/es-query'; +import { buildEmptyFilter, getFilterParams, BooleanRelation } from '@kbn/es-query'; import { DataViewField } from '@kbn/data-views-plugin/common'; import { i18n } from '@kbn/i18n'; import { cx, css } from '@emotion/css'; @@ -29,7 +30,7 @@ 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 { getBooleanRelationType } from '../../utils'; import { FiltersBuilderContextType } from '../filters_builder_context'; import { FilterGroup } from '../filters_builder_filter_group'; import type { Path } from '../filters_builder_types'; @@ -76,7 +77,7 @@ export function FilterItem({ globalParams: { hideOr }, timeRangeForSuggestionsOverride, } = useContext(FiltersBuilderContextType); - const conditionalOperationType = getConditionalOperationType(filter); + const conditionalOperationType = getBooleanRelationType(filter); const { euiTheme } = useEuiTheme(); const grabIconStyles = useMemo( @@ -91,7 +92,7 @@ export function FilterItem({ let params: Filter['meta']['params'] | undefined; if (!conditionalOperationType) { - field = getFieldFromFilter(filter as FieldFilter, dataView); + field = getFieldFromFilter(filter as FieldFilter, dataView!); operator = getOperatorFromFilter(filter); params = getFilterParams(filter); } @@ -146,21 +147,21 @@ export function FilterItem({ }, [dispatch, path]); const onAddFilter = useCallback( - (conditionalType: ConditionTypes) => { + (booleanRelation: BooleanRelation) => { dispatch({ type: 'addFilter', payload: { path, - filter: buildEmptyFilter(false, dataView.id), - conditionalType, + filter: buildEmptyFilter(false, dataView?.id), + booleanRelation, }, }); }, - [dispatch, dataView.id, path] + [dispatch, dataView?.id, path] ); - const onAddButtonClick = useCallback(() => onAddFilter(ConditionTypes.AND), [onAddFilter]); - const onOrButtonClick = useCallback(() => onAddFilter(ConditionTypes.OR), [onAddFilter]); + const onAddButtonClick = useCallback(() => onAddFilter(BooleanRelation.AND), [onAddFilter]); + const onOrButtonClick = useCallback(() => onAddFilter(BooleanRelation.OR), [onAddFilter]); if (!dataView) { return null; @@ -176,7 +177,7 @@ export function FilterItem({ {conditionalOperationType ? ( { } `); 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", - }, - "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", + 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 4", + 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", }, - ] + } `); }); }); - 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 +209,7 @@ 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, '0', BooleanRelation.AND); expect(getFilterByPath(enlargedFilters, '1')).toMatchInlineSnapshot(` Object { "$state": Object { @@ -238,7 +243,7 @@ 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, '0', '2', BooleanRelation.AND); const filterObtainedAfterFilterMovingFilters = getFilterByPath(filtersAfterMovingFilter, '2'); expect(filterBeforeMoving).toEqual(filterObtainedAfterFilterMovingFilters); }); diff --git a/src/plugins/unified_search/public/filters_builder/filters_builder_utils.ts b/src/plugins/unified_search/public/filters_builder/filters_builder_utils.ts index 4d28d091341b1..65da81c9ff139 100644 --- a/src/plugins/unified_search/public/filters_builder/filters_builder_utils.ts +++ b/src/plugins/unified_search/public/filters_builder/filters_builder_utils.ts @@ -7,10 +7,11 @@ */ import { DataViewField } from '@kbn/data-views-plugin/common'; -import type { Filter, FilterItem } from '@kbn/es-query'; +import type { Filter } from '@kbn/es-query'; +import { BooleanRelation } from '@kbn/es-query'; import { cloneDeep } from 'lodash'; import { buildCombinedFilter, isCombinedFilter } from '@kbn/es-query'; -import { ConditionTypes, getConditionalOperationType } from '../utils'; +import { getBooleanRelationType } from '../utils'; import type { Operator } from '../filter_bar/filter_editor'; const PATH_SEPARATOR = '.'; @@ -21,14 +22,10 @@ const PATH_SEPARATOR = '.'; */ export const getPathInArray = (path: string) => path.split(PATH_SEPARATOR).map((i) => +i); -const getGroupedFilters = (filter: FilterItem) => +const getGroupedFilters = (filter: Filter) => Array.isArray(filter) ? filter : filter?.meta?.params; -const doForFilterByPath = ( - filters: FilterItem[], - path: string, - action: (filter: FilterItem) => T -) => { +const doForFilterByPath = (filters: Filter[], path: string, action: (filter: Filter) => T) => { const pathArray = getPathInArray(path); let f = filters[pathArray[0]]; for (let i = 1, depth = pathArray.length; i < depth; i++) { @@ -37,14 +34,14 @@ const doForFilterByPath = ( return action(f); }; -const getContainerMetaByPath = (filters: FilterItem[], pathInArray: number[]) => { - let targetArray: FilterItem[] = filters; - let parentFilter: FilterItem | undefined; - let parentConditionType = ConditionTypes.AND; +const getContainerMetaByPath = (filters: Filter[], pathInArray: number[]) => { + let targetArray: Filter[] = filters; + let parentFilter: Filter | undefined; + let parentConditionType = BooleanRelation.AND; if (pathInArray.length > 1) { parentFilter = getFilterByPath(filters, getParentFilterPath(pathInArray)); - parentConditionType = getConditionalOperationType(parentFilter) ?? parentConditionType; + parentConditionType = getBooleanRelationType(parentFilter) ?? parentConditionType; targetArray = getGroupedFilters(parentFilter); } @@ -60,10 +57,10 @@ const getParentFilterPath = (pathInArray: number[]) => /** * 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. + * @param {Filter[]} 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) => { +export const normalizeFilters = (filters: Filter[]) => { + const doRecursive = (f: Filter, parent: Filter[] | Filter) => { if (Array.isArray(f)) { return normalizeArray(f, parent); } else if (isCombinedFilter(f)) { @@ -72,9 +69,9 @@ export const normalizeFilters = (filters: FilterItem[]) => { return f; }; - const normalizeArray = (filtersArray: FilterItem[], parent: FilterItem): FilterItem[] => { + const normalizeArray = (filtersArray: Filter[], parent: Filter[] | Filter): Filter[] => { const partiallyNormalized = filtersArray - .map((item) => { + .map((item: Filter) => { const normalized = doRecursive(item, filtersArray); if (Array.isArray(normalized)) { @@ -87,12 +84,12 @@ export const normalizeFilters = (filters: FilterItem[]) => { } return normalized; }, []) - .filter(Boolean) as FilterItem[]; + .filter(Boolean) as Filter[]; return Array.isArray(parent) ? partiallyNormalized.flat() : partiallyNormalized; }; - const normalizeCombined = (combinedFilter: Filter): FilterItem => { + const normalizeCombined = (combinedFilter: Filter): Filter => { const combinedFilters = getGroupedFilters(combinedFilter); if (combinedFilters.length < 2) { return combinedFilters[0]; @@ -112,36 +109,44 @@ export const normalizeFilters = (filters: FilterItem[]) => { /** * Find filter by path. - * @param {FilterItem[]} filters - filters in which the search for the desired filter will occur. + * @param {Filter[]} 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) => +export const getFilterByPath = (filters: Filter[], 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 {Filter} filter - new filter. * @param {string} path - path to filter. - * @param {ConditionTypes} conditionalType - OR/AND relationships between filters. + * @param {BooleanRelation} booleanRelation - OR/AND relationships between filters. */ export const addFilter = ( filters: Filter[], - filter: FilterItem, + filter: Filter, path: string, - conditionalType: ConditionTypes + booleanRelation: BooleanRelation ) => { 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 (parentConditionType !== booleanRelation) { + if (booleanRelation === BooleanRelation.OR) { + targetArray.splice( + selector, + 1, + buildCombinedFilter(BooleanRelation.OR, [targetArray[selector], filter]) + ); } - if (conditionalType === ConditionTypes.AND) { - targetArray.splice(selector, 1, [targetArray[selector], filter]); + if (booleanRelation === BooleanRelation.AND) { + targetArray.splice( + selector, + 1, + buildCombinedFilter(BooleanRelation.AND, [targetArray[selector], filter]) + ); } } else { targetArray.splice(selector + 1, 0, filter); @@ -171,20 +176,20 @@ export const removeFilter = (filters: Filter[], path: string) => { * @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. + * @param {BooleanRelation} booleanRelation - OR/AND relationships between filters. */ export const moveFilter = ( filters: Filter[], from: string, to: string, - conditionalType: ConditionTypes + booleanRelation: BooleanRelation ) => { const addFilterThenRemoveFilter = ( source: Filter[], - addedFilter: FilterItem, + addedFilter: Filter, pathFrom: string, pathTo: string, - conditional: ConditionTypes + conditional: BooleanRelation ) => { const newFiltersWithFilter = addFilter(source, addedFilter, pathTo, conditional); return removeFilter(newFiltersWithFilter, pathFrom); @@ -192,10 +197,10 @@ export const moveFilter = ( const removeFilterThenAddFilter = ( source: Filter[], - removableFilter: FilterItem, + removableFilter: Filter, pathFrom: string, pathTo: string, - conditional: ConditionTypes + conditional: BooleanRelation ) => { const newFiltersWithoutFilter = removeFilter(source, pathFrom); return addFilter(newFiltersWithoutFilter, removableFilter, pathTo, conditional); @@ -214,21 +219,21 @@ export const moveFilter = ( const { parentConditionType } = getContainerMetaByPath(newFilters, pathInArrayTo); const filterMovementDirection = Number(filterPositionTo) - Number(filterPositionFrom); - if (filterMovementDirection === -1 && parentConditionType === conditionalType) { + if (filterMovementDirection === -1 && parentConditionType === booleanRelation) { return filters; } if (filterMovementDirection >= -1) { - return addFilterThenRemoveFilter(newFilters, movingFilter, from, to, conditionalType); + return addFilterThenRemoveFilter(newFilters, movingFilter, from, to, booleanRelation); } else { - return removeFilterThenAddFilter(newFilters, movingFilter, from, to, conditionalType); + return removeFilterThenAddFilter(newFilters, movingFilter, from, to, booleanRelation); } } if (pathInArrayTo.length > pathInArrayFrom.length) { - return addFilterThenRemoveFilter(newFilters, movingFilter, from, to, conditionalType); + return addFilterThenRemoveFilter(newFilters, movingFilter, from, to, booleanRelation); } else { - return removeFilterThenAddFilter(newFilters, movingFilter, from, to, conditionalType); + return removeFilterThenAddFilter(newFilters, movingFilter, from, to, booleanRelation); } }; diff --git a/src/plugins/unified_search/public/utils/combined_filter.ts b/src/plugins/unified_search/public/utils/combined_filter.ts index 05810ed55f7c2..95fc18b39910e 100644 --- a/src/plugins/unified_search/public/utils/combined_filter.ts +++ b/src/plugins/unified_search/public/utils/combined_filter.ts @@ -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; } }; diff --git a/src/plugins/unified_search/public/utils/index.ts b/src/plugins/unified_search/public/utils/index.ts index 0e3ac5f05c20c..8c9d2d7323e47 100644 --- a/src/plugins/unified_search/public/utils/index.ts +++ b/src/plugins/unified_search/public/utils/index.ts @@ -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';