[ES|QL] [Discover] Creating where clause filters from the table, sidebar and table row viewer (#181399)

## Summary

Part of https://github.com/elastic/kibana/issues/181280

This PR handles the creation of filters (where clause) in Discover ES|QL
mode. The parts that are being handled are:

- sidebar
- document viewer
- table


![meow](f2e32e91-5d76-4723-93c1-dbeadb7eb9cb)

The creation of filters from the charts is not here.

### Checklist

Delete any items that are not applicable to this PR.

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [x] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [x] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [x] If a plugin configuration key changed, check if it needs to be
allowlisted in the cloud and added to the [docker
list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)
- [x] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Stratoula Kalafateli 2024-05-06 15:44:19 +02:00 committed by GitHub
parent 470ec04b2d
commit b64500b9e6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 549 additions and 585 deletions

View file

@ -6,4 +6,6 @@ This package contains utilities for ES|QL.
- *getIndexPatternFromESQLQuery*: Use this to retrieve the index pattern from the `from` command.
- *getLimitFromESQLQuery*: Use this function to get the limit for a given query. The limit can be either set from the `limit` command or can be a default value set in ES.
- *removeDropCommandsFromESQLQuery*: Use this function to remove all the occurences of the `drop` command from the query.
- *appendToESQLQuery*: Use this function to append more pipes in an existing ES|QL query. It adds the additional commands in a new line.
- *appendWhereClauseToESQLQuery*: Use this function to append where clause in an existing query.

View file

@ -15,6 +15,7 @@ export {
getInitialESQLQuery,
getESQLWithSafeLimit,
appendToESQLQuery,
appendWhereClauseToESQLQuery,
getESQLQueryColumns,
TextBasedLanguages,
} from './src';

View file

@ -15,5 +15,5 @@ export {
getLimitFromESQLQuery,
removeDropCommandsFromESQLQuery,
} from './utils/query_parsing_helpers';
export { appendToESQLQuery } from './utils/append_to_query';
export { appendToESQLQuery, appendWhereClauseToESQLQuery } from './utils/append_to_query';
export { getESQLQueryColumns } from './utils/run_query';

View file

@ -5,21 +5,151 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { appendToESQLQuery } from './append_to_query';
import { appendToESQLQuery, appendWhereClauseToESQLQuery } from './append_to_query';
describe('appendToESQLQuery', () => {
it('append the text on a new line after the query', () => {
expect(appendToESQLQuery('from logstash-* // meow', '| stats var = avg(woof)')).toBe(
`from logstash-* // meow
describe('appendToQuery', () => {
describe('appendToESQLQuery', () => {
it('append the text on a new line after the query', () => {
expect(appendToESQLQuery('from logstash-* // meow', '| stats var = avg(woof)')).toBe(
`from logstash-* // meow
| stats var = avg(woof)`
);
);
});
it('append the text on a new line after the query for text with variables', () => {
const limit = 10;
expect(appendToESQLQuery('from logstash-*', `| limit ${limit}`)).toBe(
`from logstash-*
| limit 10`
);
});
});
it('append the text on a new line after the query for text with variables', () => {
const limit = 10;
expect(appendToESQLQuery('from logstash-*', `| limit ${limit}`)).toBe(
`from logstash-*
| limit 10`
);
describe('appendWhereClauseToESQLQuery', () => {
it('appends a filter in where clause in an existing query', () => {
expect(
appendWhereClauseToESQLQuery('from logstash-* // meow', 'dest', 'tada!', '+', 'string')
).toBe(
`from logstash-* // meow
| where \`dest\`=="tada!"`
);
});
it('appends a filter out where clause in an existing query', () => {
expect(
appendWhereClauseToESQLQuery('from logstash-* // meow', 'dest', 'tada!', '-', 'string')
).toBe(
`from logstash-* // meow
| where \`dest\`!="tada!"`
);
});
it('appends a where clause in an existing query with casting to string when the type is not string or number', () => {
expect(
appendWhereClauseToESQLQuery('from logstash-* // meow', 'dest', 'tada!', '-', 'ip')
).toBe(
`from logstash-* // meow
| where \`dest\`::string!="tada!"`
);
});
it('appends a where clause in an existing query with casting to string when the type is not given', () => {
expect(appendWhereClauseToESQLQuery('from logstash-* // meow', 'dest', 'tada!', '-')).toBe(
`from logstash-* // meow
| where \`dest\`::string!="tada!"`
);
});
it('appends a where clause in an existing query checking that the value is not null if the user asks for existence', () => {
expect(
appendWhereClauseToESQLQuery(
'from logstash-* // meow',
'dest',
undefined,
'_exists_',
'string'
)
).toBe(
`from logstash-* // meow
| where \`dest\` is not null`
);
});
it('appends an and clause in an existing query with where command as the last pipe', () => {
expect(
appendWhereClauseToESQLQuery(
'from logstash-* | where country == "GR"',
'dest',
'Crete',
'+',
'string'
)
).toBe(
`from logstash-* | where country == "GR"
and \`dest\`=="Crete"`
);
});
it('doesnt append anything in an existing query with where command as the last pipe if the filter preexists', () => {
expect(
appendWhereClauseToESQLQuery(
'from logstash-* | where country == "GR"',
'country',
'GR',
'+',
'string'
)
).toBe(`from logstash-* | where country == "GR"`);
});
it('doesnt append anything in an existing query with where command as the last pipe if the _exists_ filter preexists', () => {
expect(
appendWhereClauseToESQLQuery(
'from logstash-* | where country IS NOT NULL',
'country',
undefined,
'_exists_',
'string'
)
).toBe(`from logstash-* | where country IS NOT NULL`);
});
it('changes the operator in an existing query with where command as the last pipe if the filter preexists but has the opposite operator', () => {
expect(
appendWhereClauseToESQLQuery(
'from logstash-* | where country == "GR"',
'country',
'GR',
'-',
'string'
)
).toBe(`from logstash-* | where country != "GR"`);
});
it('changes the operator in an existing query with where command as the last pipe if the filter preexists but has the opposite operator, the field has backticks', () => {
expect(
appendWhereClauseToESQLQuery(
'from logstash-* | where `country` == "GR"',
'country',
'GR',
'-',
'string'
)
).toBe(`from logstash-* | where \`country\`!= "GR"`);
});
it('appends an and clause in an existing query with where command as the last pipe if the filter preexists but the operator is not the correct one', () => {
expect(
appendWhereClauseToESQLQuery(
`from logstash-* | where CIDR_MATCH(ip1, "127.0.0.2/32", "127.0.0.3/32")`,
'ip',
'127.0.0.2/32',
'-',
'ip'
)
).toBe(
`from logstash-* | where CIDR_MATCH(ip1, "127.0.0.2/32", "127.0.0.3/32")
and \`ip\`::string!="127.0.0.2/32"`
);
});
});
});

