kibana/x-pack/plugins/timelines/public/components/t_grid/helpers.tsx
Kristof C 7ac6561697
142435 add is one of operator (#144988)
## Summary

This PR adds support for an is one of operator allowing users to filter
multiple values for one field.

[Some investigation
](https://discuss.elastic.co/t/passing-multiple-values-in-kibana-add-filter-is-one-of/232694/2)by
@andrew-goldstein revealed that since the underlying engine uses Lucene,
we can add support for multiple values by using an OR query:

`kibana.alert.workflow_status: ("open" OR "closed" OR "acknowledged")`
is equivalent to
```
"terms": {
      "kibana.alert.workflow_status": [ "open", "closed", "acknowledged"]
    }
```
Where the former is usable in our `DataProviders` used by timeline and
other components that navigate a user to a pre-populated timeline.

As an enhancement to the timeline view, users can also use this `is one
of` operator by interacting with the `Add field` button and selecting
the new operator.

<img width="433" alt="image"
src="https://user-images.githubusercontent.com/28942857/193487154-769005b6-3e5a-40bf-9476-8dd3f3bcb8ee.png">

### 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)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [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


## Known issues
This operator does not support timeline templates at this time so usage
there disables the ability for conversion to template field but a better
approach should be implemented to notify users.
https://github.com/elastic/kibana/issues/142437. For now I have added a
template message and prevented users from creating templates with this
operator:

<img width="374" alt="image"
src="https://user-images.githubusercontent.com/28942857/201157676-80017c6c-9f5b-4cd7-ba0b-ee2e43a884cb.png">



## Testing
Create a new timeline or visit an existing one. 
Click 'Add field' button on Timeline in OR query section
add any field ( preferably one that can have many values- consider
`kibana.alerts.workflow_status` but this requires alerts.
Select the `is one of` or `is not one of operator`
Add or remove values in the value section.
Click save.

Co-authored-by: Kristof-Pierre Cummings <kristofpierre.cummings@elastic.co>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
2022-11-16 07:06:20 -07:00

347 lines
11 KiB
TypeScript

/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { Filter, EsQueryConfig, Query } from '@kbn/es-query';
import { DataViewBase, FilterStateStore } from '@kbn/es-query';
import { get, isEmpty } from 'lodash/fp';
import memoizeOne from 'memoize-one';
import { elementOrChildrenHasFocus } from '../../../common/utils/accessibility';
import type { BrowserFields } from '../../../common/search_strategy/index_fields';
import {
DataProviderType,
EXISTS_OPERATOR,
IS_ONE_OF_OPERATOR,
IS_OPERATOR,
} from '../../../common/types/timeline';
import type { DataProvider, DataProvidersAnd } from '../../../common/types/timeline';
import { assertUnreachable } from '../../../common/utility_types';
import { convertToBuildEsQuery, escapeQueryValue } from '../utils/keury';
import { EVENTS_TABLE_CLASS_NAME } from './styles';
import { TableId } from '../../types';
import { ViewSelection } from './event_rendered_view/selector';
interface CombineQueries {
config: EsQueryConfig;
dataProviders: DataProvider[];
indexPattern: DataViewBase;
browserFields: BrowserFields;
filters: Filter[];
kqlQuery: Query;
kqlMode: string;
}
const isNumber = (value: string | number): value is number => !isNaN(Number(value));
const convertDateFieldToQuery = (field: string, value: string | number) =>
`${field}: ${isNumber(value) ? value : new Date(value).valueOf()}`;
const getBaseFields = memoizeOne((browserFields: BrowserFields): string[] => {
const baseFields = get('base', browserFields);
if (baseFields != null && baseFields.fields != null) {
return Object.keys(baseFields.fields);
}
return [];
});
const getBrowserFieldPath = (field: string, browserFields: BrowserFields) => {
const splitFields = field.split('.');
const baseFields = getBaseFields(browserFields);
if (baseFields.includes(field)) {
return ['base', 'fields', field];
}
return [splitFields[0], 'fields', field];
};
const checkIfFieldTypeIsDate = (field: string, browserFields: BrowserFields) => {
const pathBrowserField = getBrowserFieldPath(field, browserFields);
const browserField = get(pathBrowserField, browserFields);
if (browserField != null && browserField.type === 'date') {
return true;
}
return false;
};
const convertNestedFieldToQuery = (
field: string,
value: string | number,
browserFields: BrowserFields
) => {
const pathBrowserField = getBrowserFieldPath(field, browserFields);
const browserField = get(pathBrowserField, browserFields);
const nestedPath = browserField.subType.nested.path;
const key = field.replace(`${nestedPath}.`, '');
return `${nestedPath}: { ${key}: ${browserField.type === 'date' ? `"${value}"` : value} }`;
};
const convertNestedFieldToExistQuery = (field: string, browserFields: BrowserFields) => {
const pathBrowserField = getBrowserFieldPath(field, browserFields);
const browserField = get(pathBrowserField, browserFields);
const nestedPath = browserField.subType.nested.path;
const key = field.replace(`${nestedPath}.`, '');
return `${nestedPath}: { ${key}: * }`;
};
const checkIfFieldTypeIsNested = (field: string, browserFields: BrowserFields) => {
const pathBrowserField = getBrowserFieldPath(field, browserFields);
const browserField = get(pathBrowserField, browserFields);
if (browserField != null && browserField.subType && browserField.subType.nested) {
return true;
}
return false;
};
const buildQueryMatch = (
dataProvider: DataProvider | DataProvidersAnd,
browserFields: BrowserFields
) => {
const {
excluded,
type,
queryMatch: { field, operator, value },
} = dataProvider;
const isFieldTypeNested = checkIfFieldTypeIsNested(field, browserFields);
const isExcluded = excluded ? 'NOT ' : '';
switch (operator) {
case IS_OPERATOR:
if (!isStringOrNumberArray(value)) {
return `${isExcluded}${
type !== DataProviderType.template
? buildIsQueryMatch({ browserFields, field, isFieldTypeNested, value })
: buildExistsQueryMatch({ browserFields, field, isFieldTypeNested })
}`;
} else {
return `${isExcluded}${field} : ${JSON.stringify(value[0])}`;
}
case EXISTS_OPERATOR:
return `${isExcluded}${buildExistsQueryMatch({ browserFields, field, isFieldTypeNested })}`;
case IS_ONE_OF_OPERATOR:
if (isStringOrNumberArray(value)) {
return `${isExcluded}${buildIsOneOfQueryMatch({ field, value })}`;
} else {
return `${isExcluded}${field} : ${JSON.stringify(value)}`;
}
default:
assertUnreachable(operator);
}
};
export const buildGlobalQuery = (dataProviders: DataProvider[], browserFields: BrowserFields) =>
dataProviders
.reduce((queries: string[], dataProvider: DataProvider) => {
const flatDataProviders = [dataProvider, ...dataProvider.and];
const activeDataProviders = flatDataProviders.filter(
(flatDataProvider) => flatDataProvider.enabled
);
if (!activeDataProviders.length) return queries;
const activeDataProvidersQueries = activeDataProviders.map((activeDataProvider) =>
buildQueryMatch(activeDataProvider, browserFields)
);
const activeDataProvidersQueryMatch = activeDataProvidersQueries.join(' and ');
return [...queries, activeDataProvidersQueryMatch];
}, [])
.filter((queriesItem) => !isEmpty(queriesItem))
.reduce((globalQuery: string, queryMatch: string, index: number, queries: string[]) => {
if (queries.length <= 1) return queryMatch;
return !index ? `(${queryMatch})` : `${globalQuery} or (${queryMatch})`;
}, '');
export const isDataProviderEmpty = (dataProviders: DataProvider[]) => {
return isEmpty(dataProviders) || isEmpty(dataProviders.filter((d) => d.enabled === true));
};
export const combineQueries = ({
config,
dataProviders,
indexPattern,
browserFields,
filters = [],
kqlQuery,
kqlMode,
}: CombineQueries): { filterQuery: string | undefined; kqlError: Error | undefined } | null => {
const kuery: Query = { query: '', language: kqlQuery.language };
if (isDataProviderEmpty(dataProviders) && isEmpty(kqlQuery.query) && isEmpty(filters)) {
return null;
} else if (isDataProviderEmpty(dataProviders) && isEmpty(kqlQuery.query) && !isEmpty(filters)) {
const [filterQuery, kqlError] = convertToBuildEsQuery({
config,
queries: [kuery],
indexPattern,
filters,
});
return {
filterQuery,
kqlError,
};
}
const operatorKqlQuery = kqlMode === 'filter' ? 'and' : 'or';
const postpend = (q: string) => `${!isEmpty(q) ? `(${q})` : ''}`;
const globalQuery = buildGlobalQuery(dataProviders, browserFields); // based on Data Providers
const querySuffix = postpend(kqlQuery.query as string); // based on Unified Search bar
const queryPrefix = globalQuery ? `(${globalQuery})` : '';
const queryOperator = queryPrefix && querySuffix ? operatorKqlQuery : '';
kuery.query = `(${queryPrefix} ${queryOperator} ${querySuffix})`;
const [filterQuery, kqlError] = convertToBuildEsQuery({
config,
queries: [kuery],
indexPattern,
filters,
});
return {
filterQuery,
kqlError,
};
};
export const buildTimeRangeFilter = (from: string, to: string): Filter =>
({
range: {
'@timestamp': {
gte: from,
lt: to,
format: 'strict_date_optional_time',
},
},
meta: {
type: 'range',
disabled: false,
negate: false,
alias: null,
key: '@timestamp',
params: {
gte: from,
lt: to,
format: 'strict_date_optional_time',
},
},
$state: {
store: FilterStateStore.APP_STATE,
},
} as Filter);
export const getCombinedFilterQuery = ({
from,
to,
filters,
...combineQueriesParams
}: CombineQueries & { from: string; to: string }): string | undefined => {
const combinedQueries = combineQueries({
...combineQueriesParams,
filters: [...filters, buildTimeRangeFilter(from, to)],
});
return combinedQueries ? combinedQueries.filterQuery : undefined;
};
export const resolverIsShowing = (graphEventId: string | undefined): boolean =>
graphEventId != null && graphEventId !== '';
export const EVENTS_COUNT_BUTTON_CLASS_NAME = 'local-events-count-button';
/** Returns true if the events table has focus */
export const tableHasFocus = (containerElement: HTMLElement | null): boolean =>
elementOrChildrenHasFocus(
containerElement?.querySelector<HTMLDivElement>(`.${EVENTS_TABLE_CLASS_NAME}`)
);
export const isSelectableView = (timelineId: string): boolean =>
timelineId === TableId.alertsOnAlertsPage || timelineId === TableId.alertsOnRuleDetailsPage;
export const isViewSelection = (value: unknown): value is ViewSelection =>
value === 'gridView' || value === 'eventRenderedView';
/** always returns a valid default `ViewSelection` */
export const getDefaultViewSelection = ({
timelineId,
value,
}: {
timelineId: string;
value: unknown;
}): ViewSelection => {
const defaultViewSelection = 'gridView';
if (!isSelectableView(timelineId)) {
return defaultViewSelection;
} else {
return isViewSelection(value) ? value : defaultViewSelection;
}
};
/** This local storage key stores the `Grid / Event rendered view` selection */
export const ALERTS_TABLE_VIEW_SELECTION_KEY = 'securitySolution.alerts.table.view-selection';
export const buildIsQueryMatch = ({
browserFields,
field,
isFieldTypeNested,
value,
}: {
browserFields: BrowserFields;
field: string;
isFieldTypeNested: boolean;
value: string | number;
}): string => {
if (isFieldTypeNested) {
return convertNestedFieldToQuery(field, value, browserFields);
} else if (checkIfFieldTypeIsDate(field, browserFields)) {
return convertDateFieldToQuery(field, value);
} else {
return `${field} : ${isNumber(value) ? value : escapeQueryValue(value)}`;
}
};
export const buildExistsQueryMatch = ({
browserFields,
field,
isFieldTypeNested,
}: {
browserFields: BrowserFields;
field: string;
isFieldTypeNested: boolean;
}): string => {
return isFieldTypeNested
? convertNestedFieldToExistQuery(field, browserFields)
: `${field} ${EXISTS_OPERATOR}`;
};
export const buildIsOneOfQueryMatch = ({
field,
value,
}: {
field: string;
value: Array<string | number>;
}): string => {
const trimmedField = field.trim();
if (value.length) {
return `${trimmedField} : (${value
.map((item) => (isNumber(item) ? Number(item) : `${escapeQueryValue(item.trim())}`))
.join(' OR ')})`;
}
return `${trimmedField} : ''`;
};
export const isStringOrNumberArray = (value: unknown): value is Array<string | number> =>
Array.isArray(value) &&
(value.every((x) => typeof x === 'string') || value.every((x) => typeof x === 'number'));