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>
This commit is contained in:
Kristof C 2022-11-16 08:06:20 -06:00 committed by GitHub
parent 3a52aadcfd
commit 7ac6561697
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 1535 additions and 157 deletions

View file

@ -57,7 +57,11 @@ const SavedColumnHeaderRuntimeType = runtimeTypes.partial({
const SavedDataProviderQueryMatchBasicRuntimeType = runtimeTypes.partial({
field: unionWithNullType(runtimeTypes.string),
displayField: unionWithNullType(runtimeTypes.string),
value: unionWithNullType(runtimeTypes.string),
value: runtimeTypes.union([
runtimeTypes.null,
runtimeTypes.string,
runtimeTypes.array(runtimeTypes.string),
]),
displayValue: unionWithNullType(runtimeTypes.string),
operator: unionWithNullType(runtimeTypes.string),
});
@ -652,7 +656,7 @@ export interface DataProviderResult {
export interface QueryMatchResult {
field?: Maybe<string>;
displayField?: Maybe<string>;
value?: Maybe<string>;
value?: Maybe<string | string[]>;
displayValue?: Maybe<string>;
operator?: Maybe<string>;
}

View file

@ -625,6 +625,26 @@ exports[`DragDropContextWrapper rendering it renders against the snapshot 1`] =
},
"type": "string",
},
"nestedField.thirdAttributes": Object {
"aggregatable": false,
"category": "nestedField",
"description": "",
"example": "",
"format": "",
"indexes": Array [
"auditbeat",
"filebeat",
"packetbeat",
],
"name": "nestedField.thirdAttributes",
"searchable": true,
"subType": Object {
"nested": Object {
"path": "nestedField",
},
},
"type": "date",
},
},
},
"source": Object {

View file

@ -25,10 +25,11 @@ import {
} from '../../../../timelines/components/timeline/body/renderers/constants';
import { BYTES_FORMAT } from '../../../../timelines/components/timeline/body/renderers/bytes';
import { EVENT_DURATION_FIELD_NAME } from '../../../../timelines/components/duration';
import { getDisplayValue } from '../../../../timelines/components/timeline/data_providers/helpers';
import { PORT_NAMES } from '../../../../network/components/port/helpers';
import { INDICATOR_REFERENCE } from '../../../../../common/cti/constants';
import type { BrowserField } from '../../../containers/source';
import type { DataProvider } from '../../../../../common/types';
import type { DataProvider, QueryOperator } from '../../../../../common/types';
import { IS_OPERATOR } from '../../../../../common/types';
export interface UseActionCellDataProvider {
@ -48,7 +49,12 @@ export interface ActionCellValuesAndDataProvider {
dataProviders: DataProvider[];
}
export const getDataProvider = (field: string, id: string, value: string): DataProvider => ({
export const getDataProvider = (
field: string,
id: string,
value: string | string[],
operator: QueryOperator = IS_OPERATOR
): DataProvider => ({
and: [],
enabled: true,
id: escapeDataProviderId(id),
@ -58,7 +64,8 @@ export const getDataProvider = (field: string, id: string, value: string): DataP
queryMatch: {
field,
value,
operator: IS_OPERATOR,
operator,
displayValue: getDisplayValue(value),
},
});

View file

@ -80,6 +80,20 @@ export const useHoverActions = ({
const { closeTopN, toggleTopN, isShowingTopN } = useTopNPopOver(handleClosePopOverTrigger);
const values = useMemo(() => {
const val = dataProvider.queryMatch.value;
if (typeof val === 'number') {
return val.toString();
}
if (Array.isArray(val)) {
return val.map((item) => String(item));
}
return val;
}, [dataProvider.queryMatch.value]);
const hoverContent = useMemo(() => {
// display links as additional content in the hover menu to enable keyboard
// navigation of links (when the draggable contains them):
@ -110,11 +124,7 @@ export const useHoverActions = ({
showTopN={isShowingTopN}
scopeId={id}
toggleTopN={toggleTopN}
values={
typeof dataProvider.queryMatch.value !== 'number'
? dataProvider.queryMatch.value
: `${dataProvider.queryMatch.value}`
}
values={values}
/>
);
}, [
@ -131,6 +141,7 @@ export const useHoverActions = ({
onFilterAdded,
id,
toggleTopN,
values,
]);
const setContainerRef = useCallback((e: HTMLDivElement) => {

View file

@ -639,5 +639,25 @@ Array [
},
"type": "string",
},
Object {
"aggregatable": false,
"category": "nestedField",
"description": "",
"example": "",
"format": "",
"indexes": Array [
"auditbeat",
"filebeat",
"packetbeat",
],
"name": "nestedField.thirdAttributes",
"searchable": true,
"subType": Object {
"nested": Object {
"path": "nestedField",
},
},
"type": "date",
},
]
`;

View file

@ -439,6 +439,22 @@ export const mocksSource = {
},
},
},
{
aggregatable: false,
category: 'nestedField',
description: '',
example: '',
format: '',
indexes: ['auditbeat', 'filebeat', 'packetbeat'],
name: 'nestedField.thirdAttributes',
searchable: true,
type: 'date',
subType: {
nested: {
path: 'nestedField',
},
},
},
],
};
@ -952,6 +968,22 @@ export const mockBrowserFields: BrowserFields = {
},
},
},
'nestedField.thirdAttributes': {
aggregatable: false,
category: 'nestedField',
description: '',
example: '',
format: '',
indexes: ['auditbeat', 'filebeat', 'packetbeat'],
name: 'nestedField.thirdAttributes',
searchable: true,
type: 'date',
subType: {
nested: {
path: 'nestedField',
},
},
},
},
},
};

View file

@ -30,13 +30,14 @@ export const dataProviderWithOneFilter = [
{
and: [],
enabled: true,
id: '',
id: 'mock-id',
name: 'host.hostname',
excluded: false,
kqlQuery: '',
queryMatch: {
field: 'host.hostname',
value: 'Host-u6ou715rzy',
displayValue: 'Host-u6ou715rzy',
operator: ':' as QueryOperator,
},
},
@ -49,25 +50,27 @@ export const dataProviderWithAndFilters = [
and: [],
enabled: true,
excluded: false,
id: '',
id: 'mock-id',
kqlQuery: '',
name: 'kibana.alerts.workflow_status',
queryMatch: {
field: 'kibana.alerts.workflow_status',
operator: ':' as QueryOperator,
value: 'open',
displayValue: 'open',
},
},
],
enabled: true,
id: '',
id: 'mock-id',
name: 'host.hostname',
excluded: false,
kqlQuery: '',
queryMatch: {
field: 'host.hostname',
value: 'Host-u6ou715rzy',
displayValue: 'Host-u6ou715rzy',
operator: ':' as QueryOperator,
},
},
@ -79,25 +82,27 @@ export const dataProviderWithOrFilters = [
{
and: [],
enabled: true,
id: '',
id: 'mock-id',
name: 'kibana.alerts.workflow_status',
excluded: false,
kqlQuery: '',
queryMatch: {
field: 'kibana.alerts.workflow_status',
value: 'open',
displayValue: 'open',
operator: ':' as QueryOperator,
},
},
],
enabled: true,
id: '',
id: 'mock-id',
name: 'host.hostname',
excluded: false,
kqlQuery: '',
queryMatch: {
field: 'host.hostname',
value: 'Host-u6ou715rzy',
displayValue: 'Host-u6ou715rzy',
operator: ':' as QueryOperator,
},
},
@ -106,25 +111,27 @@ export const dataProviderWithOrFilters = [
{
and: [],
enabled: true,
id: '',
id: 'mock-id',
name: 'kibana.alerts.workflow_status',
excluded: false,
kqlQuery: '',
queryMatch: {
field: 'kibana.alerts.workflow_status',
value: 'closed',
displayValue: 'closed',
operator: ':' as QueryOperator,
},
},
],
enabled: true,
id: '',
id: 'mock-id',
name: 'host.hostname',
excluded: false,
kqlQuery: '',
queryMatch: {
field: 'host.hostname',
value: 'Host-u6ou715rzy',
displayValue: 'Host-u6ou715rzy',
operator: ':' as QueryOperator,
},
},
@ -133,25 +140,27 @@ export const dataProviderWithOrFilters = [
{
and: [],
enabled: true,
id: '',
id: 'mock-id',
name: 'kibana.alerts.workflow_status',
excluded: false,
kqlQuery: '',
queryMatch: {
field: 'kibana.alerts.workflow_status',
value: 'acknowledged',
displayValue: 'acknowledged',
operator: ':' as QueryOperator,
},
},
],
enabled: true,
id: '',
id: 'mock-id',
name: 'host.hostname',
excluded: false,
kqlQuery: '',
queryMatch: {
field: 'host.hostname',
value: 'Host-u6ou715rzy',
displayValue: 'Host-u6ou715rzy',
operator: ':' as QueryOperator,
},
},

View file

@ -33,6 +33,10 @@ jest.mock('react-redux', () => {
};
});
jest.mock('uuid', () => ({
v4: () => 'mock-id',
}));
const id = 'timeline-1';
const renderUseNavigatgeToTimeline = () => renderHook(() => useNavigateToTimeline());

View file

@ -7,12 +7,13 @@
import { useCallback, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import { v4 as uuid } from 'uuid';
import { useDeepEqualSelector } from '../../../../common/hooks/use_selector';
import { SourcererScopeName } from '../../../../common/store/sourcerer/model';
import { sourcererActions } from '../../../../common/store/sourcerer';
import { getDataProvider } from '../../../../common/components/event_details/table/use_action_cell_data_provider';
import type { DataProvider } from '../../../../../common/types/timeline';
import type { DataProvider, QueryOperator } from '../../../../../common/types/timeline';
import { TimelineId, TimelineType } from '../../../../../common/types/timeline';
import { useCreateTimeline } from '../../../../timelines/components/timeline/properties/use_create_timeline';
import { updateProviders } from '../../../../timelines/store/timeline/actions';
@ -21,7 +22,8 @@ import type { TimeRange } from '../../../../common/store/inputs/model';
export interface Filter {
field: string;
value: string;
value: string | string[];
operator?: QueryOperator;
}
export const useNavigateToTimeline = () => {
@ -79,10 +81,17 @@ export const useNavigateToTimeline = () => {
const mainFilter = orFilterGroup[0];
if (mainFilter) {
const dataProvider = getDataProvider(mainFilter.field, '', mainFilter.value);
const dataProvider = getDataProvider(
mainFilter.field,
uuid(),
mainFilter.value,
mainFilter.operator
);
for (const filter of orFilterGroup.slice(1)) {
dataProvider.and.push(getDataProvider(filter.field, '', filter.value));
dataProvider.and.push(
getDataProvider(filter.field, uuid(), filter.value, filter.operator)
);
}
dataProviders.push(dataProvider);
}

View file

@ -0,0 +1,135 @@
/*
* 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 React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ControlledComboboxInput, ControlledDefaultInput } from '.';
import {
convertComboboxValuesToStringArray,
convertValuesToComboboxValueArray,
} from './controlled_combobox_input';
import { getDefaultValue } from './controlled_default_input';
const onChangeCallbackMock = jest.fn();
const renderControlledComboboxInput = (badOverrideValue?: string) =>
render(
<ControlledComboboxInput
value={badOverrideValue || ['test']}
onChangeCallback={onChangeCallbackMock}
/>
);
const renderControlledDefaultInput = (badOverrideValue?: string[]) =>
render(
<ControlledDefaultInput
value={badOverrideValue || 'test'}
onChangeCallback={onChangeCallbackMock}
/>
);
describe('ControlledComboboxInput', () => {
afterEach(jest.clearAllMocks);
it('renders the current value', () => {
renderControlledComboboxInput();
expect(screen.getByText('test'));
});
it('calls onChangeCallback, and disabledButtonCallback when value is removed', () => {
renderControlledComboboxInput();
const removeButton = screen.getByTestId('is-one-of-combobox-input').querySelector('button');
userEvent.click(removeButton as HTMLButtonElement);
expect(onChangeCallbackMock).toHaveBeenLastCalledWith([]);
});
it('handles non arrays by defaulting to an empty state', () => {
renderControlledComboboxInput('nonArray');
expect(onChangeCallbackMock).toHaveBeenLastCalledWith([]);
});
});
describe('ControlledDefaultInput', () => {
afterEach(jest.clearAllMocks);
it('renders the current value', () => {
renderControlledDefaultInput();
expect(screen.getByDisplayValue('test'));
});
it('calls onChangeCallback, and disabledButtonCallback when value is changed', () => {
renderControlledDefaultInput([]);
const inputBox = screen.getByPlaceholderText('value');
userEvent.type(inputBox, 'new value');
expect(onChangeCallbackMock).toHaveBeenLastCalledWith('new value');
});
it('handles arrays by defaulting to the first value', () => {
renderControlledDefaultInput(['testing']);
expect(onChangeCallbackMock).toHaveBeenLastCalledWith('testing');
});
describe('getDefaultValue', () => {
it('Returns a provided value if the value is a string', () => {
expect(getDefaultValue('test')).toBe('test');
});
it('Returns a provided value if the value is a number', () => {
expect(getDefaultValue(0)).toBe(0);
});
it('Returns the first value of a string array', () => {
expect(getDefaultValue(['a', 'b'])).toBe('a');
});
it('Returns the first value of a number array', () => {
expect(getDefaultValue([0, 1])).toBe(0);
});
it('Returns an empty string if given an empty array', () => {
expect(getDefaultValue([])).toBe('');
});
});
describe('convertValuesToComboboxValueArray', () => {
it('returns an empty array if not provided correct input', () => {
expect(convertValuesToComboboxValueArray('test')).toEqual([]);
expect(convertValuesToComboboxValueArray(1)).toEqual([]);
});
it('returns an empty array if provided non array input', () => {
expect(convertValuesToComboboxValueArray('test')).toEqual([]);
expect(convertValuesToComboboxValueArray(1)).toEqual([]);
});
it('Returns a comboboxoption array when provided the correct input', () => {
expect(convertValuesToComboboxValueArray(['a', 'b'])).toEqual([
{ label: 'a' },
{ label: 'b' },
]);
expect(convertValuesToComboboxValueArray([1, 2])).toEqual([{ label: '1' }, { label: '2' }]);
});
});
describe('convertComboboxValuesToStringArray', () => {
it('correctly converts combobox values to a string array ', () => {
expect(convertComboboxValuesToStringArray([])).toEqual([]);
expect(convertComboboxValuesToStringArray([{ label: '1' }, { label: '2' }])).toEqual([
'1',
'2',
]);
});
});
});

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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useState, useEffect, useCallback } from 'react';
import type { EuiComboBoxOptionOption } from '@elastic/eui';
import { EuiComboBox } from '@elastic/eui';
import { isStringOrNumberArray } from '../../timeline/helpers';
import * as i18n from '../translations';
interface ControlledDataProviderInput {
onChangeCallback: (value: string | number | string[]) => void;
value: string | number | Array<string | number>;
}
export const ControlledComboboxInput = ({
value,
onChangeCallback,
}: ControlledDataProviderInput) => {
const [includeValues, setIncludeValues] = useState(convertValuesToComboboxValueArray(value));
useEffect(() => {
onChangeCallback(convertComboboxValuesToStringArray(includeValues));
}, [includeValues, onChangeCallback]);
const onCreateOption = useCallback(
(searchValue: string, flattenedOptions: EuiComboBoxOptionOption[] = includeValues) => {
const normalizedSearchValue = searchValue.trim().toLowerCase();
if (!normalizedSearchValue) {
return;
}
if (
flattenedOptions.findIndex(
(option) => option.label.trim().toLowerCase() === normalizedSearchValue
) === -1
// add the option, because it wasn't found in the current set of `includeValues`
) {
setIncludeValues([
...includeValues,
{
label: searchValue,
},
]);
}
},
[includeValues]
);
const onIncludeValueChanged = useCallback((updatedIncludesValues: EuiComboBoxOptionOption[]) => {
setIncludeValues(updatedIncludesValues);
}, []);
return (
<EuiComboBox
noSuggestions
isClearable={true}
data-test-subj="is-one-of-combobox-input"
selectedOptions={includeValues}
placeholder={i18n.ENTER_ONE_OR_MORE_VALUES}
onCreateOption={onCreateOption}
onChange={onIncludeValueChanged}
/>
);
};
export const convertValuesToComboboxValueArray = (
values: string | number | Array<string | number>
): EuiComboBoxOptionOption[] =>
isStringOrNumberArray(values) ? values.map((item) => ({ label: String(item) })) : [];
export const convertComboboxValuesToStringArray = (values: EuiComboBoxOptionOption[]): string[] =>
values.map((item) => item.label);

View file

@ -0,0 +1,53 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useState, useEffect, useCallback } from 'react';
import { EuiFieldText } from '@elastic/eui';
import { isStringOrNumberArray } from '../../timeline/helpers';
import { sanatizeValue } from '../helpers';
import * as i18n from '../translations';
interface ControlledDataProviderInput {
onChangeCallback: (value: string | number | string[]) => void;
value: string | number | Array<string | number>;
}
const VALUE_INPUT_CLASS_NAME = 'edit-data-provider-value';
export const ControlledDefaultInput = ({
value,
onChangeCallback,
}: ControlledDataProviderInput) => {
const [primitiveValue, setPrimitiveValue] = useState<string | number>(getDefaultValue(value));
useEffect(() => {
onChangeCallback(sanatizeValue(primitiveValue));
}, [primitiveValue, onChangeCallback]);
const onValueChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setPrimitiveValue(e.target.value);
}, []);
return (
<EuiFieldText
className={VALUE_INPUT_CLASS_NAME}
onChange={onValueChange}
placeholder={i18n.VALUE}
value={sanatizeValue(primitiveValue)}
/>
);
};
export const getDefaultValue = (
value: string | number | Array<string | number>
): string | number => {
if (isStringOrNumberArray(value)) {
return value[0] ?? '';
} else return value;
};

View file

@ -0,0 +1,9 @@
/*
* 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.
*/
export { ControlledComboboxInput } from './controlled_combobox_input';
export { ControlledDefaultInput } from './controlled_default_input';

View file

@ -5,14 +5,21 @@
* 2.0.
*/
import { DataProviderType } from '@kbn/timelines-plugin/common';
import { mockBrowserFields } from '../../../common/containers/source/mock';
import { EXISTS_OPERATOR, IS_OPERATOR } from '../timeline/data_providers/data_provider';
import {
EXISTS_OPERATOR,
IS_OPERATOR,
IS_ONE_OF_OPERATOR,
} from '../timeline/data_providers/data_provider';
import {
getCategorizedFieldNames,
getExcludedFromSelection,
getFieldNames,
getQueryOperatorFromSelection,
sanatizeValue,
selectionsAreValid,
} from './helpers';
@ -106,6 +113,9 @@ describe('helpers', () => {
{
label: 'nestedField.secondAttributes',
},
{
label: 'nestedField.thirdAttributes',
},
],
},
{ label: 'source', options: [{ label: 'source.ip' }, { label: 'source.port' }] },
@ -132,6 +142,7 @@ describe('helpers', () => {
label: 'is',
},
],
type: DataProviderType.default,
})
).toBe(true);
});
@ -150,6 +161,7 @@ describe('helpers', () => {
label: 'is',
},
],
type: DataProviderType.default,
})
).toBe(false);
});
@ -165,9 +177,10 @@ describe('helpers', () => {
],
selectedOperator: [
{
label: 'is',
label: 'is one of',
},
],
type: DataProviderType.default,
})
).toBe(false);
});
@ -186,6 +199,7 @@ describe('helpers', () => {
label: '',
},
],
type: DataProviderType.default,
})
).toBe(false);
});
@ -204,6 +218,45 @@ describe('helpers', () => {
label: 'invalid-operator',
},
],
type: DataProviderType.default,
})
).toBe(false);
});
test('it should return false when the selected operator is "is one of", and the DataProviderType is template', () => {
expect(
selectionsAreValid({
browserFields: mockBrowserFields,
selectedField: [
{
label: 'destination.bytes',
},
],
selectedOperator: [
{
label: 'is one of',
},
],
type: DataProviderType.template,
})
).toBe(false);
});
test('it should return false when the selected operator is "is not one of", and the DataProviderType is template', () => {
expect(
selectionsAreValid({
browserFields: mockBrowserFields,
selectedField: [
{
label: 'destination.bytes',
},
],
selectedOperator: [
{
label: 'is not one of',
},
],
type: DataProviderType.template,
})
).toBe(false);
});
@ -219,6 +272,14 @@ describe('helpers', () => {
operator: i18n.IS_NOT,
expected: IS_OPERATOR,
},
{
operator: i18n.IS_ONE_OF,
expected: IS_ONE_OF_OPERATOR,
},
{
operator: i18n.IS_NOT_ONE_OF,
expected: IS_ONE_OF_OPERATOR,
},
{
operator: i18n.EXISTS,
expected: EXISTS_OPERATOR,
@ -230,7 +291,7 @@ describe('helpers', () => {
];
validSelections.forEach(({ operator, expected }) => {
test(`it should the expected operator given "${operator}", a valid selection`, () => {
test(`it should use the expected operator given "${operator}", a valid selection`, () => {
expect(
getQueryOperatorFromSelection([
{
@ -282,6 +343,15 @@ describe('helpers', () => {
])
).toBe(false);
});
test('it returns false when the "is one of" operator is selected', () => {
expect(
getExcludedFromSelection([
{
label: i18n.IS_ONE_OF,
},
])
).toBe(false);
});
test('it returns false when the "exists" operator is selected', () => {
expect(
@ -313,6 +383,16 @@ describe('helpers', () => {
).toBe(true);
});
test('it returns true when "is not one of" is selected', () => {
expect(
getExcludedFromSelection([
{
label: i18n.IS_NOT_ONE_OF,
},
])
).toBe(true);
});
test('it returns true when "does not exist" is selected', () => {
expect(
getExcludedFromSelection([
@ -323,4 +403,19 @@ describe('helpers', () => {
).toBe(true);
});
});
describe('sanatizeValue', () => {
it("returns a provided value if it's a string or number as a string", () => {
expect(sanatizeValue('a string')).toBe('a string');
expect(sanatizeValue(1)).toBe('1');
});
it('returns the string interpretation of the first value of an array', () => {
expect(sanatizeValue(['a string', 'another value'])).toBe('a string');
expect(sanatizeValue([1, 'another value'])).toBe('1');
expect(sanatizeValue([])).toBe('');
expect(sanatizeValue([null])).toBe('null');
expect(sanatizeValue([undefined])).toBe('undefined');
});
});
});

View file

@ -6,12 +6,18 @@
*/
import { findIndex } from 'lodash/fp';
import type { EuiComboBoxOptionOption } from '@elastic/eui';
import { DataProviderType } from '@kbn/timelines-plugin/common';
import type { BrowserField, BrowserFields } from '../../../common/containers/source';
import { getAllFieldsByName } from '../../../common/containers/source';
import type { QueryOperator } from '../timeline/data_providers/data_provider';
import { EXISTS_OPERATOR, IS_OPERATOR } from '../timeline/data_providers/data_provider';
import {
EXISTS_OPERATOR,
IS_OPERATOR,
IS_ONE_OF_OPERATOR,
} from '../timeline/data_providers/data_provider';
import * as i18n from './translations';
@ -23,6 +29,12 @@ export const operatorLabels: EuiComboBoxOptionOption[] = [
{
label: i18n.IS_NOT,
},
{
label: i18n.IS_ONE_OF,
},
{
label: i18n.IS_NOT_ONE_OF,
},
{
label: i18n.EXISTS,
},
@ -57,18 +69,23 @@ export const selectionsAreValid = ({
browserFields,
selectedField,
selectedOperator,
type,
}: {
browserFields: BrowserFields;
selectedField: EuiComboBoxOptionOption[];
selectedOperator: EuiComboBoxOptionOption[];
type: DataProviderType;
}): boolean => {
const fieldId = selectedField.length > 0 ? selectedField[0].label : '';
const operator = selectedOperator.length > 0 ? selectedOperator[0].label : '';
const fieldIsValid = browserFields && getAllFieldsByName(browserFields)[fieldId] != null;
const operatorIsValid = findIndex((o) => o.label === operator, operatorLabels) !== -1;
const isOneOfOperatorSelectionWithTemplate =
type === DataProviderType.template &&
(operator === i18n.IS_ONE_OF || operator === i18n.IS_NOT_ONE_OF);
return fieldIsValid && operatorIsValid;
return fieldIsValid && operatorIsValid && !isOneOfOperatorSelectionWithTemplate;
};
/** Returns a `QueryOperator` based on the user's Operator selection */
@ -81,6 +98,9 @@ export const getQueryOperatorFromSelection = (
case i18n.IS: // fall through
case i18n.IS_NOT:
return IS_OPERATOR;
case i18n.IS_ONE_OF: // fall through
case i18n.IS_NOT_ONE_OF:
return IS_ONE_OF_OPERATOR;
case i18n.EXISTS: // fall through
case i18n.DOES_NOT_EXIST:
return EXISTS_OPERATOR;
@ -97,9 +117,19 @@ export const getExcludedFromSelection = (selectedOperator: EuiComboBoxOptionOpti
switch (selection) {
case i18n.IS_NOT: // fall through
case i18n.IS_NOT_ONE_OF:
case i18n.DOES_NOT_EXIST:
return true;
default:
return false;
}
};
/** Ensure that a value passed to ControlledDefaultInput is not an array */
export const sanatizeValue = (value: string | number | unknown[]): string => {
if (Array.isArray(value)) {
// fun fact: value should never be an array
return value.length ? `${value[0]}` : '';
}
return `${value}`;
};

View file

@ -15,6 +15,7 @@ import {
DataProviderType,
IS_OPERATOR,
EXISTS_OPERATOR,
IS_ONE_OF_OPERATOR,
} from '../timeline/data_providers/data_provider';
import { StatefulEditDataProvider } from '.';
@ -144,6 +145,46 @@ describe('StatefulEditDataProvider', () => {
expect(screen.getByText('does not exist')).toBeInTheDocument();
});
test('it renders the "is one of" operator in human-readable format', () => {
render(
<TestProviders>
<StatefulEditDataProvider
andProviderId={undefined}
browserFields={mockBrowserFields}
field={field}
isExcluded={false}
onDataProviderEdited={jest.fn()}
operator={IS_ONE_OF_OPERATOR}
providerId={`hosts-table-hostName-${value}`}
timelineId={timelineId}
value={value}
/>
</TestProviders>
);
expect(screen.getByText('is one of')).toBeInTheDocument();
});
test('it renders the negated "is one of" operator in a humanized format when isExcluded is true', () => {
render(
<TestProviders>
<StatefulEditDataProvider
andProviderId={undefined}
browserFields={mockBrowserFields}
field={field}
isExcluded={true}
onDataProviderEdited={jest.fn()}
operator={IS_ONE_OF_OPERATOR}
providerId={`hosts-table-hostName-${value}`}
timelineId={timelineId}
value={value}
/>
</TestProviders>
);
expect(screen.getByText('is not one of')).toBeInTheDocument();
});
test('it renders the current value when the operator is "is"', () => {
render(
<TestProviders>
@ -186,6 +227,48 @@ describe('StatefulEditDataProvider', () => {
expect(screen.getByDisplayValue(value)).toBeInTheDocument();
});
test('it handles bad values when the operator is "is one of" by showing default placholder', () => {
const reallyAnArrayOfBadValues = [undefined, null] as unknown as string[];
render(
<TestProviders>
<StatefulEditDataProvider
andProviderId={undefined}
browserFields={mockBrowserFields}
field={field}
isExcluded={false}
onDataProviderEdited={jest.fn()}
operator={IS_ONE_OF_OPERATOR}
providerId={`hosts-table-hostName-${value}`}
timelineId={timelineId}
value={reallyAnArrayOfBadValues}
/>
</TestProviders>
);
expect(screen.getByText('enter one or more values')).toBeInTheDocument();
});
test('it renders selected values when the type of value is an array and the operator is "is one of"', () => {
const values = ['apple', 'banana', 'cherry'];
render(
<TestProviders>
<StatefulEditDataProvider
andProviderId={undefined}
browserFields={mockBrowserFields}
field={field}
isExcluded={false}
onDataProviderEdited={jest.fn()}
operator={IS_ONE_OF_OPERATOR}
providerId={`hosts-table-hostName-${value}`}
timelineId={timelineId}
value={values}
/>
</TestProviders>
);
expect(screen.getByText(values[0])).toBeInTheDocument();
expect(screen.getByText(values[1])).toBeInTheDocument();
expect(screen.getByText(values[2])).toBeInTheDocument();
});
test('it does NOT render the current value when the operator is "is not" (isExcluded is true)', () => {
render(
<TestProviders>
@ -226,6 +309,27 @@ describe('StatefulEditDataProvider', () => {
expect(screen.getByPlaceholderText('value')).toBeInTheDocument();
});
test('it renders the expected placeholder when value is empty and operator is "is one of"', () => {
render(
<TestProviders>
<StatefulEditDataProvider
andProviderId={undefined}
browserFields={mockBrowserFields}
field={field}
isExcluded={false}
onDataProviderEdited={jest.fn()}
operator={IS_ONE_OF_OPERATOR}
providerId={`hosts-table-hostName-${value}`}
timelineId={timelineId}
value={[]}
/>
</TestProviders>
);
// EuiCombobox does not render placeholder text with placeholder tag
expect(screen.getByText('enter one or more values')).toBeInTheDocument();
});
test('it does NOT render value when the operator is "exists"', () => {
render(
<TestProviders>
@ -244,6 +348,7 @@ describe('StatefulEditDataProvider', () => {
);
expect(screen.queryByPlaceholderText('value')).not.toBeInTheDocument();
expect(screen.queryByDisplayValue('Value')).not.toBeInTheDocument();
});
test('it does NOT render value when the operator is "not exists" (isExcluded is true)', () => {

View file

@ -5,12 +5,12 @@
* 2.0.
*/
import { noop, startsWith, endsWith } from 'lodash/fp';
import { noop } from 'lodash/fp';
import type { EuiComboBoxOptionOption } from '@elastic/eui';
import {
EuiButton,
EuiComboBox,
EuiFieldText,
EuiCallOut,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
@ -33,6 +33,8 @@ import {
selectionsAreValid,
} from './helpers';
import { ControlledComboboxInput, ControlledDefaultInput } from './components';
import * as i18n from './translations';
const EDIT_DATA_PROVIDER_WIDTH = 400;
@ -55,19 +57,18 @@ interface Props {
operator: QueryOperator;
providerId: string;
timelineId: string;
value: string | number;
value: string | number | Array<string | number>;
type?: DataProviderType;
}
const sanatizeValue = (value: string | number): string =>
Array.isArray(value) ? `${value[0]}` : `${value}`; // fun fact: value should never be an array
export const getInitialOperatorLabel = (
isExcluded: boolean,
operator: QueryOperator
): EuiComboBoxOptionOption[] => {
if (operator === ':') {
return isExcluded ? [{ label: i18n.IS_NOT }] : [{ label: i18n.IS }];
} else if (operator === 'includes') {
return isExcluded ? [{ label: i18n.IS_NOT_ONE_OF }] : [{ label: i18n.IS_ONE_OF }];
} else {
return isExcluded ? [{ label: i18n.DOES_NOT_EXIST }] : [{ label: i18n.EXISTS }];
}
@ -90,7 +91,33 @@ export const StatefulEditDataProvider = React.memo<Props>(
const [updatedOperator, setUpdatedOperator] = useState<EuiComboBoxOptionOption[]>(
getInitialOperatorLabel(isExcluded, operator)
);
const [updatedValue, setUpdatedValue] = useState<string | number>(value);
const [updatedValue, setUpdatedValue] = useState<string | number | Array<string | number>>(
value
);
const showComboBoxInput = useMemo(
() =>
updatedOperator.length > 0 &&
(updatedOperator[0].label === i18n.IS_ONE_OF ||
updatedOperator[0].label === i18n.IS_NOT_ONE_OF),
[updatedOperator]
);
const showValueInput = useMemo(
() =>
type !== DataProviderType.template &&
updatedOperator.length > 0 &&
updatedOperator[0].label !== i18n.EXISTS &&
updatedOperator[0].label !== i18n.DOES_NOT_EXIST &&
!showComboBoxInput,
[showComboBoxInput, type, updatedOperator]
);
const disableSave = useMemo(
() => showComboBoxInput && Array.isArray(updatedValue) && !updatedValue.length,
[showComboBoxInput, updatedValue]
);
/** Focuses the Value input if it is visible, falling back to the Save button if it's not */
const focusInput = () => {
@ -126,8 +153,8 @@ export const StatefulEditDataProvider = React.memo<Props>(
focusInput();
}, []);
const onValueChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setUpdatedValue(e.target.value);
const onValueChange = useCallback((changedValue: string | number | string[]) => {
setUpdatedValue(changedValue);
}, []);
const disableScrolling = () => {
@ -170,14 +197,6 @@ export const StatefulEditDataProvider = React.memo<Props>(
type,
]);
const isValueFieldInvalid = useMemo(
() =>
type !== DataProviderType.template &&
(startsWith('{', sanatizeValue(updatedValue)) ||
endsWith('}', sanatizeValue(updatedValue))),
[type, updatedValue]
);
useEffect(() => {
disableScrolling();
return () => {
@ -224,22 +243,6 @@ export const StatefulEditDataProvider = React.memo<Props>(
/>
</EuiFormRow>
</EuiFlexItem>
{type !== DataProviderType.template &&
updatedOperator.length > 0 &&
updatedOperator[0].label !== i18n.EXISTS &&
updatedOperator[0].label !== i18n.DOES_NOT_EXIST ? (
<EuiFlexItem grow={false}>
<EuiFormRow label={i18n.VALUE_LABEL}>
<EuiFieldText
className={VALUE_INPUT_CLASS_NAME}
onChange={onValueChange}
placeholder={i18n.VALUE}
value={sanatizeValue(updatedValue)}
isInvalid={isValueFieldInvalid}
/>
</EuiFormRow>
</EuiFlexItem>
) : null}
</EuiFlexGroup>
</EuiFlexItem>
@ -248,6 +251,35 @@ export const StatefulEditDataProvider = React.memo<Props>(
</EuiFlexItem>
<EuiFlexItem grow={false}>
{showValueInput && (
<EuiFormRow label={i18n.VALUE_LABEL}>
<ControlledDefaultInput onChangeCallback={onValueChange} value={value} />
</EuiFormRow>
)}
{showComboBoxInput && type !== DataProviderType.template && (
<EuiFormRow label={i18n.VALUE_LABEL}>
<ControlledComboboxInput onChangeCallback={onValueChange} value={value} />
</EuiFormRow>
)}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiSpacer size="m" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
{type === DataProviderType.template && showComboBoxInput && (
<>
<EuiCallOut
color="warning"
iconType="alert"
size="s"
title={i18n.UNAVAILABLE_OPERATOR(updatedOperator[0].label)}
/>
<EuiSpacer size="m" />
</>
)}
<EuiFlexGroup justifyContent="flexEnd" gutterSize="none">
<EuiFlexItem grow={false}>
<EuiButton
@ -257,10 +289,11 @@ export const StatefulEditDataProvider = React.memo<Props>(
fill={true}
isDisabled={
!selectionsAreValid({
type,
browserFields,
selectedField: updatedField,
selectedOperator: updatedOperator,
}) || isValueFieldInvalid
}) || disableSave
}
onClick={handleSave}
size="m"

View file

@ -33,10 +33,28 @@ export const IS = i18n.translate('xpack.securitySolution.editDataProvider.isLabe
defaultMessage: 'is',
});
export const IS_ONE_OF = i18n.translate('xpack.securitySolution.editDataProvider.isOneOfLabel', {
defaultMessage: 'is one of',
});
export const IS_NOT = i18n.translate('xpack.securitySolution.editDataProvider.isNotLabel', {
defaultMessage: 'is not',
});
export const IS_NOT_ONE_OF = i18n.translate(
'xpack.securitySolution.editDataProvider.isNotOneOfLabel',
{
defaultMessage: 'is not one of',
}
);
export const ENTER_ONE_OR_MORE_VALUES = i18n.translate(
'xpack.securitySolution.editDataProvider.includesPlaceholder',
{
defaultMessage: 'enter one or more values',
}
);
export const OPERATOR = i18n.translate('xpack.securitySolution.editDataProvider.operatorLabel', {
defaultMessage: 'Operator',
});
@ -59,3 +77,9 @@ export const SELECT_AN_OPERATOR = i18n.translate(
defaultMessage: 'Select an operator',
}
);
export const UNAVAILABLE_OPERATOR = (operator: string) =>
i18n.translate('xpack.securitySolution.editDataProvider.unavailableOperator', {
values: { operator },
defaultMessage: '{operator} operator is unavailable with templates',
});

View file

@ -111,6 +111,7 @@ const FlyoutHeaderPanelComponent: React.FC<FlyoutHeaderPanelProps> = ({ timeline
() => !isEmpty(dataProviders) || !isEmpty(get('filterQuery.kuery.expression', kqlQuery)),
[dataProviders, kqlQuery]
);
const getKqlQueryTimeline = useMemo(() => timelineSelectors.getKqlFilterQuerySelector(), []);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const kqlQueryTimeline = useSelector((state: State) => getKqlQueryTimeline(state, timelineId)!);

View file

@ -626,6 +626,26 @@ exports[`ColumnHeaders rendering renders correctly against snapshot 1`] = `
},
"type": "string",
},
"nestedField.thirdAttributes": Object {
"aggregatable": false,
"category": "nestedField",
"description": "",
"example": "",
"format": "",
"indexes": Array [
"auditbeat",
"filebeat",
"packetbeat",
],
"name": "nestedField.thirdAttributes",
"searchable": true,
"subType": Object {
"nested": Object {
"path": "nestedField",
},
},
"type": "date",
},
},
},
"source": Object {

View file

@ -2,12 +2,7 @@
exports[`netflowRowRenderer renders correctly against snapshot 1`] = `
<DocumentFragment>
.c15 svg {
position: relative;
top: -1px;
}
.c0 {
.c0 {
display: inline-block;
font-size: 12px;
line-height: 1.5;
@ -21,6 +16,11 @@ exports[`netflowRowRenderer renders correctly against snapshot 1`] = `
border-radius: 4px;
}
.c15 svg {
position: relative;
top: -1px;
}
.c13,
.c13 * {
display: inline-block;

View file

@ -3,6 +3,7 @@
exports[`Provider rendering renders correctly against snapshot 1`] = `
<ProviderItemBadge
deleteProvider={[Function]}
displayValue="Provider 1"
field="name"
isEnabled={true}
isExcluded={false}

View file

@ -24,7 +24,7 @@ import type { BrowserFields } from '../../../../common/containers/source';
import { TimelineType } from '../../../../../common/types/timeline';
import { useDeepEqualSelector } from '../../../../common/hooks/use_selector';
import { StatefulEditDataProvider } from '../../edit_data_provider';
import { addContentToTimeline } from './helpers';
import { addContentToTimeline, getDisplayValue } from './helpers';
import { DataProviderType } from './data_provider';
import { timelineSelectors } from '../../../store/timeline';
import { ADD_FIELD_LABEL, ADD_TEMPLATE_FIELD_LABEL } from './translations';
@ -71,14 +71,14 @@ const AddDataProviderPopoverComponent: React.FC<AddDataProviderPopoverProps> = (
onAddedToTimeline: handleClosePopover,
providerToAdd: {
id: providerId,
name: value,
name: field,
enabled: true,
excluded,
kqlQuery: '',
type,
queryMatch: {
displayField: undefined,
displayValue: undefined,
displayValue: getDisplayValue(value),
field,
value,
operator,

View file

@ -13,8 +13,11 @@ export const IS_OPERATOR = ':';
/** The `exists` operator in a KQL query */
export const EXISTS_OPERATOR = ':*';
/** The `is one of` faux operator in a KQL query */
export const IS_ONE_OF_OPERATOR = 'includes';
/** The operator applied to a field */
export type QueryOperator = ':' | ':*';
export type QueryOperator = typeof IS_OPERATOR | typeof EXISTS_OPERATOR | typeof IS_ONE_OF_OPERATOR;
export enum DataProviderType {
default = 'default',
@ -24,7 +27,7 @@ export enum DataProviderType {
export interface QueryMatch {
field: string;
displayField?: string;
value: string | number;
value: string | number | Array<string | number>;
displayValue?: string | number;
operator: QueryOperator;
}

View file

@ -23,6 +23,7 @@ import {
reorder,
sourceAndDestinationAreSameDroppable,
unFlattenGroups,
getDisplayValue,
} from './helpers';
import {
providerA,
@ -1103,4 +1104,18 @@ describe('helpers', () => {
});
});
});
describe('getDisplayValue', () => {
it('converts an array (is one of query) to correct format for a string array', () => {
expect(getDisplayValue(['a', 'b', 'c'])).toBe('( a OR b OR c )');
expect(getDisplayValue([1, 2, 3])).toBe('( 1 OR 2 OR 3 )');
});
it('handles an empty array', () => {
expect(getDisplayValue([])).toBe('');
});
it('returns a provided value if not an array', () => {
expect(getDisplayValue(1)).toBe(1);
expect(getDisplayValue('text')).toBe('text');
});
});
});

View file

@ -10,6 +10,7 @@ import type { DraggableLocation } from 'react-beautiful-dnd';
import type { Dispatch } from 'redux';
import { updateProviders } from '../../../store/timeline/actions';
import { isStringOrNumberArray } from '../helpers';
import type { DataProvider, DataProvidersAnd } from './data_provider';
@ -342,3 +343,15 @@ export const addContentToTimeline = ({
});
}
};
export const getDisplayValue = (
value: string | number | Array<string | number>
): string | number => {
if (isStringOrNumberArray(value)) {
if (value.length) {
return `( ${value.join(' OR ')} )`;
}
return '';
}
return value;
};

View file

@ -32,7 +32,8 @@ export const Provider = React.memo<OwnProps>(({ dataProvider }) => {
toggleExcludedProvider={noop}
toggleEnabledProvider={noop}
toggleTypeProvider={noop}
val={dataProvider.queryMatch.displayValue || dataProvider.queryMatch.value}
displayValue={String(dataProvider.queryMatch.displayValue ?? dataProvider.queryMatch.value)}
val={dataProvider.queryMatch.value}
operator={dataProvider.queryMatch.operator || IS_OPERATOR}
type={dataProvider.type || DataProviderType.default}
/>

View file

@ -16,7 +16,7 @@ import { getEmptyString } from '../../../../common/components/empty_value';
import { ProviderContainer } from '../../../../common/components/drag_and_drop/provider_container';
import type { QueryOperator } from './data_provider';
import { DataProviderType, EXISTS_OPERATOR } from './data_provider';
import { DataProviderType, EXISTS_OPERATOR, IS_ONE_OF_OPERATOR } from './data_provider';
import * as i18n from './translations';
@ -102,7 +102,8 @@ interface ProviderBadgeProps {
providerId: string;
togglePopover: () => void;
toggleType: () => void;
val: string | number;
displayValue: string;
val: string | number | Array<string | number>;
operator: QueryOperator;
type: DataProviderType;
timelineType: TimelineType;
@ -124,6 +125,7 @@ export const ProviderBadge = React.memo<ProviderBadgeProps>(
providerId,
togglePopover,
toggleType,
displayValue,
val,
type,
timelineType,
@ -160,7 +162,9 @@ export const ProviderBadge = React.memo<ProviderBadgeProps>(
<>
{prefix}
{operator !== EXISTS_OPERATOR ? (
<span className="field-value">{`${field}: "${formattedValue}"`}</span>
<span className="field-value">{`${field}: "${
operator === 'includes' ? displayValue : formattedValue
}"`}</span>
) : (
<span className="field-value">
{field} {i18n.EXISTS_LABEL}
@ -168,7 +172,7 @@ export const ProviderBadge = React.memo<ProviderBadgeProps>(
)}
</>
),
[field, formattedValue, operator, prefix]
[displayValue, field, formattedValue, operator, prefix]
);
const ariaLabel = useMemo(
@ -196,7 +200,10 @@ export const ProviderBadge = React.memo<ProviderBadgeProps>(
{content}
</ProviderBadgeStyled>
{timelineType === TimelineType.template && (
{/* Add a UI feature to let users know the is one of operator doesnt work with timeline templates:
https://github.com/elastic/kibana/issues/142437 */}
{timelineType === TimelineType.template && operator !== IS_ONE_OF_OPERATOR && (
<TemplateFieldBadge toggleType={toggleType} type={type} />
)}
</>

View file

@ -16,7 +16,7 @@ import type { BrowserFields } from '../../../../common/containers/source';
import type { OnDataProviderEdited } from '../events';
import type { QueryOperator } from './data_provider';
import { DataProviderType, EXISTS_OPERATOR } from './data_provider';
import { DataProviderType, EXISTS_OPERATOR, IS_ONE_OF_OPERATOR } from './data_provider';
import { StatefulEditDataProvider } from '../../edit_data_provider';
import * as i18n from './translations';
@ -48,7 +48,7 @@ interface OwnProps {
toggleEnabledProvider: () => void;
toggleExcludedProvider: () => void;
toggleTypeProvider: () => void;
value: string | number;
value: string | number | Array<string | number>;
type: DataProviderType;
}
@ -80,7 +80,7 @@ interface GetProviderActionsProps {
toggleEnabled: () => void;
toggleExcluded: () => void;
toggleType: () => void;
value: string | number;
value: string | number | Array<string | number>;
type: DataProviderType;
}
@ -138,7 +138,7 @@ export const getProviderActions = ({
timelineType === TimelineType.template
? {
className: CONVERT_TO_FIELD_CLASS_NAME,
disabled: isLoading,
disabled: isLoading || operator === IS_ONE_OF_OPERATOR,
icon: 'visText',
name:
type === DataProviderType.template

View file

@ -43,7 +43,8 @@ interface ProviderItemBadgeProps {
toggleEnabledProvider: () => void;
toggleExcludedProvider: () => void;
toggleTypeProvider: () => void;
val: string | number;
displayValue?: string;
val: string | number | Array<string | number>;
type?: DataProviderType;
wrapperRef?: React.MutableRefObject<HTMLDivElement | null>;
}
@ -67,6 +68,7 @@ export const ProviderItemBadge = React.memo<ProviderItemBadgeProps>(
toggleEnabledProvider,
toggleExcludedProvider,
toggleTypeProvider,
displayValue,
val,
type = DataProviderType.default,
wrapperRef,
@ -144,6 +146,7 @@ export const ProviderItemBadge = React.memo<ProviderItemBadgeProps>(
providerId={providerId}
togglePopover={togglePopover}
toggleType={onToggleTypeProvider}
displayValue={displayValue ?? String(val)}
val={val}
operator={operator}
type={type}
@ -152,6 +155,7 @@ export const ProviderItemBadge = React.memo<ProviderItemBadgeProps>(
),
[
deleteProvider,
displayValue,
field,
isEnabled,
isExcluded,

View file

@ -110,9 +110,6 @@ const ParensContainer = styled(EuiFlexItem)`
align-self: center;
`;
const getDataProviderValue = (dataProvider: DataProvidersAnd) =>
dataProvider.queryMatch.displayValue ?? dataProvider.queryMatch.value;
/**
* Renders an interactive card representation of the data providers. It also
* affords uniform UI controls for the following actions:
@ -264,6 +261,10 @@ export const DataProvidersGroupItem = React.memo<DataProvidersGroupItem>(
[onKeyDown]
);
const displayValue = String(
dataProvider.queryMatch.displayValue ?? dataProvider.queryMatch.value
);
const DraggableContent = useCallback(
(provided, snapshot) => (
<div
@ -302,7 +303,8 @@ export const DataProvidersGroupItem = React.memo<DataProvidersGroupItem>(
toggleEnabledProvider={handleToggleEnabledProvider}
toggleExcludedProvider={handleToggleExcludedProvider}
toggleTypeProvider={handleToggleTypeProvider}
val={getDataProviderValue(dataProvider)}
displayValue={displayValue}
val={dataProvider.queryMatch.value}
type={dataProvider.type}
wrapperRef={keyboardHandlerRef}
/>
@ -323,6 +325,7 @@ export const DataProvidersGroupItem = React.memo<DataProvidersGroupItem>(
[
browserFields,
dataProvider,
displayValue,
group,
handleDataProviderEdited,
handleDeleteProvider,

View file

@ -36,7 +36,7 @@ export type OnDataProviderEdited = ({
id: string;
operator: QueryOperator;
providerId: string;
value: string | number;
value: string | number | Array<string | number>;
type: DataProvider['type'];
}) => void;

View file

@ -7,9 +7,19 @@
import { cloneDeep } from 'lodash/fp';
import { DataProviderType } from './data_providers/data_provider';
import { DataProviderType, EXISTS_OPERATOR, IS_OPERATOR } from './data_providers/data_provider';
import { mockDataProviders } from './data_providers/mock/mock_data_providers';
import { buildGlobalQuery, showGlobalFilters } from './helpers';
import {
buildExistsQueryMatch,
buildGlobalQuery,
buildIsOneOfQueryMatch,
buildIsQueryMatch,
handleIsOperator,
isStringOrNumberArray,
showGlobalFilters,
} from './helpers';
import { mockBrowserFields } from '../../../common/containers/source/mock';
const cleanUpKqlQuery = (str: string) => str.replace(/\n/g, '').replace(/\s\s+/g, ' ');
@ -96,6 +106,24 @@ describe('Build KQL Query', () => {
expect(cleanUpKqlQuery(kqlQuery)).toEqual('name : "Provider 2"');
});
test('Build KQL query with "includes" operator', () => {
const dataProviders = cloneDeep(mockDataProviders.slice(0, 1));
dataProviders[0].enabled = true;
dataProviders[0].queryMatch.operator = 'includes';
dataProviders[0].queryMatch.value = ['a', 'b', 'c'];
const kqlQuery = buildGlobalQuery(dataProviders, mockBrowserFields);
expect(cleanUpKqlQuery(kqlQuery)).toEqual(`name : (\"a\" OR \"b\" OR \"c\")`);
});
test('Handles bad inputs to buildKQLQuery', () => {
const dataProviders = cloneDeep(mockDataProviders.slice(0, 1));
dataProviders[0].enabled = true;
dataProviders[0].queryMatch.operator = 'includes';
dataProviders[0].queryMatch.value = [undefined] as unknown as string[];
const kqlQuery = buildGlobalQuery(dataProviders, mockBrowserFields);
expect(cleanUpKqlQuery(kqlQuery)).toEqual('name : [null]');
});
test('Build KQL query with two data provider and second is disabled', () => {
const dataProviders = cloneDeep(mockDataProviders.slice(0, 2));
dataProviders[1].enabled = false;
@ -243,3 +271,123 @@ describe('Build KQL Query', () => {
});
});
});
describe('isStringOrNumberArray', () => {
test('it returns false when value is not an array', () => {
expect(isStringOrNumberArray('just a string')).toBe(false);
});
test('it returns false when value is an array of mixed types', () => {
expect(isStringOrNumberArray(['mixed', 123, 'types'])).toBe(false);
});
test('it returns false when value is an array of bad types', () => {
const badValues = [undefined, null, {}] as unknown as string[];
expect(isStringOrNumberArray(badValues)).toBe(false);
});
test('it returns true when value is an empty array', () => {
expect(isStringOrNumberArray([])).toBe(true);
});
test('it returns true when value is an array of all strings', () => {
expect(isStringOrNumberArray(['all', 'string', 'values'])).toBe(true);
});
test('it returns true when value is an array of all numbers', () => {
expect(isStringOrNumberArray([123, 456, 789])).toBe(true);
});
describe('queryHandlerFunctions', () => {
describe('handleIsOperator', () => {
it('returns the entire query unchanged, if value is an array', () => {
expect(
handleIsOperator({
browserFields: {},
field: 'host.name',
isExcluded: '',
isFieldTypeNested: false,
type: undefined,
value: ['some', 'values'],
})
).toBe('host.name : ["some","values"]');
});
});
});
describe('buildExistsQueryMatch', () => {
it('correcty computes EXISTS query with no nested field', () => {
expect(
buildExistsQueryMatch({ isFieldTypeNested: false, field: 'host', browserFields: {} })
).toBe(`host ${EXISTS_OPERATOR}`);
});
it('correcty computes EXISTS query with nested field', () => {
expect(
buildExistsQueryMatch({
isFieldTypeNested: true,
field: 'nestedField.firstAttributes',
browserFields: mockBrowserFields,
})
).toBe(`nestedField: { firstAttributes: * }`);
});
});
describe('buildIsQueryMatch', () => {
it('correcty computes IS query with no nested field', () => {
expect(
buildIsQueryMatch({
isFieldTypeNested: false,
field: 'nestedField.thirdAttributes',
value: 100000,
browserFields: {},
})
).toBe(`nestedField.thirdAttributes ${IS_OPERATOR} 100000`);
});
it('correcty computes IS query with nested date field', () => {
expect(
buildIsQueryMatch({
isFieldTypeNested: true,
browserFields: mockBrowserFields,
field: 'nestedField.thirdAttributes',
value: 100000,
})
).toBe(`nestedField: { thirdAttributes${IS_OPERATOR} \"100000\" }`);
});
it('correcty computes IS query with nested string field', () => {
expect(
buildIsQueryMatch({
isFieldTypeNested: true,
browserFields: mockBrowserFields,
field: 'nestedField.secondAttributes',
value: 'text',
})
).toBe(`nestedField: { secondAttributes${IS_OPERATOR} text }`);
});
});
describe('buildIsOneOfQueryMatch', () => {
it('correcty computes IS ONE OF query with numbers', () => {
expect(
buildIsOneOfQueryMatch({
field: 'kibana.alert.worflow_status',
value: [1, 2, 3],
})
).toBe('kibana.alert.worflow_status : (1 OR 2 OR 3)');
});
it('correcty computes IS ONE OF query with strings', () => {
expect(
buildIsOneOfQueryMatch({
field: 'kibana.alert.worflow_status',
value: ['a', 'b', 'c'],
})
).toBe(`kibana.alert.worflow_status : (\"a\" OR \"b\" OR \"c\")`);
});
it('correcty computes IS ONE OF query if value is an empty array', () => {
expect(
buildIsOneOfQueryMatch({
field: 'kibana.alert.worflow_status',
value: [],
})
).toBe("kibana.alert.worflow_status : ''");
});
});
});

View file

@ -9,21 +9,26 @@ import { isEmpty, get } from 'lodash/fp';
import memoizeOne from 'memoize-one';
import {
handleSkipFocus,
elementOrChildrenHasFocus,
getFocusedAriaColindexCell,
getTableSkipFocus,
handleSkipFocus,
stopPropagationAndPreventDefault,
} from '@kbn/timelines-plugin/public';
import { escapeQueryValue } from '../../../common/lib/kuery';
import type { DataProvider, DataProvidersAnd } from './data_providers/data_provider';
import { DataProviderType, EXISTS_OPERATOR } from './data_providers/data_provider';
import { assertUnreachable } from '../../../../common/utility_types';
import type { BrowserFields } from '../../../common/containers/source';
import { escapeQueryValue } from '../../../common/lib/kuery';
import type { DataProvider, DataProvidersAnd } from './data_providers/data_provider';
import {
DataProviderType,
EXISTS_OPERATOR,
IS_ONE_OF_OPERATOR,
IS_OPERATOR,
} from './data_providers/data_provider';
import { EVENTS_TABLE_CLASS_NAME } from './styles';
const isNumber = (value: string | number) => !isNaN(Number(value));
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()}`;
@ -86,27 +91,30 @@ const checkIfFieldTypeIsNested = (field: string, browserFields: BrowserFields) =
const buildQueryMatch = (
dataProvider: DataProvider | DataProvidersAnd,
browserFields: BrowserFields
) =>
`${dataProvider.excluded ? 'NOT ' : ''}${
dataProvider.queryMatch.operator !== EXISTS_OPERATOR &&
dataProvider.type !== DataProviderType.template
? checkIfFieldTypeIsNested(dataProvider.queryMatch.field, browserFields)
? convertNestedFieldToQuery(
dataProvider.queryMatch.field,
dataProvider.queryMatch.value,
browserFields
)
: checkIfFieldTypeIsDate(dataProvider.queryMatch.field, browserFields)
? convertDateFieldToQuery(dataProvider.queryMatch.field, dataProvider.queryMatch.value)
: `${dataProvider.queryMatch.field} : ${
isNumber(dataProvider.queryMatch.value)
? dataProvider.queryMatch.value
: escapeQueryValue(dataProvider.queryMatch.value)
}`
: checkIfFieldTypeIsNested(dataProvider.queryMatch.field, browserFields)
? convertNestedFieldToExistQuery(dataProvider.queryMatch.field, browserFields)
: `${dataProvider.queryMatch.field} ${EXISTS_OPERATOR}`
}`.trim();
) => {
const {
excluded,
type,
queryMatch: { field, operator, value },
} = dataProvider;
const isFieldTypeNested = checkIfFieldTypeIsNested(field, browserFields);
const isExcluded = excluded ? 'NOT ' : '';
switch (operator) {
case IS_OPERATOR:
return handleIsOperator({ browserFields, field, isExcluded, isFieldTypeNested, type, value });
case EXISTS_OPERATOR:
return `${isExcluded}${buildExistsQueryMatch({ browserFields, field, isFieldTypeNested })}`;
case IS_ONE_OF_OPERATOR:
return handleIsOneOfOperator({ field, isExcluded, value });
default:
assertUnreachable(operator);
}
};
export const buildGlobalQuery = (dataProviders: DataProvider[], browserFields: BrowserFields) =>
dataProviders
@ -253,3 +261,94 @@ export const focusUtilityBarAction = (containerElement: HTMLElement | null) => {
export const resetKeyboardFocus = () => {
document.querySelector<HTMLAnchorElement>('header.headerGlobalNav a.euiHeaderLogo')?.focus();
};
interface OperatorHandler {
field: string;
isExcluded: string;
value: string | number | Array<string | number>;
}
export const handleIsOperator = ({
browserFields,
field,
isExcluded,
isFieldTypeNested,
type,
value,
}: OperatorHandler & {
browserFields: BrowserFields;
isFieldTypeNested: boolean;
type?: DataProviderType;
}) => {
if (!isStringOrNumberArray(value)) {
return `${isExcluded}${
type !== DataProviderType.template
? buildIsQueryMatch({ browserFields, field, isFieldTypeNested, value })
: buildExistsQueryMatch({ browserFields, field, isFieldTypeNested })
}`;
} else {
return `${isExcluded}${field} : ${JSON.stringify(value)}`;
}
};
const handleIsOneOfOperator = ({ field, isExcluded, value }: OperatorHandler) => {
if (isStringOrNumberArray(value)) {
return `${isExcluded}${buildIsOneOfQueryMatch({ field, value })}`;
} else {
return `${isExcluded}${field} : ${JSON.stringify(value)}`;
}
};
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).trim()
: `${field} ${EXISTS_OPERATOR}`.trim();
};
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'));

View file

@ -114,7 +114,7 @@ export const dataProviderEdited = actionCreator<{
id: string;
operator: QueryOperator;
providerId: string;
value: string | number;
value: string | number | Array<string | number>;
}>('DATA_PROVIDER_EDITED');
export const updateDataProviderType = actionCreator<{

View file

@ -50,6 +50,7 @@ import {
import { activeTimeline } from '../../containers/active_timeline_context';
import type { ResolveTimelineConfig } from '../../components/open_timeline/types';
import type { SessionViewConfig } from '../../components/timeline/session_tab_content/use_session_view';
import { getDisplayValue } from '../../components/timeline/data_providers/helpers';
export const isNotNull = <T>(value: T | null): value is T => value !== null;
interface AddTimelineNoteParams {
@ -850,7 +851,7 @@ const updateProviderProperties = ({
operator: QueryOperator;
providerId: string;
timeline: TimelineModel;
value: string | number;
value: string | number | Array<string | number>;
}) =>
timeline.dataProviders.map((provider) =>
provider.id === providerId
@ -862,7 +863,7 @@ const updateProviderProperties = ({
field,
displayField: field,
value,
displayValue: value,
displayValue: getDisplayValue(value),
operator,
},
}
@ -884,7 +885,7 @@ const updateAndProviderProperties = ({
operator: QueryOperator;
providerId: string;
timeline: TimelineModel;
value: string | number;
value: string | number | Array<string | number>;
}) =>
timeline.dataProviders.map((provider) =>
provider.id === providerId
@ -900,7 +901,7 @@ const updateAndProviderProperties = ({
field,
displayField: field,
value,
displayValue: value,
displayValue: getDisplayValue(value),
operator,
},
}
@ -918,7 +919,7 @@ interface UpdateTimelineProviderEditPropertiesParams {
operator: QueryOperator;
providerId: string;
timelineById: TimelineById;
value: string | number;
value: string | number | Array<string | number>;
}
export const updateTimelineProviderProperties = ({

View file

@ -13,8 +13,11 @@ export const IS_OPERATOR = ':';
/** The `exists` operator in a KQL query */
export const EXISTS_OPERATOR = ':*';
/** The `is one of` operator in a KQL query */
export const IS_ONE_OF_OPERATOR = 'includes';
/** The operator applied to a field */
export type QueryOperator = ':' | ':*';
export type QueryOperator = typeof IS_OPERATOR | typeof EXISTS_OPERATOR | typeof IS_ONE_OF_OPERATOR;
export enum DataProviderType {
default = 'default',
@ -24,7 +27,7 @@ export enum DataProviderType {
export interface QueryMatch {
field: string;
displayField?: string;
value: string | number;
value: string | number | Array<string | number>;
displayValue?: string | number;
operator: QueryOperator;
}

View file

@ -95,7 +95,7 @@ const InspectButtonComponent: React.FC<InspectButtonProps> = ({
data-test-subj="inspect-icon-button"
iconSize="m"
iconType="inspect"
isDisabled={loading || isDisabled || false}
isDisabled={loading || isDisabled}
title={i18n.INSPECT}
onClick={handleClick}
/>

View file

@ -8,12 +8,21 @@
import { cloneDeep } from 'lodash/fp';
import { Filter, EsQueryConfig, FilterStateStore } from '@kbn/es-query';
import { DataProviderType } from '../../../common/types/timeline';
import {
DataProviderType,
EXISTS_OPERATOR,
IS_ONE_OF_OPERATOR,
IS_OPERATOR,
} from '../../../common/types/timeline';
import {
buildExistsQueryMatch,
buildGlobalQuery,
buildIsOneOfQueryMatch,
buildIsQueryMatch,
combineQueries,
getDefaultViewSelection,
isSelectableView,
isStringOrNumberArray,
isViewSelection,
resolverIsShowing,
} from './helpers';
@ -682,7 +691,7 @@ describe('Combined Queries', () => {
});
invalidViewSelections.forEach((value) => {
test(`it returns false when value is INvalid: ${value}`, () => {
test(`it returns false when value is invalid: ${value}`, () => {
expect(isViewSelection(value)).toBe(false);
});
});
@ -699,9 +708,9 @@ describe('Combined Queries', () => {
});
});
describe('given INvalid values', () => {
describe('given invalid values', () => {
invalidViewSelections.forEach((value) => {
test(`it ALWAYS returns 'gridView' for NON-selectable timelineId ${timelineId}, with INvalid value: ${value}`, () => {
test(`it ALWAYS returns 'gridView' for NON-selectable timelineId ${timelineId}, with invalid value: ${value}`, () => {
expect(getDefaultViewSelection({ timelineId, value })).toEqual('gridView');
});
});
@ -722,7 +731,7 @@ describe('Combined Queries', () => {
describe('given INvalid values', () => {
invalidViewSelections.forEach((value) => {
test(`it ALWAYS returns 'gridView' for selectable timelineId ${timelineId}, with INvalid value: ${value}`, () => {
test(`it ALWAYS returns 'gridView' for selectable timelineId ${timelineId}, with invalid value: ${value}`, () => {
expect(getDefaultViewSelection({ timelineId, value })).toEqual('gridView');
});
});
@ -730,4 +739,290 @@ describe('Combined Queries', () => {
});
});
});
describe('DataProvider yields same result as kqlQuery equivolent with each operator', () => {
describe('IS ONE OF operator', () => {
test('dataprovider matches kql equivolent', () => {
const dataProviders = cloneDeep(mockDataProviders.slice(0, 1));
dataProviders[0].queryMatch.operator = IS_ONE_OF_OPERATOR;
dataProviders[0].queryMatch.value = ['a', 'b', 'c'];
const { filterQuery: filterQueryWithDataProvider } = combineQueries({
config,
dataProviders,
indexPattern: mockIndexPattern,
browserFields: mockBrowserFields,
filters: [],
kqlQuery: { query: '', language: 'kuery' },
kqlMode: 'search',
})!;
const { filterQuery: filterQueryWithKQLQuery } = combineQueries({
config,
dataProviders: [],
indexPattern: mockIndexPattern,
browserFields: mockBrowserFields,
filters: [],
kqlQuery: { query: 'name: ("a" OR "b" OR "c")', language: 'kuery' },
kqlMode: 'search',
})!;
expect(filterQueryWithDataProvider).toEqual(filterQueryWithKQLQuery);
});
test('dataprovider with negated IS ONE OF operator matches kql equivolent', () => {
const dataProviders = cloneDeep(mockDataProviders.slice(0, 1));
dataProviders[0].queryMatch.operator = IS_ONE_OF_OPERATOR;
dataProviders[0].queryMatch.value = ['a', 'b', 'c'];
dataProviders[0].excluded = true;
const { filterQuery: filterQueryWithDataProvider } = combineQueries({
config,
dataProviders,
indexPattern: mockIndexPattern,
browserFields: mockBrowserFields,
filters: [],
kqlQuery: { query: '', language: 'kuery' },
kqlMode: 'search',
})!;
const { filterQuery: filterQueryWithKQLQuery } = combineQueries({
config,
dataProviders: [],
indexPattern: mockIndexPattern,
browserFields: mockBrowserFields,
filters: [],
kqlQuery: { query: 'NOT name: ("a" OR "b" OR "c")', language: 'kuery' },
kqlMode: 'search',
})!;
expect(filterQueryWithDataProvider).toEqual(filterQueryWithKQLQuery);
});
});
describe('IS operator', () => {
test('dataprovider matches kql equivolent', () => {
const dataProviders = cloneDeep(mockDataProviders.slice(0, 1));
dataProviders[0].queryMatch.operator = IS_OPERATOR;
dataProviders[0].queryMatch.value = 'a';
const { filterQuery: filterQueryWithDataProvider } = combineQueries({
config,
dataProviders,
indexPattern: mockIndexPattern,
browserFields: mockBrowserFields,
filters: [],
kqlQuery: { query: '', language: 'kuery' },
kqlMode: 'search',
})!;
const { filterQuery: filterQueryWithKQLQuery } = combineQueries({
config,
dataProviders: [],
indexPattern: mockIndexPattern,
browserFields: mockBrowserFields,
filters: [],
kqlQuery: { query: 'name: "a"', language: 'kuery' },
kqlMode: 'search',
})!;
expect(filterQueryWithDataProvider).toEqual(filterQueryWithKQLQuery);
});
test('dataprovider with negated IS operator matches kql equivolent', () => {
const dataProviders = cloneDeep(mockDataProviders.slice(0, 1));
dataProviders[0].queryMatch.operator = IS_OPERATOR;
dataProviders[0].queryMatch.value = 'a';
dataProviders[0].excluded = true;
const { filterQuery: filterQueryWithDataProvider } = combineQueries({
config,
dataProviders,
indexPattern: mockIndexPattern,
browserFields: mockBrowserFields,
filters: [],
kqlQuery: { query: '', language: 'kuery' },
kqlMode: 'search',
})!;
const { filterQuery: filterQueryWithKQLQuery } = combineQueries({
config,
dataProviders: [],
indexPattern: mockIndexPattern,
browserFields: mockBrowserFields,
filters: [],
kqlQuery: { query: 'NOT name: "a"', language: 'kuery' },
kqlMode: 'search',
})!;
expect(filterQueryWithDataProvider).toEqual(filterQueryWithKQLQuery);
});
});
describe('Exists operator', () => {
test('dataprovider matches kql equivolent', () => {
const dataProviders = cloneDeep(mockDataProviders.slice(0, 1));
dataProviders[0].queryMatch.operator = EXISTS_OPERATOR;
const { filterQuery: filterQueryWithDataProvider } = combineQueries({
config,
dataProviders,
indexPattern: mockIndexPattern,
browserFields: mockBrowserFields,
filters: [],
kqlQuery: { query: '', language: 'kuery' },
kqlMode: 'search',
})!;
const { filterQuery: filterQueryWithKQLQuery } = combineQueries({
config,
dataProviders: [],
indexPattern: mockIndexPattern,
browserFields: mockBrowserFields,
filters: [],
kqlQuery: { query: 'name : *', language: 'kuery' },
kqlMode: 'search',
})!;
expect(filterQueryWithDataProvider).toEqual(filterQueryWithKQLQuery);
});
test('dataprovider with negated EXISTS operator matches kql equivolent', () => {
const dataProviders = cloneDeep(mockDataProviders.slice(0, 1));
dataProviders[0].queryMatch.operator = EXISTS_OPERATOR;
dataProviders[0].excluded = true;
const { filterQuery: filterQueryWithDataProvider } = combineQueries({
config,
dataProviders,
indexPattern: mockIndexPattern,
browserFields: mockBrowserFields,
filters: [],
kqlQuery: { query: '', language: 'kuery' },
kqlMode: 'search',
})!;
const { filterQuery: filterQueryWithKQLQuery } = combineQueries({
config,
dataProviders: [],
indexPattern: mockIndexPattern,
browserFields: mockBrowserFields,
filters: [],
kqlQuery: { query: 'NOT name : *', language: 'kuery' },
kqlMode: 'search',
})!;
expect(filterQueryWithDataProvider).toEqual(filterQueryWithKQLQuery);
});
});
});
});
describe('isStringOrNumberArray', () => {
test('it returns false when value is not an array', () => {
expect(isStringOrNumberArray('just a string')).toBe(false);
});
test('it returns false when value is an array of mixed types', () => {
expect(isStringOrNumberArray(['mixed', 123, 'types'])).toBe(false);
});
test('it returns false when value is an array of bad values', () => {
const badValues = [undefined, null, {}] as unknown as string[];
expect(isStringOrNumberArray(badValues)).toBe(false);
});
test('it returns true when value is an empty array', () => {
expect(isStringOrNumberArray([])).toBe(true);
});
test('it returns true when value is an array of all strings', () => {
expect(isStringOrNumberArray(['all', 'string', 'values'])).toBe(true);
});
test('it returns true when value is an array of all numbers', () => {
expect(isStringOrNumberArray([123, 456, 789])).toBe(true);
});
});
describe('buildExistsQueryMatch', () => {
it('correcty computes EXISTS query with no nested field', () => {
expect(
buildExistsQueryMatch({ isFieldTypeNested: false, field: 'host', browserFields: {} })
).toBe(`host ${EXISTS_OPERATOR}`);
});
it('correcty computes EXISTS query with nested field', () => {
expect(
buildExistsQueryMatch({
isFieldTypeNested: true,
field: 'nestedField.firstAttributes',
browserFields: mockBrowserFields,
})
).toBe(`nestedField: { firstAttributes: * }`);
});
});
describe('buildIsQueryMatch', () => {
it('correcty computes IS query with no nested field', () => {
expect(
buildIsQueryMatch({
isFieldTypeNested: false,
field: 'nestedField.thirdAttributes',
value: 100000,
browserFields: {},
})
).toBe(`nestedField.thirdAttributes ${IS_OPERATOR} 100000`);
});
it('correcty computes IS query with nested date field', () => {
expect(
buildIsQueryMatch({
isFieldTypeNested: true,
browserFields: mockBrowserFields,
field: 'nestedField.thirdAttributes',
value: 1668521970232,
})
).toBe(`nestedField: { thirdAttributes${IS_OPERATOR} \"1668521970232\" }`);
});
it('correcty computes IS query with nested string field', () => {
expect(
buildIsQueryMatch({
isFieldTypeNested: true,
browserFields: mockBrowserFields,
field: 'nestedField.secondAttributes',
value: 'text',
})
).toBe(`nestedField: { secondAttributes${IS_OPERATOR} text }`);
});
});
describe('buildIsOneOfQueryMatch', () => {
it('correcty computes IS ONE OF query with numbers', () => {
expect(
buildIsOneOfQueryMatch({
field: 'kibana.alert.worflow_status',
value: [1, 2, 3],
})
).toBe('kibana.alert.worflow_status : (1 OR 2 OR 3)');
});
it('correcty computes IS ONE OF query with strings', () => {
expect(
buildIsOneOfQueryMatch({
field: 'kibana.alert.worflow_status',
value: ['a', 'b', 'c'],
})
).toBe(`kibana.alert.worflow_status : (\"a\" OR \"b\" OR \"c\")`);
});
it('correcty computes IS ONE OF query if value is an empty array', () => {
expect(
buildIsOneOfQueryMatch({
field: 'kibana.alert.worflow_status',
value: [],
})
).toBe("kibana.alert.worflow_status : ''");
});
it('correcty computes IS ONE OF query if given a single string value', () => {
expect(
buildIsOneOfQueryMatch({
field: 'kibana.alert.worflow_status',
value: ['a'],
})
).toBe(`kibana.alert.worflow_status : (\"a\")`);
});
it('correcty computes IS ONE OF query if given a single numeric value', () => {
expect(
buildIsOneOfQueryMatch({
field: 'kibana.alert.worflow_status',
value: [1],
})
).toBe(`kibana.alert.worflow_status : (1)`);
});
});

View file

@ -12,13 +12,14 @@ import memoizeOne from 'memoize-one';
import { elementOrChildrenHasFocus } from '../../../common/utils/accessibility';
import type { BrowserFields } from '../../../common/search_strategy/index_fields';
import {
DataProvider,
DataProvidersAnd,
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';
@ -33,7 +34,7 @@ interface CombineQueries {
kqlMode: string;
}
const isNumber = (value: string | number) => !isNaN(Number(value));
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()}`;
@ -96,27 +97,41 @@ const checkIfFieldTypeIsNested = (field: string, browserFields: BrowserFields) =
const buildQueryMatch = (
dataProvider: DataProvider | DataProvidersAnd,
browserFields: BrowserFields
) =>
`${dataProvider.excluded ? 'NOT ' : ''}${
dataProvider.queryMatch.operator !== EXISTS_OPERATOR &&
dataProvider.type !== DataProviderType.template
? checkIfFieldTypeIsNested(dataProvider.queryMatch.field, browserFields)
? convertNestedFieldToQuery(
dataProvider.queryMatch.field,
dataProvider.queryMatch.value,
browserFields
)
: checkIfFieldTypeIsDate(dataProvider.queryMatch.field, browserFields)
? convertDateFieldToQuery(dataProvider.queryMatch.field, dataProvider.queryMatch.value)
: `${dataProvider.queryMatch.field} : ${
isNumber(dataProvider.queryMatch.value)
? dataProvider.queryMatch.value
: escapeQueryValue(dataProvider.queryMatch.value)
}`
: checkIfFieldTypeIsNested(dataProvider.queryMatch.field, browserFields)
? convertNestedFieldToExistQuery(dataProvider.queryMatch.field, browserFields)
: `${dataProvider.queryMatch.field} ${EXISTS_OPERATOR}`
}`.trim();
) => {
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
@ -276,3 +291,57 @@ export const getDefaultViewSelection = ({
/** 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'));

View file

@ -810,6 +810,22 @@ export const mockBrowserFields: BrowserFields = {
},
},
},
'nestedField.thirdAttributes': {
aggregatable: false,
category: 'nestedField',
description: '',
example: '',
format: '',
indexes: ['auditbeat', 'filebeat', 'packetbeat'],
name: 'nestedField.thirdAttributes',
searchable: true,
type: 'date',
subType: {
nested: {
path: 'nestedField',
},
},
},
},
},
};