View file

@ -5,9 +5,86 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { getAstAndSyntaxErrors } from '@kbn/esql-ast';
// Append in a new line the appended text to take care of the case where the user adds a comment at the end of the query
// in these cases a base query such as "from index // comment" will result in errors or wrong data if we don't append in a new line
export function appendToESQLQuery(baseESQLQuery: string, appendedText: string): string {
return `${baseESQLQuery}\n${appendedText}`;
}
export function appendWhereClauseToESQLQuery(
baseESQLQuery: string,
field: string,
value: unknown,
operation: '+' | '-' | '_exists_',
fieldType?: string
): string {
let operator;
switch (operation) {
case '_exists_':
operator = ' is not null';
break;
case '-':
operator = '!=';
break;
default:
operator = '==';
}
let filterValue = typeof value === 'string' ? `"${value.replace(/"/g, '\\"')}"` : value;
// Adding the backticks here are they are needed for special char fields
let fieldName = `\`${field}\``;
// casting to string
// there are some field types such as the ip that need
// to cast in string first otherwise ES will fail
if (fieldType !== 'string' && fieldType !== 'number' && fieldType !== 'boolean') {
fieldName = `${fieldName}::string`;
}
// checking that the value is not null
// this is the existence filter
if (operation === '_exists_') {
fieldName = `\`${String(field)}\``;
filterValue = '';
}
const { ast } = getAstAndSyntaxErrors(baseESQLQuery);
const lastCommandIsWhere = ast[ast.length - 1].name === 'where';
// if where command already exists in the end of the query:
// - we need to append with and if the filter doesnt't exist
// - we need to change the filter operator if the filter exists with different operator
// - we do nothing if the filter exists with the same operator
if (lastCommandIsWhere) {
const whereCommand = ast[ast.length - 1];
const whereAstText = whereCommand.text;
// the filter already exists in the where clause
if (whereAstText.includes(field) && whereAstText.includes(String(filterValue))) {
const pipesArray = baseESQLQuery.split('|');
const whereClause = pipesArray[pipesArray.length - 1];
const matches = whereClause.match(new RegExp(field + '(.*)' + String(filterValue)));
if (matches) {
const existingOperator = matches[1]?.trim().replace('`', '').toLowerCase();
if (!['==', '!=', 'is not null'].includes(existingOperator.trim())) {
return appendToESQLQuery(baseESQLQuery, `and ${fieldName}${operator}${filterValue}`);
}
// the filter is the same
if (existingOperator === operator.trim()) {
return baseESQLQuery;
// the filter has different operator
} else {
const existingFilter = matches[0].trim();
const newFilter = existingFilter.replace(existingOperator, operator);
return baseESQLQuery.replace(existingFilter, newFilter);
}
}
}
// filter does not exist in the where clause
const whereClause = `and ${fieldName}${operator}${filterValue}`;
return appendToESQLQuery(baseESQLQuery, whereClause);
}
const whereClause = `| where ${fieldName}${operator}${filterValue}`;
return appendToESQLQuery(baseESQLQuery, whereClause);
}

View file

@ -96,10 +96,23 @@ Array [
},
"cellActions": Array [
[Function],
[Function],
[Function],
],
"display": <Memo(DataTableColumnHeader)
columnDisplayName="message"
columnName="message"
columnsMeta={
Object {
"extension": Object {
"type": "string",
},
"message": Object {
"esType": "keyword",
"type": "string",
},
}
}
dataView={
Object {
"docvalueFields": Array [],
@ -177,39 +190,6 @@ Array [
Array [
"message",
],
Array [
"timestamp",
],
Array [
"extension",
],
Array [
"message",
],
Array [
"extension",
],
Array [
"message",
],
Array [
"timestamp",
],
Array [
"extension",
],
Array [
"timestamp",
],
Array [
"var_test",
],
Array [
"extension",
],
Array [
"message",
],
Array [
"extension",
],
@ -305,120 +285,6 @@ Array [
"type": "string",
},
},
Object {
"type": "return",
"value": Object {
"aggregatable": true,
"displayName": "timestamp",
"filterable": true,
"name": "timestamp",
"scripted": false,
"sortable": true,
"type": "date",
},
},
Object {
"type": "return",
"value": Object {
"aggregatable": true,
"displayName": "extension",
"filterable": true,
"name": "extension",
"scripted": false,
"type": "string",
},
},
Object {
"type": "return",
"value": Object {
"displayName": "message",
"filterable": false,
"name": "message",
"scripted": false,
"type": "string",
},
},
Object {
"type": "return",
"value": Object {
"aggregatable": true,
"displayName": "extension",
"filterable": true,
"name": "extension",
"scripted": false,
"type": "string",
},
},
Object {
"type": "return",
"value": Object {
"displayName": "message",
"filterable": false,
"name": "message",
"scripted": false,
"type": "string",
},
},
Object {
"type": "return",
"value": Object {
"aggregatable": true,
"displayName": "timestamp",
"filterable": true,
"name": "timestamp",
"scripted": false,
"sortable": true,
"type": "date",
},
},
Object {
"type": "return",
"value": Object {
"aggregatable": true,
"displayName": "extension",
"filterable": true,
"name": "extension",
"scripted": false,
"type": "string",
},
},
Object {
"type": "return",
"value": Object {
"aggregatable": true,
"displayName": "timestamp",
"filterable": true,
"name": "timestamp",
"scripted": false,
"sortable": true,
"type": "date",
},
},
Object {
"type": "return",
"value": undefined,
},
Object {
"type": "return",
"value": Object {
"aggregatable": true,
"displayName": "extension",
"filterable": true,
"name": "extension",
"scripted": false,
"type": "string",
},
},
Object {
"type": "return",
"value": Object {
"displayName": "message",
"filterable": false,
"name": "message",
"scripted": false,
"type": "string",
},
},
Object {
"type": "return",
"value": Object {
@ -595,15 +461,6 @@ Array [
Array [
"message",
],
Array [
"timestamp",
],
Array [
"extension",
],
Array [
"message",
],
],
"results": Array [
Object {
@ -693,39 +550,6 @@ Array [
"type": "string",
},
},
Object {
"type": "return",
"value": Object {
"aggregatable": true,
"displayName": "timestamp",
"filterable": true,
"name": "timestamp",
"scripted": false,
"sortable": true,
"type": "date",
},
},
Object {
"type": "return",
"value": Object {
"aggregatable": true,
"displayName": "extension",
"filterable": true,
"name": "extension",
"scripted": false,
"type": "string",
},
},
Object {
"type": "return",
"value": Object {
"displayName": "message",
"filterable": false,
"name": "message",
"scripted": false,
"type": "string",
},
},
],
},
"getFormatterForField": [MockFunction],
@ -893,15 +717,6 @@ Array [
Array [
"message",
],
Array [
"timestamp",
],
Array [
"extension",
],
Array [
"message",
],
],
"results": Array [
Object {
@ -991,39 +806,6 @@ Array [
"type": "string",
},
},
Object {
"type": "return",
"value": Object {
"aggregatable": true,
"displayName": "timestamp",
"filterable": true,
"name": "timestamp",
"scripted": false,
"sortable": true,
"type": "date",
},
},
Object {
"type": "return",
"value": Object {
"aggregatable": true,
"displayName": "extension",
"filterable": true,
"name": "extension",
"scripted": false,
"type": "string",
},
},
Object {
"type": "return",
"value": Object {
"displayName": "message",
"filterable": false,
"name": "message",
"scripted": false,
"type": "string",
},
},
],
},
"getFormatterForField": [MockFunction],
@ -1178,15 +960,6 @@ Array [
Array [
"message",
],
Array [
"timestamp",
],
Array [
"extension",
],
Array [
"message",
],
],
"results": Array [
Object {
@ -1276,39 +1049,6 @@ Array [
"type": "string",
},
},
Object {
"type": "return",
"value": Object {
"aggregatable": true,
"displayName": "timestamp",
"filterable": true,
"name": "timestamp",
"scripted": false,
"sortable": true,
"type": "date",
},
},
Object {
"type": "return",
"value": Object {
"aggregatable": true,
"displayName": "extension",
"filterable": true,
"name": "extension",
"scripted": false,
"type": "string",
},
},
Object {
"type": "return",
"value": Object {
"displayName": "message",
"filterable": false,
"name": "message",
"scripted": false,
"type": "string",
},
},
],
},
"getFormatterForField": [MockFunction],
@ -1396,7 +1136,7 @@ Array [
columnsMeta={
Object {
"extension": Object {
"type": "number",
"type": "string",
},
"message": Object {
"esType": "keyword",
@ -1481,15 +1221,6 @@ Array [
Array [
"message",
],
Array [
"timestamp",
],
Array [
"extension",
],
Array [
"message",
],
Array [
"extension",
],
@ -1585,39 +1316,6 @@ Array [
"type": "string",
},
},
Object {
"type": "return",
"value": Object {
"aggregatable": true,
"displayName": "timestamp",
"filterable": true,
"name": "timestamp",
"scripted": false,
"sortable": true,
"type": "date",
},
},
Object {
"type": "return",
"value": Object {
"aggregatable": true,
"displayName": "extension",
"filterable": true,
"name": "extension",
"scripted": false,
"type": "string",
},
},
Object {
"type": "return",
"value": Object {
"displayName": "message",
"filterable": false,
"name": "message",
"scripted": false,
"type": "string",
},
},
Object {
"type": "return",
"value": Object {
@ -1668,7 +1366,7 @@ Array [
"displayAsText": "extension",
"id": "extension",
"isSortable": false,
"schema": "numeric",
"schema": "string",
"visibleCellActions": undefined,
},
Object {
@ -1719,7 +1417,7 @@ Array [
columnsMeta={
Object {
"extension": Object {
"type": "number",
"type": "string",
},
"message": Object {
"esType": "keyword",
@ -1804,15 +1502,6 @@ Array [
Array [
"message",
],
Array [
"timestamp",
],
Array [
"extension",
],
Array [
"message",
],
Array [
"extension",
],
@ -1908,39 +1597,6 @@ Array [
"type": "string",
},
},
Object {
"type": "return",
"value": Object {
"aggregatable": true,
"displayName": "timestamp",
"filterable": true,
"name": "timestamp",
"scripted": false,
"sortable": true,
"type": "date",
},
},
Object {
"type": "return",
"value": Object {
"aggregatable": true,
"displayName": "extension",
"filterable": true,
"name": "extension",
"scripted": false,
"type": "string",
},
},
Object {
"type": "return",
"value": Object {
"displayName": "message",
"filterable": false,
"name": "message",
"scripted": false,
"type": "string",
},
},
Object {
"type": "return",
"value": Object {
@ -3042,15 +2698,6 @@ Array [
Array [
"message",
],
Array [
"timestamp",
],
Array [
"extension",
],
Array [
"message",
],
],
"results": Array [
Object {
@ -3107,39 +2754,6 @@ Array [
"type": "string",
},
},
Object {
"type": "return",
"value": Object {
"aggregatable": true,
"displayName": "timestamp",
"filterable": true,
"name": "timestamp",
"scripted": false,
"sortable": true,
"type": "date",
},
},
Object {
"type": "return",
"value": Object {
"aggregatable": true,
"displayName": "extension",
"filterable": true,
"name": "extension",
"scripted": false,
"type": "string",
},
},
Object {
"type": "return",
"value": Object {
"displayName": "message",
"filterable": false,
"name": "message",
"scripted": false,
"type": "string",
},
},
],
},
"getFormatterForField": [MockFunction],
@ -3165,12 +2779,22 @@ Array [
}
dataViewField={
Object {
"aggregatable": true,
"displayName": "timestamp",
"filterable": true,
"aggregatable": false,
"conflictDescriptions": undefined,
"count": 0,
"customDescription": undefined,
"customLabel": undefined,
"defaultFormatter": undefined,
"esTypes": Array [
"dateTime",
],
"lang": undefined,
"name": "timestamp",
"readFromDocValues": false,
"script": undefined,
"scripted": false,
"sortable": true,
"searchable": true,
"subType": undefined,
"type": "date",
}
}
@ -3230,6 +2854,21 @@ Array [
"display": <Memo(DataTableColumnHeader)
columnDisplayName="extension"
columnName="extension"
columnsMeta={
Object {
"extension": Object {
"type": "string",
},
"message": Object {
"esType": "keyword",
"type": "string",
},
"timestamp": Object {
"esType": "dateTime",
"type": "date",
},
}
}
dataView={
Object {
"docvalueFields": Array [],
@ -3298,15 +2937,6 @@ Array [
Array [
"message",
],
Array [
"timestamp",
],
Array [
"extension",
],
Array [
"message",
],
],
"results": Array [
Object {
@ -3363,39 +2993,6 @@ Array [
"type": "string",
},
},
Object {
"type": "return",
"value": Object {
"aggregatable": true,
"displayName": "timestamp",
"filterable": true,
"name": "timestamp",
"scripted": false,
"sortable": true,
"type": "date",
},
},
Object {
"type": "return",
"value": Object {
"aggregatable": true,
"displayName": "extension",
"filterable": true,
"name": "extension",
"scripted": false,
"type": "string",
},
},
Object {
"type": "return",
"value": Object {
"displayName": "message",
"filterable": false,
"name": "message",
"scripted": false,
"type": "string",
},
},
],
},
"getFormatterForField": [MockFunction],
@ -3468,10 +3065,27 @@ Array [
},
"cellActions": Array [
[Function],
[Function],
[Function],
],
"display": <Memo(DataTableColumnHeader)
columnDisplayName="message"
columnName="message"
columnsMeta={
Object {
"extension": Object {
"type": "string",
},
"message": Object {
"esType": "keyword",
"type": "string",
},
"timestamp": Object {
"esType": "dateTime",
"type": "date",
},
}
}
dataView={
Object {
"docvalueFields": Array [],
@ -3540,15 +3154,6 @@ Array [
Array [
"message",
],
Array [
"timestamp",
],
Array [
"extension",
],
Array [
"message",
],
],
"results": Array [
Object {
@ -3605,39 +3210,6 @@ Array [
"type": "string",
},
},
Object {
"type": "return",
"value": Object {
"aggregatable": true,
"displayName": "timestamp",
"filterable": true,
"name": "timestamp",
"scripted": false,
"sortable": true,
"type": "date",
},
},
Object {
"type": "return",
"value": Object {
"aggregatable": true,
"displayName": "extension",
"filterable": true,
"name": "extension",
"scripted": false,
"type": "string",
},
},
Object {
"type": "return",
"value": Object {
"displayName": "message",
"filterable": false,
"name": "message",
"scripted": false,
"type": "string",
},
},
],
},
"getFormatterForField": [MockFunction],

View file

@ -153,7 +153,7 @@ export interface UnifiedDataTableProps {
/**
* Function to add a filter in the grid cell or document flyout
*/
onFilter: DocViewFilterFn;
onFilter?: DocViewFilterFn;
/**
* Function triggered when a column is resized by the user
*/
@ -513,11 +513,13 @@ export const UnifiedDataTable = ({
},
valueToStringConverter,
componentsTourSteps,
isPlainRecord,
}),
[
componentsTourSteps,
darkMode,
dataView,
isPlainRecord,
displayedRows,
expandedDoc,
isFilterActive,

View file

@ -93,6 +93,11 @@ describe('Data table columns', function () {
hasEditDataViewPermission: () =>
servicesMock.dataViewFieldEditor.userPermissions.editIndexPattern(),
onFilter: () => {},
columnsMeta: {
extension: { type: 'string' },
message: { type: 'string', esType: 'keyword' },
timestamp: { type: 'date', esType: 'dateTime' },
},
});
expect(actual).toMatchSnapshot();
});
@ -299,7 +304,7 @@ describe('Data table columns', function () {
const actual = getEuiGridColumns({
showColumnTokens: true,
columnsMeta: {
extension: { type: 'number' },
extension: { type: 'string' },
message: { type: 'string', esType: 'keyword' },
},
columns,
@ -348,7 +353,7 @@ describe('Data table columns', function () {
servicesMock.dataViewFieldEditor.userPermissions.editIndexPattern(),
onFilter: () => {},
columnsMeta: {
var_test: { type: 'number' },
extension: { type: 'string' },
},
});
expect(gridColumns[1].schema).toBe('string');
@ -402,6 +407,10 @@ describe('Data table columns', function () {
hasEditDataViewPermission: () =>
servicesMock.dataViewFieldEditor.userPermissions.editIndexPattern(),
onFilter: () => {},
columnsMeta: {
extension: { type: 'string' },
message: { type: 'string', esType: 'keyword' },
},
});
const extensionGridColumn = gridColumns[0];
@ -428,6 +437,10 @@ describe('Data table columns', function () {
servicesMock.dataViewFieldEditor.userPermissions.editIndexPattern(),
onFilter: () => {},
customGridColumnsConfiguration,
columnsMeta: {
extension: { type: 'string' },
message: { type: 'string', esType: 'keyword' },
},
});
expect(customizedGridColumns).toMatchSnapshot();

View file

@ -13,7 +13,7 @@ import {
type EuiDataGridColumnCellAction,
EuiScreenReaderOnly,
} from '@elastic/eui';
import type { DataView } from '@kbn/data-views-plugin/public';
import { type DataView, DataViewField } from '@kbn/data-views-plugin/public';
import { ToastsStart, IUiSettingsClient } from '@kbn/core/public';
import { DocViewFilterFn } from '@kbn/unified-doc-viewer/types';
import { ExpandButton } from './data_table_expand_button';
@ -118,7 +118,17 @@ function buildEuiGridColumn({
headerRowHeight?: number;
customGridColumnsConfiguration?: CustomGridColumnsConfiguration;
}) {
const dataViewField = dataView.getFieldByName(columnName);
const dataViewField = !isPlainRecord
? dataView.getFieldByName(columnName)
: new DataViewField({
name: columnName,
type: columnsMeta?.[columnName]?.type ?? 'unknown',
esTypes: columnsMeta?.[columnName]?.esType
? ([columnsMeta[columnName].esType] as string[])
: undefined,
searchable: true,
aggregatable: false,
});
const editFieldButton =
editField &&
dataViewField &&
@ -263,7 +273,7 @@ export function getEuiGridColumns({
};
hasEditDataViewPermission: () => boolean;
valueToStringConverter: ValueToStringConverter;
onFilter: DocViewFilterFn;
onFilter?: DocViewFilterFn;
editField?: (fieldName: string) => void;
visibleCellActions?: number;
columnsMeta?: DataTableColumnsMeta;

View file

@ -63,8 +63,7 @@ describe('Default cell actions ', function () {
dataTableContextMock.valueToStringConverter,
jest.fn()
);
expect(cellActions).toContain(FilterInBtn);
expect(cellActions).toContain(FilterOutBtn);
expect(cellActions).toHaveLength(3);
});
it('should show Copy action for _source field', async () => {
@ -96,11 +95,7 @@ describe('Default cell actions ', function () {
);
const button = findTestSubject(component, 'filterForButton');
await button.simulate('click');
expect(dataTableContextMock.onFilter).toHaveBeenCalledWith(
dataTableContextMock.dataView.fields.getByName('extension'),
'jpg',
'+'
);
expect(dataTableContextMock.onFilter).toHaveBeenCalledWith({}, 'jpg', '+');
});
it('triggers filter function when FilterInBtn is clicked for a non-provided value', async () => {
const component = mountWithIntl(
@ -116,11 +111,7 @@ describe('Default cell actions ', function () {
);
const button = findTestSubject(component, 'filterForButton');
await button.simulate('click');
expect(dataTableContextMock.onFilter).toHaveBeenCalledWith(
dataTableContextMock.dataView.fields.getByName('extension'),
undefined,
'+'
);
expect(dataTableContextMock.onFilter).toHaveBeenCalledWith({}, undefined, '+');
});
it('triggers filter function when FilterInBtn is clicked for an empty string value', async () => {
const component = mountWithIntl(
@ -136,11 +127,7 @@ describe('Default cell actions ', function () {
);
const button = findTestSubject(component, 'filterForButton');
await button.simulate('click');
expect(dataTableContextMock.onFilter).toHaveBeenCalledWith(
dataTableContextMock.dataView.fields.getByName('message'),
'',
'+'
);
expect(dataTableContextMock.onFilter).toHaveBeenCalledWith({}, '', '+');
});
it('triggers filter function when FilterOutBtn is clicked', async () => {
const component = mountWithIntl(
@ -156,11 +143,7 @@ describe('Default cell actions ', function () {
);
const button = findTestSubject(component, 'filterOutButton');
await button.simulate('click');
expect(dataTableContextMock.onFilter).toHaveBeenCalledWith(
dataTableContextMock.dataView.fields.getByName('extension'),
'jpg',
'-'
);
expect(dataTableContextMock.onFilter).toHaveBeenCalledWith({}, 'jpg', '-');
});
it('triggers clipboard copy when CopyBtn is clicked', async () => {
const component = mountWithIntl(

View file

@ -20,22 +20,21 @@ function onFilterCell(
context: DataTableContext,
rowIndex: EuiDataGridColumnCellActionProps['rowIndex'],
columnId: EuiDataGridColumnCellActionProps['columnId'],
mode: '+' | '-'
mode: '+' | '-',
field: DataViewField
) {
const row = context.rows[rowIndex];
const value = row.flattened[columnId];
const field = context.dataView.fields.getByName(columnId);
if (field && context.onFilter) {
context.onFilter(field, value, mode);
}
}
export const FilterInBtn = ({
Component,
rowIndex,
columnId,
}: EuiDataGridColumnCellActionProps) => {
export const FilterInBtn = (
{ Component, rowIndex, columnId }: EuiDataGridColumnCellActionProps,
field: DataViewField
) => {
const context = useContext(UnifiedDataTableContext);
const buttonTitle = i18n.translate('unifiedDataTable.grid.filterForAria', {
defaultMessage: 'Filter for this {value}',
@ -45,7 +44,7 @@ export const FilterInBtn = ({
return (
<Component
onClick={() => {
onFilterCell(context, rowIndex, columnId, '+');
onFilterCell(context, rowIndex, columnId, '+', field);
}}
iconType="plusInCircle"
aria-label={buttonTitle}
@ -59,11 +58,10 @@ export const FilterInBtn = ({
);
};
export const FilterOutBtn = ({
Component,
rowIndex,
columnId,
}: EuiDataGridColumnCellActionProps) => {
export const FilterOutBtn = (
{ Component, rowIndex, columnId }: EuiDataGridColumnCellActionProps,
field: DataViewField
) => {
const context = useContext(UnifiedDataTableContext);
const buttonTitle = i18n.translate('unifiedDataTable.grid.filterOutAria', {
defaultMessage: 'Filter out this {value}',
@ -73,7 +71,7 @@ export const FilterOutBtn = ({
return (
<Component
onClick={() => {
onFilterCell(context, rowIndex, columnId, '-');
onFilterCell(context, rowIndex, columnId, '-', field);
}}
iconType="minusInCircle"
aria-label={buttonTitle}
@ -126,7 +124,20 @@ export function buildCellActions(
onFilter?: DocViewFilterFn
) {
return [
...(onFilter && field.filterable ? [FilterInBtn, FilterOutBtn] : []),
...(onFilter && field.filterable
? [
({ Component, rowIndex, columnId }: EuiDataGridColumnCellActionProps) =>
FilterInBtn(
{ Component, rowIndex, columnId } as EuiDataGridColumnCellActionProps,
field
),
({ Component, rowIndex, columnId }: EuiDataGridColumnCellActionProps) =>
FilterOutBtn(
{ Component, rowIndex, columnId } as EuiDataGridColumnCellActionProps,
field
),
]
: []),
({ Component, rowIndex, columnId }: EuiDataGridColumnCellActionProps) =>
buildCopyValueButton(
{ Component, rowIndex, columnId } as EuiDataGridColumnCellActionProps,

View file

@ -23,6 +23,7 @@ export interface DataTableContext {
setSelectedDocs: (selected: string[]) => void;
valueToStringConverter: ValueToStringConverter;
componentsTourSteps?: Record<string, string>;
isPlainRecord?: boolean;
}
const defaultContext = {} as unknown as DataTableContext;

View file

@ -17,6 +17,8 @@ import {
} from '@elastic/eui';
import { css } from '@emotion/react';
import { i18n } from '@kbn/i18n';
import { isOfAggregateQueryType } from '@kbn/es-query';
import { appendWhereClauseToESQLQuery } from '@kbn/esql-utils';
import { METRIC_TYPE } from '@kbn/analytics';
import classNames from 'classnames';
import { generateFilters } from '@kbn/data-plugin/public';
@ -156,6 +158,37 @@ export function DiscoverLayout({ stateContainer }: DiscoverLayoutProps) {
[filterManager, dataView, dataViews, trackUiMetric, capabilities]
);
const onPopulateWhereClause = useCallback(
(field: DataViewField | string, values: unknown, operation: '+' | '-') => {
if (query && isOfAggregateQueryType(query) && 'esql' in query) {
const fieldName = typeof field === 'string' ? field : field.name;
// send the field type for casting
const fieldType = typeof field !== 'string' ? field.type : undefined;
// weird existence logic from Discover components
// in the field it comes the operator _exists_ and in the value the field
// I need to take care of it here but I think it should be handled on the fieldlist instead
const updatedQuery = appendWhereClauseToESQLQuery(
query.esql,
fieldName === '_exists_' ? String(values) : fieldName,
fieldName === '_exists_' ? undefined : values,
fieldName === '_exists_' ? '_exists_' : operation,
fieldType
);
data.query.queryString.setQuery({
esql: updatedQuery,
});
if (trackUiMetric) {
trackUiMetric(METRIC_TYPE.CLICK, 'esql_filter_added');
}
}
},
[data.query.queryString, query, trackUiMetric]
);
const onFilter = isPlainRecord
? (onPopulateWhereClause as DocViewFilterFn)
: (onAddFilter as DocViewFilterFn);
const onFieldEdited = useCallback(
async ({ removedFieldName }: { removedFieldName?: string } = {}) => {
if (removedFieldName && currentColumns.includes(removedFieldName)) {
@ -234,7 +267,7 @@ export function DiscoverLayout({ stateContainer }: DiscoverLayoutProps) {
stateContainer={stateContainer}
columns={currentColumns}
viewMode={viewMode}
onAddFilter={onAddFilter as DocViewFilterFn}
onAddFilter={onFilter}
onFieldEdited={onFieldEdited}
container={mainContainer}
onDropFieldToTable={onDropFieldToTable}
@ -244,16 +277,16 @@ export function DiscoverLayout({ stateContainer }: DiscoverLayoutProps) {
</>
);
}, [
currentColumns,
dataView,
isPlainRecord,
mainContainer,
onAddFilter,
onDropFieldToTable,
onFieldEdited,
resultState,
isPlainRecord,
dataView,
stateContainer,
currentColumns,
viewMode,
onFilter,
onFieldEdited,
mainContainer,
onDropFieldToTable,
panelsToggle,
]);
@ -329,7 +362,7 @@ export function DiscoverLayout({ stateContainer }: DiscoverLayoutProps) {
documents$={stateContainer.dataState.data$.documents$}
onAddField={onAddColumn}
columns={currentColumns}
onAddFilter={!isPlainRecord ? onAddFilter : undefined}
onAddFilter={onFilter}
onRemoveField={onRemoveColumn}
onChangeDataView={stateContainer.actions.onChangeDataView}
selectedDataView={dataView}

View file

@ -125,7 +125,7 @@ export const DiscoverMainContent = ({
<DiscoverDocuments
viewModeToggle={viewModeToggle}
dataView={dataView}
onAddFilter={!isPlainRecord ? onAddFilter : undefined}
onAddFilter={onAddFilter}
stateContainer={stateContainer}
onFieldEdited={!isPlainRecord ? onFieldEdited : undefined}
/>

View file

@ -73,7 +73,7 @@ export function getTextBasedQueryFieldList(
name: column.name,
type: column.meta?.type ?? 'unknown',
esTypes: column.meta?.esType ? [column.meta?.esType] : undefined,
searchable: false,
searchable: true,
aggregatable: false,
isNull: Boolean(column?.isNull),
})

View file

@ -131,7 +131,7 @@ describe('sidebar reducer', function () {
esTypes: undefined,
aggregatable: false,
isNull: true,
searchable: false,
searchable: true,
}),
new DataViewField({
name: 'text2',
@ -139,7 +139,7 @@ describe('sidebar reducer', function () {
esTypes: ['keyword'],
aggregatable: false,
isNull: false,
searchable: false,
searchable: true,
}),
],
fieldCounts: {},

View file

@ -32,7 +32,7 @@ export type DocTableRow = EsHitRecord & {
export interface TableRowProps {
columns: string[];
filter: DocViewFilterFn;
filter?: DocViewFilterFn;
filters?: Filter[];
isPlainRecord?: boolean;
savedSearchId?: string;
@ -105,7 +105,7 @@ export const TableRow = ({
const inlineFilter = useCallback(
(column: string, type: '+' | '-') => {
const field = dataView.fields.getByName(column);
filter(field!, row.flattened[column], type);
filter?.(field!, row.flattened[column], type);
},
[filter, dataView.fields, row.flattened]
);

View file

@ -72,7 +72,7 @@ export interface DocTableProps {
/**
* Filter callback
*/
onFilter: DocViewFilterFn;
onFilter?: DocViewFilterFn;
/**
* Sorting callback
*/

View file

@ -459,25 +459,28 @@ export class SavedSearchEmbeddable
});
this.updateInput({ sort: sortOrderArr });
},
onFilter: async (field, value, operator) => {
let filters = generateFilters(
this.services.filterManager,
// @ts-expect-error
field,
value,
operator,
dataView
);
filters = filters.map((filter) => ({
...filter,
$state: { store: FilterStateStore.APP_STATE },
}));
// I don't want to create filters when is embedded
...(!this.isTextBasedSearch(savedSearch) && {
onFilter: async (field, value, operator) => {
let filters = generateFilters(
this.services.filterManager,
// @ts-expect-error
field,
value,
operator,
dataView
);
filters = filters.map((filter) => ({
...filter,
$state: { store: FilterStateStore.APP_STATE },
}));
await this.executeTriggerActions(APPLY_FILTER_TRIGGER, {
embeddable: this,
filters,
});
},
await this.executeTriggerActions(APPLY_FILTER_TRIGGER, {
embeddable: this,
filters,
});
},
}),
useNewFieldsApi: !this.services.uiSettings.get(SEARCH_FIELDS_FROM_SOURCE, false),
showTimeCol: !this.services.uiSettings.get(DOC_HIDE_TIME_COLUMN_SETTING, false),
ariaLabelledBy: 'documentsAriaLabel',

View file

@ -527,5 +527,67 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
);
});
});
describe('filtering by clicking on the table', () => {
beforeEach(async () => {
await PageObjects.common.navigateToApp('discover');
await PageObjects.timePicker.setDefaultAbsoluteRange();
});
it('should append a where clause by clicking the table', async () => {
await PageObjects.discover.selectTextBaseLang();
const testQuery = `from logstash-* | sort @timestamp desc | limit 10000 | stats countB = count(bytes) by geo.dest | sort countB`;
await monacoEditor.setCodeEditorValue(testQuery);
await testSubjects.click('querySubmitButton');
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.discover.waitUntilSearchingHasFinished();
await PageObjects.unifiedFieldList.waitUntilSidebarHasLoaded();
await testSubjects.click('TextBasedLangEditor-expand');
await dataGrid.clickCellFilterForButton(0, 3);
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.discover.waitUntilSearchingHasFinished();
await PageObjects.unifiedFieldList.waitUntilSidebarHasLoaded();
const editorValue = await monacoEditor.getCodeEditorValue();
expect(editorValue).to.eql(
`from logstash-* | sort @timestamp desc | limit 10000 | stats countB = count(bytes) by geo.dest | sort countB\n| where \`geo.dest\`=="BT"`
);
// negate
await dataGrid.clickCellFilterOutButton(0, 3);
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.discover.waitUntilSearchingHasFinished();
await PageObjects.unifiedFieldList.waitUntilSidebarHasLoaded();
const newValue = await monacoEditor.getCodeEditorValue();
expect(newValue).to.eql(
`from logstash-* | sort @timestamp desc | limit 10000 | stats countB = count(bytes) by geo.dest | sort countB\n| where \`geo.dest\`!="BT"`
);
});
it('should append an end in existing where clause by clicking the table', async () => {
await PageObjects.discover.selectTextBaseLang();
const testQuery = `from logstash-* | sort @timestamp desc | limit 10000 | stats countB = count(bytes) by geo.dest | sort countB | where countB > 0`;
await monacoEditor.setCodeEditorValue(testQuery);
await testSubjects.click('querySubmitButton');
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.discover.waitUntilSearchingHasFinished();
await PageObjects.unifiedFieldList.waitUntilSidebarHasLoaded();
await testSubjects.click('TextBasedLangEditor-expand');
await dataGrid.clickCellFilterForButton(0, 3);
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.discover.waitUntilSearchingHasFinished();
await PageObjects.unifiedFieldList.waitUntilSidebarHasLoaded();
const editorValue = await monacoEditor.getCodeEditorValue();
expect(editorValue).to.eql(
`from logstash-* | sort @timestamp desc | limit 10000 | stats countB = count(bytes) by geo.dest | sort countB | where countB > 0\nand \`geo.dest\`=="BT"`
);
});
});
});
}

View file

@ -144,10 +144,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
describe('text based columns', function () {
before(async () => {
const TEST_START_TIME = 'Sep 23, 2015 @ 06:31:44.000';
const TEST_END_TIME = 'Sep 23, 2015 @ 18:31:44.000';
await PageObjects.timePicker.setAbsoluteRange(TEST_START_TIME, TEST_END_TIME);
beforeEach(async () => {
await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings();
await PageObjects.common.navigateToApp('discover');
await PageObjects.discover.waitUntilSearchingHasFinished();
@ -169,6 +167,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expect(await testSubjects.getVisibleText('dscFieldStats-statsFooter')).to.contain(
'42 sample values'
);
await PageObjects.unifiedFieldList.clickFieldListPlusFilter('bytes', '0');
await testSubjects.click('TextBasedLangEditor-expand');
const editorValue = await monacoEditor.getCodeEditorValue();
expect(editorValue).to.eql(
`from logstash-* [METADATA _index, _id] | sort @timestamp desc | limit 500\n| where \`bytes\`==0`
);
await PageObjects.unifiedFieldList.closeFieldPopover();
});
@ -183,6 +188,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expect(await testSubjects.getVisibleText('dscFieldStats-statsFooter')).to.contain(
'500 sample values'
);
await PageObjects.unifiedFieldList.clickFieldListPlusFilter('extension.raw', 'css');
await testSubjects.click('TextBasedLangEditor-expand');
const editorValue = await monacoEditor.getCodeEditorValue();
expect(editorValue).to.eql(
`from logstash-* [METADATA _index, _id] | sort @timestamp desc | limit 500\n| where \`extension.raw\`=="css"`
);
await PageObjects.unifiedFieldList.closeFieldPopover();
});
@ -197,6 +210,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expect(await testSubjects.getVisibleText('dscFieldStats-statsFooter')).to.contain(
'32 sample values'
);
await PageObjects.unifiedFieldList.clickFieldListPlusFilter('clientip', '216.126.255.31');
await testSubjects.click('TextBasedLangEditor-expand');
const editorValue = await monacoEditor.getCodeEditorValue();
expect(editorValue).to.eql(
`from logstash-* [METADATA _index, _id] | sort @timestamp desc | limit 500\n| where \`clientip\`::string=="216.126.255.31"`
);
await PageObjects.unifiedFieldList.closeFieldPopover();
});
@ -214,8 +235,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await PageObjects.unifiedFieldList.closeFieldPopover();
});
it('should not have stats for a date field yet', async () => {
it('should not have stats for a date field yet but create an is not null filter', async () => {
await PageObjects.unifiedFieldList.clickFieldListItem('@timestamp');
await PageObjects.unifiedFieldList.clickFieldListExistsFilter('@timestamp');
await testSubjects.click('TextBasedLangEditor-expand');
const editorValue = await monacoEditor.getCodeEditorValue();
expect(editorValue).to.eql(
`from logstash-* [METADATA _index, _id] | sort @timestamp desc | limit 500\n| where \`@timestamp\` is not null`
);
await testSubjects.missingOrFail('dscFieldStats-statsFooter');
await PageObjects.unifiedFieldList.closeFieldPopover();
});
@ -245,6 +272,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expect(await testSubjects.getVisibleText('dscFieldStats-statsFooter')).to.contain(
'100 sample records'
);
await PageObjects.unifiedFieldList.clickFieldListPlusFilter('extension', 'css');
await testSubjects.click('TextBasedLangEditor-expand');
const editorValue = await monacoEditor.getCodeEditorValue();
expect(editorValue).to.eql(
`from logstash-* [METADATA _index, _id] | sort @timestamp desc | limit 500\n| where \`extension\`=="css"`
);
await PageObjects.unifiedFieldList.closeFieldPopover();
});
@ -277,6 +312,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expect(await testSubjects.getVisibleText('dscFieldStats-statsFooter')).to.contain(
'3 sample values'
);
await PageObjects.unifiedFieldList.clickFieldListPlusFilter('avg(bytes)', '5453');
await testSubjects.click('TextBasedLangEditor-expand');
const editorValue = await monacoEditor.getCodeEditorValue();
expect(editorValue).to.eql(
`from logstash-* | sort @timestamp desc | limit 50 | stats avg(bytes) by geo.dest | limit 3\n| where \`avg(bytes)\`==5453`
);
await PageObjects.unifiedFieldList.closeFieldPopover();
});
@ -298,6 +341,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expect(await testSubjects.getVisibleText('dscFieldStats-statsFooter')).to.contain(
'1 sample value'
);
await PageObjects.unifiedFieldList.clickFieldListMinusFilter('enabled', 'true');
await testSubjects.click('TextBasedLangEditor-expand');
const editorValue = await monacoEditor.getCodeEditorValue();
expect(editorValue).to.eql(`row enabled = true\n| where \`enabled\`!=true`);
await PageObjects.unifiedFieldList.closeFieldPopover();
});
});

View file

@ -233,6 +233,17 @@ export class UnifiedFieldListPageObject extends FtrService {
await this.header.waitUntilLoadingHasFinished();
}
public async clickFieldListExistsFilter(field: string) {
const existsFilterTestSubj = `discoverFieldListPanelAddExistFilter-${field}`;
if (!(await this.testSubjects.exists(existsFilterTestSubj))) {
// field has to be open
await this.clickFieldListItem(field);
}
// this.testSubjects.find doesn't handle spaces in the data-test-subj value
await this.testSubjects.click(existsFilterTestSubj);
await this.header.waitUntilLoadingHasFinished();
}
public async openSidebarFieldFilter() {
await this.testSubjects.click('fieldListFiltersFieldTypeFilterToggle');
await this.testSubjects.existOrFail('fieldListFiltersFieldTypeFilterOptions');

View file

@ -136,6 +136,11 @@ export class DataGridService extends FtrService {
await actionButton.click();
}
public async clickCellFilterOutButton(rowIndex: number = 0, columnIndex: number = 0) {
const actionButton = await this.getCellActionButton(rowIndex, columnIndex, 'filterOutButton');
await actionButton.click();
}
/**
* The same as getCellElement, but useful when multiple data grids are on the page.
*/