mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
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:
parent
3a52aadcfd
commit
7ac6561697
41 changed files with 1535 additions and 157 deletions
|
@ -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>;
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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),
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
]
|
||||
`;
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
|
|
|
@ -33,6 +33,10 @@ jest.mock('react-redux', () => {
|
|||
};
|
||||
});
|
||||
|
||||
jest.mock('uuid', () => ({
|
||||
v4: () => 'mock-id',
|
||||
}));
|
||||
|
||||
const id = 'timeline-1';
|
||||
const renderUseNavigatgeToTimeline = () => renderHook(() => useNavigateToTimeline());
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
|
@ -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;
|
||||
};
|
|
@ -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';
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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}`;
|
||||
};
|
||||
|
|
|
@ -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)', () => {
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
|
|
|
@ -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)!);
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
exports[`Provider rendering renders correctly against snapshot 1`] = `
|
||||
<ProviderItemBadge
|
||||
deleteProvider={[Function]}
|
||||
displayValue="Provider 1"
|
||||
field="name"
|
||||
isEnabled={true}
|
||||
isExcluded={false}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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} />
|
||||
)}
|
||||
</>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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 : ''");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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'));
|
||||
|
|
|
@ -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<{
|
||||
|
|
|
@ -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 = ({
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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)`);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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'));
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue