[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:
Nodir Latipov 2022-12-19 18:18:30 +05:00 committed by GitHub
parent 136ed8014b
commit 3c4ab973be
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
147 changed files with 4646 additions and 2866 deletions

View file

@ -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 {

View 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 [],
},
}
`);
});
});
});

View 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);
};

View file

@ -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);
};

View file

@ -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);
});

View file

@ -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 } = {}

View file

@ -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",
},
}
`);
});
});

View file

@ -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];
}, []);
}

View file

@ -19,6 +19,7 @@ export {
getAggregateQueryMode,
getIndexPatternFromSQLQuery,
} from './es_query_sql';
export { fromCombinedFilter } from './from_combined_filter';
export type {
IFieldSubType,
BoolQuery,

View file

@ -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']);

View file

@ -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,
},
};
}

View file

@ -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();
});
});
});

View file

@ -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);

View file

@ -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';

View 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 } })),
},
},
};
}

View file

@ -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';

View file

@ -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),

View file

@ -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',

View file

@ -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),

View file

@ -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);

View file

@ -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);
}

View file

@ -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;
}

View file

@ -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",
}
`);
});
});
});

View file

@ -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),
};
};

View file

@ -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'],
};

View file

@ -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() {

View file

@ -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}
`;

View file

@ -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;

View file

@ -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;
}
}

View file

@ -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>
);
}

View file

@ -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;

View file

@ -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>
);
};

View file

@ -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>
`;

View file

@ -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();
});

View file

@ -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;

View file

@ -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);

View 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 ',
}),
};

View 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);

View file

@ -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;
}
`;

View file

@ -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',

View file

@ -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);

View 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 { css } from '@emotion/css';
import type { EuiThemeComputed } from '@elastic/eui';
export const genericComboBoxStyle = (euiTheme: EuiThemeComputed) => css`
.euiComboBoxPlaceholder {
padding-right: calc(${euiTheme.size.xs});
}
`;

View file

@ -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"

View file

@ -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';

View file

@ -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);

View file

@ -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,
};

View file

@ -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;
};

View file

@ -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>
);
}
}

View file

@ -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>
);
}
}

View file

@ -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"
/>
}
/>

View file

@ -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…');
});
});
});

View file

@ -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>
);
});

View file

@ -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;

View file

@ -71,7 +71,7 @@
}
.globalFilterItem__editorForm {
padding: $euiSizeS;
padding: $euiSizeM;
}
.globalFilterItem__popover,

View file

@ -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}

View file

@ -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>
`;

View file

@ -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>
);
}
}

View file

@ -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 ? (

View file

@ -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

View file

@ -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',

View file

@ -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>(

View file

@ -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};
}
`;

View file

@ -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>
);
};

View file

@ -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',
}),
};

View file

@ -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>
);
};

View file

@ -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';

View 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, { 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>
);
};

View file

@ -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;
}

View file

@ -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>
);
}

View 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 { 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;
}
`;

View file

@ -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>
);
}

View file

@ -6,5 +6,4 @@
* Side Public License, v 1.
*/
/** @internal **/
export type Path = string;
export { FilterItem } from './filter_item';

View file

@ -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"
/>
);
}

View file

@ -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>
);
}

View file

@ -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}
/>
);
}

View file

@ -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
)}
</>
);

View file

@ -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};
}
}
`;

View file

@ -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>

View file

@ -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>
);
};

View file

@ -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>
);
}

View file

@ -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
/>
);
}

View file

@ -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 } };
}),
},
},
};
}

View file

@ -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

View 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;
}

View file

@ -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);
});

View file

@ -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;
};

View file

@ -6,4 +6,4 @@
* Side Public License, v 1.
*/
export { FilterItem } from './filters_builder_filter_item';
export * from './filters_builder';

View file

@ -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';

View file

@ -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)}
`;

View file

@ -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

View file

@ -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}
/>
);

View file

@ -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>
}
>

View file

@ -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>

View file

@ -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>,
},

View file

@ -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;

View file

@ -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) {

View file

@ -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;
}
};

View file

@ -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';

View file

@ -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();
});

View file

@ -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