From b2bd4fe146a2257a9c189ebd9dae8734fe5c6e67 Mon Sep 17 00:00:00 2001 From: Yara Tercero Date: Wed, 1 Jul 2020 22:34:53 -0400 Subject: [PATCH] [SIEM][Exceptions] - Exception builder component (#67013) (#70539) ### Summary This PR creates the bulk functionality of the exception builder. The exception builder is the component that will be used to create exception list items. It does not deal with the actual API creation/deletion/update of exceptions, it does contain an `onChange` handler that can be used to access the exceptions. The builder is able to: - accept `ExceptionListItem` and render them correctly - allow user to add exception list item and exception list item entries - accept an `indexPattern` and use it to fetch relevant field and autocomplete field values - disable `Or` button if user is only allowed to edit/add to exception list item (not add additional exception list items) - displays `Add new exception` button if no exception items exist - An exception item can be created without entries, the `add new exception` button will show in the case that an exception list contains exception list item(s) with an empty `entries` array (as long as there is one exception list item with an item in `entries`, button does not show) - debounces field value autocomplete searches - bubble up exceptions to parent component, stripping out any empty entries --- .../common/schemas/common/schemas.test.ts | 33 +- .../lists/common/schemas/common/schemas.ts | 4 + ...est.tsx => persist_exception_item.test.ts} | 0 ...ion_item.tsx => persist_exception_item.ts} | 0 ...est.tsx => persist_exception_list.test.ts} | 0 ...ion_list.tsx => persist_exception_list.ts} | 0 .../{use_api.test.tsx => use_api.test.ts} | 0 .../hooks/{use_api.tsx => use_api.ts} | 0 ...st.test.tsx => use_exception_list.test.ts} | 0 ...ception_list.tsx => use_exception_list.ts} | 0 .../and_or_badge/rounded_badge_antenna.tsx | 9 +- .../components/autocomplete/field.test.tsx | 156 +++++++ .../common/components/autocomplete/field.tsx | 74 ++++ .../autocomplete/field_value_exists.test.tsx | 27 ++ .../autocomplete/field_value_exists.tsx | 27 ++ .../autocomplete/field_value_lists.test.tsx | 161 ++++++++ .../autocomplete/field_value_lists.tsx | 105 +++++ .../autocomplete/field_value_match.test.tsx | 238 +++++++++++ .../autocomplete/field_value_match.tsx | 106 +++++ .../field_value_match_any.test.tsx | 238 +++++++++++ .../autocomplete/field_value_match_any.tsx | 104 +++++ .../components/autocomplete/helpers.test.ts | 192 +++++++++ .../common/components/autocomplete/helpers.ts | 81 ++++ .../use_field_value_autocomplete.test.ts | 221 ++++++++++ .../hooks/use_field_value_autocomplete.ts | 102 +++++ .../components/autocomplete/operator.test.tsx | 197 +++++++++ .../components/autocomplete/operator.tsx | 77 ++++ .../{exceptions => autocomplete}/operators.ts | 18 +- .../common/components/autocomplete/readme.md | 122 ++++++ .../components/autocomplete/translations.ts | 11 + .../common/components/autocomplete/types.ts | 22 + .../__examples__/index.stories.tsx | 34 -- .../__snapshots__/index.test.tsx.snap | 26 -- .../autocomplete_field/index.test.tsx | 388 ------------------ .../components/autocomplete_field/index.tsx | 335 --------------- .../autocomplete_field/suggestion_item.tsx | 131 ------ .../builder_button_options.stories.tsx | 83 ++++ .../builder/builder_button_options.test.tsx | 167 ++++++++ .../builder/builder_button_options.tsx | 89 ++++ .../exceptions/builder/entry_item.tsx | 243 +++++++++++ .../exceptions/builder/exception_item.tsx | 137 +++++++ .../components/exceptions/builder/index.tsx | 248 +++++++++++ .../components/exceptions/helpers.test.tsx | 48 ++- .../common/components/exceptions/helpers.tsx | 207 ++++++++-- .../components/exceptions/translations.ts | 63 +++ .../common/components/exceptions/types.ts | 66 ++- .../exception_item/exception_entries.test.tsx | 4 +- .../public/lists_plugin_deps.ts | 9 + 48 files changed, 3623 insertions(+), 980 deletions(-) rename x-pack/plugins/lists/public/exceptions/hooks/{persist_exception_item.test.tsx => persist_exception_item.test.ts} (100%) rename x-pack/plugins/lists/public/exceptions/hooks/{persist_exception_item.tsx => persist_exception_item.ts} (100%) rename x-pack/plugins/lists/public/exceptions/hooks/{persist_exception_list.test.tsx => persist_exception_list.test.ts} (100%) rename x-pack/plugins/lists/public/exceptions/hooks/{persist_exception_list.tsx => persist_exception_list.ts} (100%) rename x-pack/plugins/lists/public/exceptions/hooks/{use_api.test.tsx => use_api.test.ts} (100%) rename x-pack/plugins/lists/public/exceptions/hooks/{use_api.tsx => use_api.ts} (100%) rename x-pack/plugins/lists/public/exceptions/hooks/{use_exception_list.test.tsx => use_exception_list.test.ts} (100%) rename x-pack/plugins/lists/public/exceptions/hooks/{use_exception_list.tsx => use_exception_list.ts} (100%) create mode 100644 x-pack/plugins/security_solution/public/common/components/autocomplete/field.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/autocomplete/field.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_exists.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_exists.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match_any.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match_any.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.test.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/autocomplete/hooks/use_field_value_autocomplete.test.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/autocomplete/hooks/use_field_value_autocomplete.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/autocomplete/operator.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/autocomplete/operator.tsx rename x-pack/plugins/security_solution/public/common/components/{exceptions => autocomplete}/operators.ts (87%) create mode 100644 x-pack/plugins/security_solution/public/common/components/autocomplete/readme.md create mode 100644 x-pack/plugins/security_solution/public/common/components/autocomplete/translations.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/autocomplete/types.ts delete mode 100644 x-pack/plugins/security_solution/public/common/components/autocomplete_field/__examples__/index.stories.tsx delete mode 100644 x-pack/plugins/security_solution/public/common/components/autocomplete_field/__snapshots__/index.test.tsx.snap delete mode 100644 x-pack/plugins/security_solution/public/common/components/autocomplete_field/index.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/common/components/autocomplete_field/index.tsx delete mode 100644 x-pack/plugins/security_solution/public/common/components/autocomplete_field/suggestion_item.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.stories.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_item.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/exceptions/builder/exception_item.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.tsx diff --git a/x-pack/plugins/lists/common/schemas/common/schemas.test.ts b/x-pack/plugins/lists/common/schemas/common/schemas.test.ts index eed5be39b7a03..d426a91e71b9e 100644 --- a/x-pack/plugins/lists/common/schemas/common/schemas.test.ts +++ b/x-pack/plugins/lists/common/schemas/common/schemas.test.ts @@ -9,7 +9,7 @@ import { left } from 'fp-ts/lib/Either'; import { foldLeftRight, getPaths } from '../../siem_common_deps'; -import { operator_type as operatorType } from './schemas'; +import { operator, operator_type as operatorType } from './schemas'; describe('Common schemas', () => { describe('operatorType', () => { @@ -60,4 +60,35 @@ describe('Common schemas', () => { expect(keys.length).toEqual(4); }); }); + + describe('operator', () => { + test('it should validate for "included"', () => { + const payload = 'included'; + const decoded = operator.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate for "excluded"', () => { + const payload = 'excluded'; + const decoded = operator.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should contain 2 keys', () => { + // Might seem like a weird test, but its meant to + // ensure that if operator is updated, you + // also update the operatorEnum, a workaround + // for io-ts not yet supporting enums + // https://github.com/gcanti/io-ts/issues/67 + const keys = Object.keys(operator.keys); + + expect(keys.length).toEqual(2); + }); + }); }); diff --git a/x-pack/plugins/lists/common/schemas/common/schemas.ts b/x-pack/plugins/lists/common/schemas/common/schemas.ts index fea8a219bc774..a91f487cfa274 100644 --- a/x-pack/plugins/lists/common/schemas/common/schemas.ts +++ b/x-pack/plugins/lists/common/schemas/common/schemas.ts @@ -130,6 +130,10 @@ export type NamespaceType = t.TypeOf; export const operator = t.keyof({ excluded: null, included: null }); export type Operator = t.TypeOf; +export enum OperatorEnum { + INCLUDED = 'included', + EXCLUDED = 'excluded', +} export const operator_type = t.keyof({ exists: null, diff --git a/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_item.test.tsx b/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_item.test.ts similarity index 100% rename from x-pack/plugins/lists/public/exceptions/hooks/persist_exception_item.test.tsx rename to x-pack/plugins/lists/public/exceptions/hooks/persist_exception_item.test.ts diff --git a/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_item.tsx b/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_item.ts similarity index 100% rename from x-pack/plugins/lists/public/exceptions/hooks/persist_exception_item.tsx rename to x-pack/plugins/lists/public/exceptions/hooks/persist_exception_item.ts diff --git a/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_list.test.tsx b/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_list.test.ts similarity index 100% rename from x-pack/plugins/lists/public/exceptions/hooks/persist_exception_list.test.tsx rename to x-pack/plugins/lists/public/exceptions/hooks/persist_exception_list.test.ts diff --git a/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_list.tsx b/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_list.ts similarity index 100% rename from x-pack/plugins/lists/public/exceptions/hooks/persist_exception_list.tsx rename to x-pack/plugins/lists/public/exceptions/hooks/persist_exception_list.ts diff --git a/x-pack/plugins/lists/public/exceptions/hooks/use_api.test.tsx b/x-pack/plugins/lists/public/exceptions/hooks/use_api.test.ts similarity index 100% rename from x-pack/plugins/lists/public/exceptions/hooks/use_api.test.tsx rename to x-pack/plugins/lists/public/exceptions/hooks/use_api.test.ts diff --git a/x-pack/plugins/lists/public/exceptions/hooks/use_api.tsx b/x-pack/plugins/lists/public/exceptions/hooks/use_api.ts similarity index 100% rename from x-pack/plugins/lists/public/exceptions/hooks/use_api.tsx rename to x-pack/plugins/lists/public/exceptions/hooks/use_api.ts diff --git a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.test.tsx b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.test.ts similarity index 100% rename from x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.test.tsx rename to x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.test.ts diff --git a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.tsx b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.ts similarity index 100% rename from x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.tsx rename to x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.ts diff --git a/x-pack/plugins/security_solution/public/common/components/and_or_badge/rounded_badge_antenna.tsx b/x-pack/plugins/security_solution/public/common/components/and_or_badge/rounded_badge_antenna.tsx index 1076d8b41b955..4a0e9ee416aaf 100644 --- a/x-pack/plugins/security_solution/public/common/components/and_or_badge/rounded_badge_antenna.tsx +++ b/x-pack/plugins/security_solution/public/common/components/and_or_badge/rounded_badge_antenna.tsx @@ -15,7 +15,6 @@ const antennaStyles = css` background: ${({ theme }) => theme.eui.euiColorLightShade}; position: relative; width: 2px; - margin: 0 12px 0 0; &:after { background: ${({ theme }) => theme.eui.euiColorLightShade}; content: ''; @@ -40,10 +39,6 @@ const BottomAntenna = styled(EuiFlexItem)` } `; -const EuiFlexItemWrapper = styled(EuiFlexItem)` - margin: 0 12px 0 0; -`; - export const RoundedBadgeAntenna: React.FC<{ type: AndOr }> = ({ type }) => ( = ({ type }) => ( alignItems="center" > - + - + ); diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/field.test.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/field.test.tsx new file mode 100644 index 0000000000000..30864f246071b --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/field.test.tsx @@ -0,0 +1,156 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { ThemeProvider } from 'styled-components'; +import { mount } from 'enzyme'; +import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; +import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; + +import { + fields, + getField, +} from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks.ts'; +import { FieldComponent } from './field'; + +describe('FieldComponent', () => { + test('it renders disabled if "isDisabled" is true', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect( + wrapper.find(`[data-test-subj="fieldAutocompleteComboBox"] input`).prop('disabled') + ).toBeTruthy(); + }); + + test('it renders loading if "isLoading" is true', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + wrapper.find(`[data-test-subj="fieldAutocompleteComboBox"] button`).at(0).simulate('click'); + expect( + wrapper + .find(`EuiComboBoxOptionsList[data-test-subj="fieldAutocompleteComboBox-optionsList"]`) + .prop('isLoading') + ).toBeTruthy(); + }); + + test('it allows user to clear values if "isClearable" is true', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect( + wrapper + .find(`[data-test-subj="comboBoxInput"]`) + .hasClass('euiComboBox__inputWrap-isClearable') + ).toBeTruthy(); + }); + + test('it correctly displays selected field', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect( + wrapper.find(`[data-test-subj="fieldAutocompleteComboBox"] EuiComboBoxPill`).at(0).text() + ).toEqual('machine.os.raw'); + }); + + test('it invokes "onChange" when option selected', () => { + const mockOnChange = jest.fn(); + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + ((wrapper.find(EuiComboBox).props() as unknown) as { + onChange: (a: EuiComboBoxOptionOption[]) => void; + }).onChange([{ label: 'machine.os' }]); + + expect(mockOnChange).toHaveBeenCalledWith([ + { + aggregatable: true, + count: 0, + esTypes: ['text'], + name: 'machine.os', + readFromDocValues: false, + scripted: false, + searchable: true, + type: 'string', + }, + ]); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/field.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/field.tsx new file mode 100644 index 0000000000000..8a6f049c96037 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/field.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useMemo, useCallback } from 'react'; +import { EuiComboBoxOptionOption, EuiComboBox } from '@elastic/eui'; + +import { IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/common'; +import { getGenericComboBoxProps } from './helpers'; +import { GetGenericComboBoxPropsReturn } from './types'; + +interface OperatorProps { + placeholder: string; + selectedField: IFieldType | undefined; + indexPattern: IIndexPattern | undefined; + isLoading: boolean; + isDisabled: boolean; + isClearable: boolean; + fieldInputWidth?: number; + onChange: (a: IFieldType[]) => void; +} + +export const FieldComponent: React.FC = ({ + placeholder, + selectedField, + indexPattern, + isLoading = false, + isDisabled = false, + isClearable = false, + fieldInputWidth = 190, + onChange, +}): JSX.Element => { + const getLabel = useCallback((field): string => field.name, []); + const optionsMemo = useMemo((): IFieldType[] => (indexPattern ? indexPattern.fields : []), [ + indexPattern, + ]); + const selectedOptionsMemo = useMemo((): IFieldType[] => (selectedField ? [selectedField] : []), [ + selectedField, + ]); + const { comboOptions, labels, selectedComboOptions } = useMemo( + (): GetGenericComboBoxPropsReturn => + getGenericComboBoxProps({ + options: optionsMemo, + selectedOptions: selectedOptionsMemo, + getLabel, + }), + [optionsMemo, selectedOptionsMemo, getLabel] + ); + + const handleValuesChange = (newOptions: EuiComboBoxOptionOption[]): void => { + const newValues: IFieldType[] = newOptions.map( + ({ label }) => optionsMemo[labels.indexOf(label)] + ); + onChange(newValues); + }; + + return ( + + ); +}; + +FieldComponent.displayName = 'Field'; diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_exists.test.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_exists.test.tsx new file mode 100644 index 0000000000000..c4904df3a135c --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_exists.test.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { ThemeProvider } from 'styled-components'; +import { mount } from 'enzyme'; +import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; + +import { AutocompleteFieldExistsComponent } from './field_value_exists'; + +describe('AutocompleteFieldExistsComponent', () => { + test('it renders field disabled', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect( + wrapper + .find(`[data-test-subj="valuesAutocompleteComboBox existsComboxBox"] input`) + .prop('disabled') + ).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_exists.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_exists.tsx new file mode 100644 index 0000000000000..f2161e376eab5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_exists.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { EuiComboBox } from '@elastic/eui'; + +interface AutocompleteFieldExistsProps { + placeholder: string; +} + +export const AutocompleteFieldExistsComponent: React.FC = ({ + placeholder, +}): JSX.Element => ( + +); + +AutocompleteFieldExistsComponent.displayName = 'AutocompleteFieldExists'; diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.test.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.test.tsx new file mode 100644 index 0000000000000..7734344d193b8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.test.tsx @@ -0,0 +1,161 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { ThemeProvider } from 'styled-components'; +import { mount } from 'enzyme'; +import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; +import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; + +import { getField } from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks.ts'; +import { AutocompleteFieldListsComponent } from './field_value_lists'; +import { getFoundListSchemaMock } from '../../../../../lists/common/schemas/response/found_list_schema.mock'; + +const mockStart = jest.fn(); +const mockResult = getFoundListSchemaMock(); +jest.mock('../../../common/lib/kibana'); +jest.mock('../../../lists_plugin_deps', () => { + const originalModule = jest.requireActual('../../../lists_plugin_deps'); + + return { + ...originalModule, + useFindLists: () => ({ + loading: false, + start: mockStart.mockReturnValue(mockResult), + result: mockResult, + error: undefined, + }), + }; +}); + +describe('AutocompleteFieldListsComponent', () => { + test('it renders disabled if "isDisabled" is true', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect( + wrapper + .find(`[data-test-subj="valuesAutocompleteComboBox listsComboxBox"] input`) + .prop('disabled') + ).toBeTruthy(); + }); + + test('it renders loading if "isLoading" is true', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + wrapper + .find(`[data-test-subj="valuesAutocompleteComboBox listsComboxBox"] button`) + .at(0) + .simulate('click'); + expect( + wrapper + .find( + `EuiComboBoxOptionsList[data-test-subj="valuesAutocompleteComboBox listsComboxBox-optionsList"]` + ) + .prop('isLoading') + ).toBeTruthy(); + }); + + test('it allows user to clear values if "isClearable" is true', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect( + wrapper + .find(`[data-test-subj="comboBoxInput"]`) + .hasClass('euiComboBox__inputWrap-isClearable') + ).toBeTruthy(); + }); + + test('it correctly displays selected list', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect( + wrapper + .find(`[data-test-subj="valuesAutocompleteComboBox listsComboxBox"] EuiComboBoxPill`) + .at(0) + .text() + ).toEqual('some name'); + }); + + test('it invokes "onChange" when option selected', async () => { + const mockOnChange = jest.fn(); + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + ((wrapper.find(EuiComboBox).props() as unknown) as { + onChange: (a: EuiComboBoxOptionOption[]) => void; + }).onChange([{ label: 'some name' }]); + + expect(mockOnChange).toHaveBeenCalledWith({ + created_at: '2020-04-20T15:25:31.830Z', + created_by: 'some user', + description: 'some description', + id: 'some-list-id', + meta: {}, + name: 'some name', + tie_breaker_id: '6a76b69d-80df-4ab2-8c3e-85f466b06a0e', + type: 'ip', + updated_at: '2020-04-20T15:25:31.830Z', + updated_by: 'some user', + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.tsx new file mode 100644 index 0000000000000..d8ce27e97874d --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.tsx @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { EuiComboBoxOptionOption, EuiComboBox } from '@elastic/eui'; + +import { IFieldType } from '../../../../../../../src/plugins/data/common'; +import { useFindLists, ListSchema } from '../../../lists_plugin_deps'; +import { useKibana } from '../../../common/lib/kibana'; +import { getGenericComboBoxProps } from './helpers'; + +interface AutocompleteFieldListsProps { + placeholder: string; + selectedField: IFieldType | undefined; + selectedValue: string | undefined; + isLoading: boolean; + isDisabled: boolean; + isClearable: boolean; + onChange: (arg: ListSchema) => void; +} + +export const AutocompleteFieldListsComponent: React.FC = ({ + placeholder, + selectedField, + selectedValue, + isLoading = false, + isDisabled = false, + isClearable = false, + onChange, +}): JSX.Element => { + const { http } = useKibana().services; + const [lists, setLists] = useState([]); + const { loading, result, start } = useFindLists(); + const getLabel = useCallback(({ name }) => name, []); + + const optionsMemo = useMemo(() => { + if (selectedField != null) { + return lists.filter(({ type }) => type === selectedField.type); + } else { + return []; + } + }, [lists, selectedField]); + const selectedOptionsMemo = useMemo(() => { + if (selectedValue != null) { + const list = lists.filter(({ id }) => id === selectedValue); + return list ?? []; + } else { + return []; + } + }, [selectedValue, lists]); + const { comboOptions, labels, selectedComboOptions } = useMemo( + () => + getGenericComboBoxProps({ + options: optionsMemo, + selectedOptions: selectedOptionsMemo, + getLabel, + }), + [optionsMemo, selectedOptionsMemo, getLabel] + ); + + const handleValuesChange = useCallback( + (newOptions: EuiComboBoxOptionOption[]) => { + const [newValue] = newOptions.map(({ label }) => optionsMemo[labels.indexOf(label)]); + onChange(newValue ?? ''); + }, + [labels, optionsMemo, onChange] + ); + + useEffect(() => { + if (result != null) { + setLists(result.data); + } + }, [result]); + + useEffect(() => { + if (selectedField != null) { + start({ + http, + pageIndex: 1, + pageSize: 500, + }); + } + }, [selectedField, start, http]); + + return ( + + ); +}; + +AutocompleteFieldListsComponent.displayName = 'AutocompleteFieldList'; diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.test.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.test.tsx new file mode 100644 index 0000000000000..72467a62f57c1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.test.tsx @@ -0,0 +1,238 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { ThemeProvider } from 'styled-components'; +import { mount } from 'enzyme'; +import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; +import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; + +import { + fields, + getField, +} from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks.ts'; +import { AutocompleteFieldMatchComponent } from './field_value_match'; +import { useFieldValueAutocomplete } from './hooks/use_field_value_autocomplete'; +jest.mock('./hooks/use_field_value_autocomplete'); + +describe('AutocompleteFieldMatchComponent', () => { + const getValueSuggestionsMock = jest + .fn() + .mockResolvedValue([false, ['value 3', 'value 4'], jest.fn()]); + + beforeAll(() => { + (useFieldValueAutocomplete as jest.Mock).mockReturnValue([ + false, + ['value 1', 'value 2'], + getValueSuggestionsMock, + ]); + }); + test('it renders disabled if "isDisabled" is true', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect( + wrapper + .find(`[data-test-subj="valuesAutocompleteComboBox matchComboxBox"] input`) + .prop('disabled') + ).toBeTruthy(); + }); + + test('it renders loading if "isLoading" is true', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + wrapper + .find(`[data-test-subj="valuesAutocompleteComboBox matchComboxBox"] button`) + .at(0) + .simulate('click'); + expect( + wrapper + .find( + `EuiComboBoxOptionsList[data-test-subj="valuesAutocompleteComboBox matchComboxBox-optionsList"]` + ) + .prop('isLoading') + ).toBeTruthy(); + }); + + test('it allows user to clear values if "isClearable" is true', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect( + wrapper + .find(`[data-test-subj="comboBoxInput"]`) + .hasClass('euiComboBox__inputWrap-isClearable') + ).toBeTruthy(); + }); + + test('it correctly displays selected value', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect( + wrapper + .find(`[data-test-subj="valuesAutocompleteComboBox matchComboxBox"] EuiComboBoxPill`) + .at(0) + .text() + ).toEqual('126.45.211.34'); + }); + + test('it invokes "onChange" when new value created', async () => { + const mockOnChange = jest.fn(); + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + ((wrapper.find(EuiComboBox).props() as unknown) as { + onCreateOption: (a: string) => void; + }).onCreateOption('126.45.211.34'); + + expect(mockOnChange).toHaveBeenCalledWith('126.45.211.34'); + }); + + test('it invokes "onChange" when new value selected', async () => { + const mockOnChange = jest.fn(); + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + ((wrapper.find(EuiComboBox).props() as unknown) as { + onChange: (a: EuiComboBoxOptionOption[]) => void; + }).onChange([{ label: 'value 1' }]); + + expect(mockOnChange).toHaveBeenCalledWith('value 1'); + }); + + test('it invokes updateSuggestions when new value searched', async () => { + const mockOnChange = jest.fn(); + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + ((wrapper.find(EuiComboBox).props() as unknown) as { + onSearchChange: (a: string) => void; + }).onSearchChange('value 1'); + + expect(getValueSuggestionsMock).toHaveBeenCalledWith({ + fieldSelected: getField('machine.os.raw'), + patterns: { + id: '1234', + title: 'logstash-*', + fields, + }, + value: 'value 1', + signal: new AbortController().signal, + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.tsx new file mode 100644 index 0000000000000..4d96d6638132b --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.tsx @@ -0,0 +1,106 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useCallback, useMemo } from 'react'; +import { EuiComboBoxOptionOption, EuiComboBox } from '@elastic/eui'; +import { uniq } from 'lodash'; + +import { IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/common'; +import { useFieldValueAutocomplete } from './hooks/use_field_value_autocomplete'; +import { validateParams, getGenericComboBoxProps } from './helpers'; +import { OperatorTypeEnum } from '../../../lists_plugin_deps'; +import { GetGenericComboBoxPropsReturn } from './types'; +import * as i18n from './translations'; + +interface AutocompleteFieldMatchProps { + placeholder: string; + selectedField: IFieldType | undefined; + selectedValue: string | undefined; + indexPattern: IIndexPattern | undefined; + isLoading: boolean; + isDisabled: boolean; + isClearable: boolean; + onChange: (arg: string) => void; +} + +export const AutocompleteFieldMatchComponent: React.FC = ({ + placeholder, + selectedField, + selectedValue, + indexPattern, + isLoading, + isDisabled = false, + isClearable = false, + onChange, +}): JSX.Element => { + const [isLoadingSuggestions, suggestions, updateSuggestions] = useFieldValueAutocomplete({ + selectedField, + operatorType: OperatorTypeEnum.MATCH, + fieldValue: selectedValue, + indexPattern, + }); + const getLabel = useCallback((option: string): string => option, []); + const optionsMemo = useMemo((): string[] => { + const valueAsStr = String(selectedValue); + return selectedValue ? uniq([valueAsStr, ...suggestions]) : suggestions; + }, [suggestions, selectedValue]); + const selectedOptionsMemo = useMemo((): string[] => { + const valueAsStr = String(selectedValue); + return selectedValue ? [valueAsStr] : []; + }, [selectedValue]); + + const { comboOptions, labels, selectedComboOptions } = useMemo( + (): GetGenericComboBoxPropsReturn => + getGenericComboBoxProps({ + options: optionsMemo, + selectedOptions: selectedOptionsMemo, + getLabel, + }), + [optionsMemo, selectedOptionsMemo, getLabel] + ); + + const handleValuesChange = (newOptions: EuiComboBoxOptionOption[]): void => { + const [newValue] = newOptions.map(({ label }) => optionsMemo[labels.indexOf(label)]); + onChange(newValue ?? ''); + }; + + const onSearchChange = (searchVal: string): void => { + const signal = new AbortController().signal; + + updateSuggestions({ + fieldSelected: selectedField, + value: `${searchVal}`, + patterns: indexPattern, + signal, + }); + }; + + const isValid = useMemo( + (): boolean => validateParams(selectedValue, selectedField ? selectedField.type : ''), + [selectedField, selectedValue] + ); + + return ( + + ); +}; + +AutocompleteFieldMatchComponent.displayName = 'AutocompleteFieldMatch'; diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match_any.test.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match_any.test.tsx new file mode 100644 index 0000000000000..f3f0f2e2a44b1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match_any.test.tsx @@ -0,0 +1,238 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { ThemeProvider } from 'styled-components'; +import { mount } from 'enzyme'; +import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; +import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; + +import { + fields, + getField, +} from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks.ts'; +import { AutocompleteFieldMatchAnyComponent } from './field_value_match_any'; +import { useFieldValueAutocomplete } from './hooks/use_field_value_autocomplete'; +jest.mock('./hooks/use_field_value_autocomplete'); + +describe('AutocompleteFieldMatchAnyComponent', () => { + const getValueSuggestionsMock = jest + .fn() + .mockResolvedValue([false, ['value 3', 'value 4'], jest.fn()]); + + beforeAll(() => { + (useFieldValueAutocomplete as jest.Mock).mockReturnValue([ + false, + ['value 1', 'value 2'], + getValueSuggestionsMock, + ]); + }); + test('it renders disabled if "isDisabled" is true', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect( + wrapper + .find(`[data-test-subj="valuesAutocompleteComboBox matchAnyComboxBox"] input`) + .prop('disabled') + ).toBeTruthy(); + }); + + test('it renders loading if "isLoading" is true', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + wrapper + .find(`[data-test-subj="valuesAutocompleteComboBox matchAnyComboxBox"] button`) + .at(0) + .simulate('click'); + expect( + wrapper + .find( + `EuiComboBoxOptionsList[data-test-subj="valuesAutocompleteComboBox matchAnyComboxBox-optionsList"]` + ) + .prop('isLoading') + ).toBeTruthy(); + }); + + test('it allows user to clear values if "isClearable" is true', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect( + wrapper + .find(`[data-test-subj="comboBoxInput"]`) + .hasClass('euiComboBox__inputWrap-isClearable') + ).toBeTruthy(); + }); + + test('it correctly displays selected value', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect( + wrapper + .find(`[data-test-subj="valuesAutocompleteComboBox matchAnyComboxBox"] EuiComboBoxPill`) + .at(0) + .text() + ).toEqual('126.45.211.34'); + }); + + test('it invokes "onChange" when new value created', async () => { + const mockOnChange = jest.fn(); + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + ((wrapper.find(EuiComboBox).props() as unknown) as { + onCreateOption: (a: string) => void; + }).onCreateOption('126.45.211.34'); + + expect(mockOnChange).toHaveBeenCalledWith(['126.45.211.34']); + }); + + test('it invokes "onChange" when new value selected', async () => { + const mockOnChange = jest.fn(); + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + ((wrapper.find(EuiComboBox).props() as unknown) as { + onChange: (a: EuiComboBoxOptionOption[]) => void; + }).onChange([{ label: 'value 1' }]); + + expect(mockOnChange).toHaveBeenCalledWith(['value 1']); + }); + + test('it invokes updateSuggestions when new value searched', async () => { + const mockOnChange = jest.fn(); + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + ((wrapper.find(EuiComboBox).props() as unknown) as { + onSearchChange: (a: string) => void; + }).onSearchChange('value 1'); + + expect(getValueSuggestionsMock).toHaveBeenCalledWith({ + fieldSelected: getField('machine.os.raw'), + patterns: { + id: '1234', + title: 'logstash-*', + fields, + }, + value: 'value 1', + signal: new AbortController().signal, + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match_any.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match_any.tsx new file mode 100644 index 0000000000000..080c89ff013cd --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match_any.tsx @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useCallback, useMemo } from 'react'; +import { EuiComboBoxOptionOption, EuiComboBox } from '@elastic/eui'; +import { uniq } from 'lodash'; + +import { IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/common'; +import { useFieldValueAutocomplete } from './hooks/use_field_value_autocomplete'; +import { getGenericComboBoxProps, validateParams } from './helpers'; +import { OperatorTypeEnum } from '../../../lists_plugin_deps'; +import { GetGenericComboBoxPropsReturn } from './types'; +import * as i18n from './translations'; + +interface AutocompleteFieldMatchAnyProps { + placeholder: string; + selectedField: IFieldType | undefined; + selectedValue: string[]; + indexPattern: IIndexPattern | undefined; + isLoading: boolean; + isDisabled: boolean; + isClearable: boolean; + onChange: (arg: string[]) => void; +} + +export const AutocompleteFieldMatchAnyComponent: React.FC = ({ + placeholder, + selectedField, + selectedValue, + indexPattern, + isLoading, + isDisabled = false, + isClearable = false, + onChange, +}): JSX.Element => { + const [isLoadingSuggestions, suggestions, updateSuggestions] = useFieldValueAutocomplete({ + selectedField, + operatorType: OperatorTypeEnum.MATCH_ANY, + fieldValue: selectedValue, + indexPattern, + }); + const getLabel = useCallback((option: string): string => option, []); + const optionsMemo = useMemo( + (): string[] => (selectedValue ? uniq([...selectedValue, ...suggestions]) : suggestions), + [suggestions, selectedValue] + ); + const { comboOptions, labels, selectedComboOptions } = useMemo( + (): GetGenericComboBoxPropsReturn => + getGenericComboBoxProps({ + options: optionsMemo, + selectedOptions: selectedValue, + getLabel, + }), + [optionsMemo, selectedValue, getLabel] + ); + + const handleValuesChange = (newOptions: EuiComboBoxOptionOption[]): void => { + const newValues: string[] = newOptions.map(({ label }) => optionsMemo[labels.indexOf(label)]); + onChange(newValues); + }; + + const onSearchChange = (searchVal: string) => { + const signal = new AbortController().signal; + + updateSuggestions({ + fieldSelected: selectedField, + value: `${searchVal}`, + patterns: indexPattern, + signal, + }); + }; + + const onCreateOption = (option: string) => onChange([...(selectedValue || []), option]); + + const isValid = useMemo((): boolean => { + const areAnyInvalid = selectedComboOptions.filter( + ({ label }) => !validateParams(label, selectedField ? selectedField.type : '') + ); + return areAnyInvalid.length === 0; + }, [selectedComboOptions, selectedField]); + + return ( + + ); +}; + +AutocompleteFieldMatchAnyComponent.displayName = 'AutocompleteFieldMatchAny'; diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.test.ts b/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.test.ts new file mode 100644 index 0000000000000..c2e8e56084452 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.test.ts @@ -0,0 +1,192 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getField } from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks.ts'; + +import { + EXCEPTION_OPERATORS, + isOperator, + isNotOperator, + existsOperator, + doesNotExistOperator, +} from './operators'; +import { getOperators, validateParams, getGenericComboBoxProps } from './helpers'; + +describe('helpers', () => { + describe('#getOperators', () => { + test('it returns "isOperator" if passed in field is "undefined"', () => { + const operator = getOperators(undefined); + + expect(operator).toEqual([isOperator]); + }); + + test('it returns expected operators when field type is "boolean"', () => { + const operator = getOperators(getField('ssl')); + + expect(operator).toEqual([isOperator, isNotOperator, existsOperator, doesNotExistOperator]); + }); + + test('it returns "isOperator" when field type is "nested"', () => { + const operator = getOperators({ + name: 'nestedField', + type: 'nested', + esTypes: ['text'], + count: 0, + scripted: false, + searchable: true, + aggregatable: false, + readFromDocValues: false, + subType: { nested: { path: 'nestedField' } }, + }); + + expect(operator).toEqual([isOperator]); + }); + + test('it returns all operator types when field type is not null, boolean, or nested', () => { + const operator = getOperators(getField('machine.os.raw')); + + expect(operator).toEqual(EXCEPTION_OPERATORS); + }); + }); + + describe('#validateParams', () => { + test('returns true if value is undefined', () => { + const isValid = validateParams(undefined, 'date'); + + expect(isValid).toBeTruthy(); + }); + + test('returns true if value is empty string', () => { + const isValid = validateParams('', 'date'); + + expect(isValid).toBeTruthy(); + }); + + test('returns true if type is "date" and value is valid', () => { + const isValid = validateParams('1994-11-05T08:15:30-05:00', 'date'); + + expect(isValid).toBeTruthy(); + }); + + test('returns false if type is "date" and value is not valid', () => { + const isValid = validateParams('1593478826', 'date'); + + expect(isValid).toBeFalsy(); + }); + + test('returns true if type is "ip" and value is valid', () => { + const isValid = validateParams('126.45.211.34', 'ip'); + + expect(isValid).toBeTruthy(); + }); + + test('returns false if type is "ip" and value is not valid', () => { + const isValid = validateParams('hellooo', 'ip'); + + expect(isValid).toBeFalsy(); + }); + + test('returns true if type is "number" and value is valid', () => { + const isValid = validateParams('123', 'number'); + + expect(isValid).toBeTruthy(); + }); + + test('returns false if type is "number" and value is not valid', () => { + const isValid = validateParams('not a number', 'number'); + + expect(isValid).toBeFalsy(); + }); + }); + + describe('#getGenericComboBoxProps', () => { + test('it returns empty arrays if "options" is empty array', () => { + const result = getGenericComboBoxProps({ + options: [], + selectedOptions: ['option1'], + getLabel: (t: string) => t, + }); + + expect(result).toEqual({ comboOptions: [], labels: [], selectedComboOptions: [] }); + }); + + test('it returns formatted props if "options" array is not empty', () => { + const result = getGenericComboBoxProps({ + options: ['option1', 'option2', 'option3'], + selectedOptions: [], + getLabel: (t: string) => t, + }); + + expect(result).toEqual({ + comboOptions: [ + { + label: 'option1', + }, + { + label: 'option2', + }, + { + label: 'option3', + }, + ], + labels: ['option1', 'option2', 'option3'], + selectedComboOptions: [], + }); + }); + + test('it does not return "selectedOptions" items that do not appear in "options"', () => { + const result = getGenericComboBoxProps({ + options: ['option1', 'option2', 'option3'], + selectedOptions: ['option4'], + getLabel: (t: string) => t, + }); + + expect(result).toEqual({ + comboOptions: [ + { + label: 'option1', + }, + { + label: 'option2', + }, + { + label: 'option3', + }, + ], + labels: ['option1', 'option2', 'option3'], + selectedComboOptions: [], + }); + }); + + test('it return "selectedOptions" items that do appear in "options"', () => { + const result = getGenericComboBoxProps({ + options: ['option1', 'option2', 'option3'], + selectedOptions: ['option2'], + getLabel: (t: string) => t, + }); + + expect(result).toEqual({ + comboOptions: [ + { + label: 'option1', + }, + { + label: 'option2', + }, + { + label: 'option3', + }, + ], + labels: ['option1', 'option2', 'option3'], + selectedComboOptions: [ + { + label: 'option2', + }, + ], + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.ts b/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.ts new file mode 100644 index 0000000000000..888c881f45ce4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.ts @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import dateMath from '@elastic/datemath'; +import { EuiComboBoxOptionOption } from '@elastic/eui'; + +import { IFieldType } from '../../../../../../../src/plugins/data/common'; +import { Ipv4Address } from '../../../../../../../src/plugins/kibana_utils/public'; +import { + EXCEPTION_OPERATORS, + isOperator, + isNotOperator, + existsOperator, + doesNotExistOperator, +} from './operators'; +import { GetGenericComboBoxPropsReturn, OperatorOption } from './types'; + +export const getOperators = (field: IFieldType | undefined): OperatorOption[] => { + if (field == null) { + return [isOperator]; + } else if (field.type === 'boolean') { + return [isOperator, isNotOperator, existsOperator, doesNotExistOperator]; + } else if (field.type === 'nested') { + return [isOperator]; + } else { + return EXCEPTION_OPERATORS; + } +}; + +export function validateParams(params: string | undefined, type: string) { + // Box would show error state if empty otherwise + if (params == null || params === '') { + return true; + } + + switch (type) { + case 'date': + const moment = dateMath.parse(params); + return Boolean(moment && moment.isValid()); + case 'ip': + try { + return Boolean(new Ipv4Address(params)); + } catch (e) { + return false; + } + case 'number': + const val = parseFloat(params); + return typeof val === 'number' && !isNaN(val); + default: + return true; + } +} + +export function getGenericComboBoxProps({ + options, + selectedOptions, + getLabel, +}: { + options: T[]; + selectedOptions: T[]; + getLabel: (value: T) => string; +}): GetGenericComboBoxPropsReturn { + const newLabels = options.map(getLabel); + const newComboOptions: EuiComboBoxOptionOption[] = newLabels.map((label) => ({ label })); + const newSelectedComboOptions = selectedOptions + .filter((option) => { + return options.indexOf(option) !== -1; + }) + .map((option) => { + return newComboOptions[options.indexOf(option)]; + }); + + return { + comboOptions: newComboOptions, + labels: newLabels, + selectedComboOptions: newSelectedComboOptions, + }; +} diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/hooks/use_field_value_autocomplete.test.ts b/x-pack/plugins/security_solution/public/common/components/autocomplete/hooks/use_field_value_autocomplete.test.ts new file mode 100644 index 0000000000000..def2a303f6038 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/hooks/use_field_value_autocomplete.test.ts @@ -0,0 +1,221 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; + +import { + UseFieldValueAutocompleteProps, + UseFieldValueAutocompleteReturn, + useFieldValueAutocomplete, +} from './use_field_value_autocomplete'; +import { useKibana } from '../../../../common/lib/kibana'; +import { stubIndexPatternWithFields } from '../../../../../../../../src/plugins/data/common/index_patterns/index_pattern.stub'; +import { getField } from '../../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks.ts'; +import { OperatorTypeEnum } from '../../../../lists_plugin_deps'; + +jest.mock('../../../../common/lib/kibana'); + +describe('useFieldValueAutocomplete', () => { + const onErrorMock = jest.fn(); + const getValueSuggestionsMock = jest.fn().mockResolvedValue(['value 1', 'value 2']); + + beforeAll(() => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + data: { + autocomplete: { + getValueSuggestions: getValueSuggestionsMock, + }, + }, + }, + }); + }); + + afterEach(() => { + onErrorMock.mockClear(); + getValueSuggestionsMock.mockClear(); + }); + + test('initializes hook', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook< + UseFieldValueAutocompleteProps, + UseFieldValueAutocompleteReturn + >(() => + useFieldValueAutocomplete({ + selectedField: undefined, + operatorType: OperatorTypeEnum.MATCH, + fieldValue: '', + indexPattern: undefined, + }) + ); + await waitForNextUpdate(); + + expect(result.current).toEqual([false, [], result.current[2]]); + }); + }); + + test('does not call autocomplete service if "operatorType" is "exists"', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook< + UseFieldValueAutocompleteProps, + UseFieldValueAutocompleteReturn + >(() => + useFieldValueAutocomplete({ + selectedField: getField('machine.os'), + operatorType: OperatorTypeEnum.EXISTS, + fieldValue: '', + indexPattern: stubIndexPatternWithFields, + }) + ); + await waitForNextUpdate(); + + const expectedResult: UseFieldValueAutocompleteReturn = [false, [], result.current[2]]; + + expect(getValueSuggestionsMock).not.toHaveBeenCalled(); + expect(result.current).toEqual(expectedResult); + }); + }); + + test('does not call autocomplete service if "selectedField" is undefined', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook< + UseFieldValueAutocompleteProps, + UseFieldValueAutocompleteReturn + >(() => + useFieldValueAutocomplete({ + selectedField: undefined, + operatorType: OperatorTypeEnum.EXISTS, + fieldValue: '', + indexPattern: stubIndexPatternWithFields, + }) + ); + await waitForNextUpdate(); + + const expectedResult: UseFieldValueAutocompleteReturn = [false, [], result.current[2]]; + + expect(getValueSuggestionsMock).not.toHaveBeenCalled(); + expect(result.current).toEqual(expectedResult); + }); + }); + + test('does not call autocomplete service if "indexPattern" is undefined', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook< + UseFieldValueAutocompleteProps, + UseFieldValueAutocompleteReturn + >(() => + useFieldValueAutocomplete({ + selectedField: getField('machine.os'), + operatorType: OperatorTypeEnum.EXISTS, + fieldValue: '', + indexPattern: undefined, + }) + ); + await waitForNextUpdate(); + + const expectedResult: UseFieldValueAutocompleteReturn = [false, [], result.current[2]]; + + expect(getValueSuggestionsMock).not.toHaveBeenCalled(); + expect(result.current).toEqual(expectedResult); + }); + }); + + test('returns suggestions of "true" and "false" if field type is boolean', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook< + UseFieldValueAutocompleteProps, + UseFieldValueAutocompleteReturn + >(() => + useFieldValueAutocomplete({ + selectedField: getField('ssl'), + operatorType: OperatorTypeEnum.MATCH, + fieldValue: '', + indexPattern: stubIndexPatternWithFields, + }) + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + + const expectedResult: UseFieldValueAutocompleteReturn = [ + false, + ['true', 'false'], + result.current[2], + ]; + + expect(getValueSuggestionsMock).not.toHaveBeenCalled(); + expect(result.current).toEqual(expectedResult); + }); + }); + + test('returns suggestions', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook< + UseFieldValueAutocompleteProps, + UseFieldValueAutocompleteReturn + >(() => + useFieldValueAutocomplete({ + selectedField: getField('@tags'), + operatorType: OperatorTypeEnum.MATCH, + fieldValue: '', + indexPattern: stubIndexPatternWithFields, + }) + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + + const expectedResult: UseFieldValueAutocompleteReturn = [ + false, + ['value 1', 'value 2'], + result.current[2], + ]; + + expect(getValueSuggestionsMock).toHaveBeenCalledWith({ + field: getField('@tags'), + indexPattern: stubIndexPatternWithFields, + query: '', + signal: new AbortController().signal, + }); + expect(result.current).toEqual(expectedResult); + }); + }); + + test('returns new suggestions on subsequent calls', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook< + UseFieldValueAutocompleteProps, + UseFieldValueAutocompleteReturn + >(() => + useFieldValueAutocomplete({ + selectedField: getField('@tags'), + operatorType: OperatorTypeEnum.MATCH, + fieldValue: '', + indexPattern: stubIndexPatternWithFields, + }) + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + + result.current[2]({ + fieldSelected: getField('@tags'), + value: 'hello', + patterns: stubIndexPatternWithFields, + signal: new AbortController().signal, + }); + + await waitForNextUpdate(); + + const expectedResult: UseFieldValueAutocompleteReturn = [ + false, + ['value 1', 'value 2'], + result.current[2], + ]; + + expect(getValueSuggestionsMock).toHaveBeenCalledTimes(2); + expect(result.current).toEqual(expectedResult); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/hooks/use_field_value_autocomplete.ts b/x-pack/plugins/security_solution/public/common/components/autocomplete/hooks/use_field_value_autocomplete.ts new file mode 100644 index 0000000000000..541c0a8d3fbae --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/hooks/use_field_value_autocomplete.ts @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useEffect, useState, useRef } from 'react'; +import { debounce } from 'lodash'; + +import { IFieldType, IIndexPattern } from '../../../../../../../../src/plugins/data/common'; +import { useKibana } from '../../../../common/lib/kibana'; +import { OperatorTypeEnum } from '../../../../lists_plugin_deps'; + +export type UseFieldValueAutocompleteReturn = [ + boolean, + string[], + (args: { + fieldSelected: IFieldType | undefined; + value: string | string[] | undefined; + patterns: IIndexPattern | undefined; + signal: AbortSignal; + }) => void +]; + +export interface UseFieldValueAutocompleteProps { + selectedField: IFieldType | undefined; + operatorType: OperatorTypeEnum; + fieldValue: string | string[] | undefined; + indexPattern: IIndexPattern | undefined; +} +/** + * Hook for using the field value autocomplete service + * + */ +export const useFieldValueAutocomplete = ({ + selectedField, + operatorType, + fieldValue, + indexPattern, +}: UseFieldValueAutocompleteProps): UseFieldValueAutocompleteReturn => { + const { services } = useKibana(); + const [isLoading, setIsLoading] = useState(false); + const [suggestions, setSuggestions] = useState([]); + const updateSuggestions = useRef( + debounce( + async ({ + fieldSelected, + value, + patterns, + signal, + }: { + fieldSelected: IFieldType | undefined; + value: string | string[] | undefined; + patterns: IIndexPattern | undefined; + signal: AbortSignal; + }) => { + if (fieldSelected == null || patterns == null) { + return; + } + + setIsLoading(true); + + // Fields of type boolean should only display two options + if (fieldSelected.type === 'boolean') { + setIsLoading(false); + setSuggestions(['true', 'false']); + return; + } + + const newSuggestions = await services.data.autocomplete.getValueSuggestions({ + indexPattern: patterns, + field: fieldSelected, + query: '', + signal, + }); + + setIsLoading(false); + setSuggestions(newSuggestions); + }, + 500 + ) + ); + + useEffect(() => { + const abortCtrl = new AbortController(); + + if (operatorType !== OperatorTypeEnum.EXISTS) { + updateSuggestions.current({ + fieldSelected: selectedField, + value: fieldValue, + patterns: indexPattern, + signal: abortCtrl.signal, + }); + } + + return (): void => { + abortCtrl.abort(); + }; + }, [updateSuggestions, selectedField, operatorType, fieldValue, indexPattern]); + + return [isLoading, suggestions, updateSuggestions.current]; +}; diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/operator.test.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/operator.test.tsx new file mode 100644 index 0000000000000..45fe6be78ace6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/operator.test.tsx @@ -0,0 +1,197 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { ThemeProvider } from 'styled-components'; +import { mount } from 'enzyme'; +import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; +import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; + +import { getField } from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks.ts'; +import { OperatorComponent } from './operator'; +import { isOperator, isNotOperator } from './operators'; + +describe('OperatorComponent', () => { + test('it renders disabled if "isDisabled" is true', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect( + wrapper.find(`[data-test-subj="operatorAutocompleteComboBox"] input`).prop('disabled') + ).toBeTruthy(); + }); + + test('it renders loading if "isLoading" is true', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + wrapper.find(`[data-test-subj="operatorAutocompleteComboBox"] button`).at(0).simulate('click'); + expect( + wrapper + .find(`EuiComboBoxOptionsList[data-test-subj="operatorAutocompleteComboBox-optionsList"]`) + .prop('isLoading') + ).toBeTruthy(); + }); + + test('it allows user to clear values if "isClearable" is true', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect(wrapper.find(`button[data-test-subj="comboBoxClearButton"]`).exists()).toBeTruthy(); + }); + + test('it displays "operatorOptions" if param is passed in', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect( + wrapper.find(`[data-test-subj="operatorAutocompleteComboBox"]`).at(0).prop('options') + ).toEqual([{ label: 'is not' }]); + }); + + test('it correctly displays selected operator', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect( + wrapper.find(`[data-test-subj="operatorAutocompleteComboBox"] EuiComboBoxPill`).at(0).text() + ).toEqual('is'); + }); + + test('it only displays subset of operators if field type is nested', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect( + wrapper.find(`[data-test-subj="operatorAutocompleteComboBox"]`).at(0).prop('options') + ).toEqual([{ label: 'is' }]); + }); + + test('it only displays subset of operators if field type is boolean', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect( + wrapper.find(`[data-test-subj="operatorAutocompleteComboBox"]`).at(0).prop('options') + ).toEqual([ + { label: 'is' }, + { label: 'is not' }, + { label: 'exists' }, + { label: 'does not exist' }, + ]); + }); + + test('it invokes "onChange" when option selected', () => { + const mockOnChange = jest.fn(); + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + ((wrapper.find(EuiComboBox).props() as unknown) as { + onChange: (a: EuiComboBoxOptionOption[]) => void; + }).onChange([{ label: 'is not' }]); + + expect(mockOnChange).toHaveBeenCalledWith([ + { message: 'is not', operator: 'excluded', type: 'match', value: 'is_not' }, + ]); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/operator.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/operator.tsx new file mode 100644 index 0000000000000..6d9a684aab2de --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/operator.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useCallback, useMemo } from 'react'; +import { EuiComboBoxOptionOption, EuiComboBox } from '@elastic/eui'; + +import { IFieldType } from '../../../../../../../src/plugins/data/common'; +import { getOperators, getGenericComboBoxProps } from './helpers'; +import { GetGenericComboBoxPropsReturn, OperatorOption } from './types'; + +interface OperatorState { + placeholder: string; + selectedField: IFieldType | undefined; + operator: OperatorOption; + isLoading: boolean; + isDisabled: boolean; + isClearable: boolean; + operatorInputWidth?: number; + operatorOptions?: OperatorOption[]; + onChange: (arg: OperatorOption[]) => void; +} + +export const OperatorComponent: React.FC = ({ + placeholder, + selectedField, + operator, + isLoading = false, + isDisabled = false, + isClearable = false, + operatorOptions, + operatorInputWidth = 150, + onChange, +}): JSX.Element => { + const getLabel = useCallback(({ message }): string => message, []); + const optionsMemo = useMemo( + (): OperatorOption[] => (operatorOptions ? operatorOptions : getOperators(selectedField)), + [operatorOptions, selectedField] + ); + const selectedOptionsMemo = useMemo((): OperatorOption[] => (operator ? [operator] : []), [ + operator, + ]); + const { comboOptions, labels, selectedComboOptions } = useMemo( + (): GetGenericComboBoxPropsReturn => + getGenericComboBoxProps({ + options: optionsMemo, + selectedOptions: selectedOptionsMemo, + getLabel, + }), + [optionsMemo, selectedOptionsMemo, getLabel] + ); + + const handleValuesChange = (newOptions: EuiComboBoxOptionOption[]): void => { + const newValues: OperatorOption[] = newOptions.map( + ({ label }) => optionsMemo[labels.indexOf(label)] + ); + onChange(newValues); + }; + + return ( + + ); +}; + +OperatorComponent.displayName = 'Operator'; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/operators.ts b/x-pack/plugins/security_solution/public/common/components/autocomplete/operators.ts similarity index 87% rename from x-pack/plugins/security_solution/public/common/components/exceptions/operators.ts rename to x-pack/plugins/security_solution/public/common/components/autocomplete/operators.ts index 2c18d7447d5f6..a81d8cde94e34 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/operators.ts +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/operators.ts @@ -6,7 +6,7 @@ import { i18n } from '@kbn/i18n'; import { OperatorOption } from './types'; -import { OperatorTypeEnum } from '../../../lists_plugin_deps'; +import { OperatorEnum, OperatorTypeEnum } from '../../../lists_plugin_deps'; export const isOperator: OperatorOption = { message: i18n.translate('xpack.securitySolution.exceptions.isOperatorLabel', { @@ -14,7 +14,7 @@ export const isOperator: OperatorOption = { }), value: 'is', type: OperatorTypeEnum.MATCH, - operator: 'included', + operator: OperatorEnum.INCLUDED, }; export const isNotOperator: OperatorOption = { @@ -23,7 +23,7 @@ export const isNotOperator: OperatorOption = { }), value: 'is_not', type: OperatorTypeEnum.MATCH, - operator: 'excluded', + operator: OperatorEnum.EXCLUDED, }; export const isOneOfOperator: OperatorOption = { @@ -32,7 +32,7 @@ export const isOneOfOperator: OperatorOption = { }), value: 'is_one_of', type: OperatorTypeEnum.MATCH_ANY, - operator: 'included', + operator: OperatorEnum.INCLUDED, }; export const isNotOneOfOperator: OperatorOption = { @@ -41,7 +41,7 @@ export const isNotOneOfOperator: OperatorOption = { }), value: 'is_not_one_of', type: OperatorTypeEnum.MATCH_ANY, - operator: 'excluded', + operator: OperatorEnum.EXCLUDED, }; export const existsOperator: OperatorOption = { @@ -50,7 +50,7 @@ export const existsOperator: OperatorOption = { }), value: 'exists', type: OperatorTypeEnum.EXISTS, - operator: 'included', + operator: OperatorEnum.INCLUDED, }; export const doesNotExistOperator: OperatorOption = { @@ -59,7 +59,7 @@ export const doesNotExistOperator: OperatorOption = { }), value: 'does_not_exist', type: OperatorTypeEnum.EXISTS, - operator: 'excluded', + operator: OperatorEnum.EXCLUDED, }; export const isInListOperator: OperatorOption = { @@ -68,7 +68,7 @@ export const isInListOperator: OperatorOption = { }), value: 'is_in_list', type: OperatorTypeEnum.LIST, - operator: 'included', + operator: OperatorEnum.INCLUDED, }; export const isNotInListOperator: OperatorOption = { @@ -77,7 +77,7 @@ export const isNotInListOperator: OperatorOption = { }), value: 'is_not_in_list', type: OperatorTypeEnum.LIST, - operator: 'excluded', + operator: OperatorEnum.EXCLUDED, }; export const EXCEPTION_OPERATORS: OperatorOption[] = [ diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/readme.md b/x-pack/plugins/security_solution/public/common/components/autocomplete/readme.md new file mode 100644 index 0000000000000..2bf1867c008d2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/readme.md @@ -0,0 +1,122 @@ +# Autocomplete Fields + +Need an input that shows available index fields? Or an input that autocompletes based on a selected indexPattern field? Bingo! That's what these components are for. They are generalized enough so that they can be reused throughout and repurposed based on your needs. + +All three of the available components rely on Eui's combo box. + +## useFieldValueAutocomplete + +This hook uses the kibana `services.data.autocomplete.getValueSuggestions()` service to return possible autocomplete fields based on the passed in `indexPattern` and `selectedField`. + +## FieldComponent + +This component can be used to display available indexPattern fields. It requires an indexPattern to be passed in and will show an error state if value is not one of the available indexPattern fields. Users will be able to select only one option. + +The `onChange` handler is passed `IFieldType[]`. + +```js + +``` + +## OperatorComponent + +This component can be used to display available operators. If you want to pass in your own operators, you can use `operatorOptions` prop. If a `operatorOptions` is provided, those will be used and it will ignore any of the built in logic that determines which operators to show. The operators within `operatorOptions` will still need to be of type `OperatorOption`. + +If no `operatorOptions` is provided, then the following behavior is observed: + +- if `selectedField` type is `boolean`, only `is`, `is not`, `exists`, `does not exist` operators will show +- if `selectedField` type is `nested`, only `is` operator will show +- if not one of the above, all operators will show (see `operators.ts`) + +The `onChange` handler is passed `OperatorOption[]`. + +```js + +``` + +## AutocompleteFieldExistsComponent + +This field value component is used when the selected operator is `exists` or `does not exist`. When these operators are selected, they are equivalent to using a wildcard. The combo box will be displayed as disabled. + +```js + +``` + +## AutocompleteFieldListsComponent + +This component can be used to display available large value lists - when operator selected is `is in list` or `is not in list`. It relies on hooks from the `lists` plugin. Users can only select one list and an error is shown if value is not one of available lists. + +The `selectedValue` should be the `id` of the selected list. + +This component relies on `selectedField` to render available lists. The reason being that it relies on the `selectedField` type to determine which lists to show as each large value list has a type as well. So if a user selects a field of type `ip`, it will only display lists of type `ip`. + +The `onChange` handler is passed `ListSchema`. + +```js + +``` + +## AutocompleteFieldMatchComponent + +This component can be used to allow users to select one single value. It uses the autocomplete hook to display any autocomplete options based on the passed in `indexPattern`, but also allows a user to add their own value. + +It does some minor validation, assuring that field value is a date if `selectedField` type is `date`, a number if `selectedField` type is `number`, an ip if `selectedField` type is `ip`. + +The `onChange` handler is passed selected `string`. + +```js + +``` + +## AutocompleteFieldMatchAnyComponent + +This component can be used to allow users to select multiple values. It uses the autocomplete hook to display any autocomplete options based on the passed in `indexPattern`, but also allows a user to add their own values. + +It does some minor validation, assuring that field values are a date if `selectedField` type is `date`, numbers if `selectedField` type is `number`, ips if `selectedField` type is `ip`. + +The `onChange` handler is passed selected `string[]`. + +```js + +``` diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/translations.ts b/x-pack/plugins/security_solution/public/common/components/autocomplete/translations.ts new file mode 100644 index 0000000000000..6d83086b15e6a --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/translations.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const LOADING = i18n.translate('xpack.securitySolution.autocomplete.loadingDescription', { + defaultMessage: 'Loading...', +}); diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/types.ts b/x-pack/plugins/security_solution/public/common/components/autocomplete/types.ts new file mode 100644 index 0000000000000..78a7b8aeb61eb --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/types.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiComboBoxOptionOption } from '@elastic/eui'; + +import { OperatorEnum, OperatorTypeEnum } from '../../../lists_plugin_deps'; + +export interface GetGenericComboBoxPropsReturn { + comboOptions: EuiComboBoxOptionOption[]; + labels: string[]; + selectedComboOptions: EuiComboBoxOptionOption[]; +} + +export interface OperatorOption { + message: string; + value: string; + operator: OperatorEnum; + type: OperatorTypeEnum; +} diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete_field/__examples__/index.stories.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete_field/__examples__/index.stories.tsx deleted file mode 100644 index 8f261da629f94..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete_field/__examples__/index.stories.tsx +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import * as React from 'react'; -import { storiesOf } from '@storybook/react'; -import { ThemeProvider } from 'styled-components'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; -import { - QuerySuggestion, - QuerySuggestionTypes, -} from '../../../../../../../../src/plugins/data/public'; -import { SuggestionItem } from '../suggestion_item'; - -const suggestion: QuerySuggestion = { - description: 'Description...', - end: 3, - start: 1, - text: 'Text...', - type: QuerySuggestionTypes.Value, -}; - -storiesOf('components/SuggestionItem', module).add('example', () => ( - ({ - eui: euiLightVars, - darkMode: false, - })} - > - - -)); diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete_field/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/autocomplete_field/__snapshots__/index.test.tsx.snap deleted file mode 100644 index dfd9612d52443..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete_field/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,26 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Autocomplete rendering it renders against snapshot 1`] = ` - - - - - -`; diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete_field/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete_field/index.test.tsx deleted file mode 100644 index 55e114818ffea..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete_field/index.test.tsx +++ /dev/null @@ -1,388 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { EuiFieldSearch } from '@elastic/eui'; -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; -import { mount, shallow } from 'enzyme'; -import { noop } from 'lodash/fp'; -import React from 'react'; -import { ThemeProvider } from 'styled-components'; -import { - QuerySuggestion, - QuerySuggestionTypes, -} from '../../../../../../../src/plugins/data/public'; - -import { TestProviders } from '../../mock'; - -import { AutocompleteField } from '.'; - -const mockAutoCompleteData: QuerySuggestion[] = [ - { - type: QuerySuggestionTypes.Field, - text: 'agent.ephemeral_id ', - description: - '

Filter results that contain agent.ephemeral_id

', - start: 0, - end: 1, - }, - { - type: QuerySuggestionTypes.Field, - text: 'agent.hostname ', - description: - '

Filter results that contain agent.hostname

', - start: 0, - end: 1, - }, - { - type: QuerySuggestionTypes.Field, - text: 'agent.id ', - description: - '

Filter results that contain agent.id

', - start: 0, - end: 1, - }, - { - type: QuerySuggestionTypes.Field, - text: 'agent.name ', - description: - '

Filter results that contain agent.name

', - start: 0, - end: 1, - }, - { - type: QuerySuggestionTypes.Field, - text: 'agent.type ', - description: - '

Filter results that contain agent.type

', - start: 0, - end: 1, - }, - { - type: QuerySuggestionTypes.Field, - text: 'agent.version ', - description: - '

Filter results that contain agent.version

', - start: 0, - end: 1, - }, - { - type: QuerySuggestionTypes.Field, - text: 'agent.test1 ', - description: - '

Filter results that contain agent.test1

', - start: 0, - end: 1, - }, - { - type: QuerySuggestionTypes.Field, - text: 'agent.test2 ', - description: - '

Filter results that contain agent.test2

', - start: 0, - end: 1, - }, - { - type: QuerySuggestionTypes.Field, - text: 'agent.test3 ', - description: - '

Filter results that contain agent.test3

', - start: 0, - end: 1, - }, - { - type: QuerySuggestionTypes.Field, - text: 'agent.test4 ', - description: - '

Filter results that contain agent.test4

', - start: 0, - end: 1, - }, -]; - -describe('Autocomplete', () => { - describe('rendering', () => { - test('it renders against snapshot', () => { - const placeholder = 'myPlaceholder'; - - const wrapper = shallow( - - ); - expect(wrapper).toMatchSnapshot(); - }); - - test('it is rendering with placeholder', () => { - const placeholder = 'myPlaceholder'; - - const wrapper = mount( - - ); - const input = wrapper.find('input[type="search"]'); - expect(input.find('[placeholder]').props().placeholder).toEqual(placeholder); - }); - - test('Rendering suggested items', () => { - const wrapper = mount( - ({ eui: euiDarkVars, darkMode: true })}> - - - ); - const wrapperAutocompleteField = wrapper.find(AutocompleteField); - wrapperAutocompleteField.setState({ areSuggestionsVisible: true }); - wrapper.update(); - - expect(wrapper.find('.euiPanel div[data-test-subj="suggestion-item"]').length).toEqual(10); - }); - - test('Should Not render suggested items if loading new suggestions', () => { - const wrapper = mount( - ({ eui: euiDarkVars, darkMode: true })}> - - - ); - const wrapperAutocompleteField = wrapper.find(AutocompleteField); - wrapperAutocompleteField.setState({ areSuggestionsVisible: true }); - wrapper.update(); - - expect(wrapper.find('.euiPanel div[data-test-subj="suggestion-item"]').length).toEqual(0); - }); - }); - - describe('events', () => { - test('OnChange should have been called', () => { - const onChange = jest.fn((value: string) => value); - - const wrapper = mount( - - ); - const wrapperFixedEuiFieldSearch = wrapper.find('input'); - wrapperFixedEuiFieldSearch.simulate('change', { target: { value: 'test' } }); - expect(onChange).toHaveBeenCalled(); - }); - }); - - test('OnSubmit should have been called by keying enter on the search input', () => { - const onSubmit = jest.fn((value: string) => value); - - const wrapper = mount( - - ); - const wrapperAutocompleteField = wrapper.find(AutocompleteField); - wrapperAutocompleteField.setState({ selectedIndex: null }); - const wrapperFixedEuiFieldSearch = wrapper.find('input'); - wrapperFixedEuiFieldSearch.simulate('keydown', { key: 'Enter', preventDefault: noop }); - expect(onSubmit).toHaveBeenCalled(); - }); - - test('OnSubmit should have been called by onSearch event on the input', () => { - const onSubmit = jest.fn((value: string) => value); - - const wrapper = mount( - - ); - const wrapperAutocompleteField = wrapper.find(AutocompleteField); - wrapperAutocompleteField.setState({ selectedIndex: null }); - const wrapperFixedEuiFieldSearch = wrapper.find(EuiFieldSearch); - // TODO: FixedEuiFieldSearch fails to import - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (wrapperFixedEuiFieldSearch as any).props().onSearch(); - expect(onSubmit).toHaveBeenCalled(); - }); - - test('OnChange should have been called if keying enter on a suggested item selected', () => { - const onChange = jest.fn((value: string) => value); - - const wrapper = mount( - - ); - const wrapperAutocompleteField = wrapper.find(AutocompleteField); - wrapperAutocompleteField.setState({ selectedIndex: 1 }); - const wrapperFixedEuiFieldSearch = wrapper.find('input'); - wrapperFixedEuiFieldSearch.simulate('keydown', { key: 'Enter', preventDefault: noop }); - expect(onChange).toHaveBeenCalled(); - }); - - test('OnChange should be called if tab is pressed when a suggested item is selected', () => { - const onChange = jest.fn((value: string) => value); - - const wrapper = mount( - - ); - const wrapperAutocompleteField = wrapper.find(AutocompleteField); - wrapperAutocompleteField.setState({ selectedIndex: 1 }); - const wrapperFixedEuiFieldSearch = wrapper.find('input'); - wrapperFixedEuiFieldSearch.simulate('keydown', { key: 'Tab', preventDefault: noop }); - expect(onChange).toHaveBeenCalled(); - }); - - test('OnChange should NOT be called if tab is pressed when more than one item is suggested, and no selection has been made', () => { - const onChange = jest.fn((value: string) => value); - - const wrapper = mount( - - ); - - const wrapperFixedEuiFieldSearch = wrapper.find('input'); - wrapperFixedEuiFieldSearch.simulate('keydown', { key: 'Tab', preventDefault: noop }); - expect(onChange).not.toHaveBeenCalled(); - }); - - test('OnChange should be called if tab is pressed when only one item is suggested, even though that item is NOT selected', () => { - const onChange = jest.fn((value: string) => value); - const onlyOneSuggestion = [mockAutoCompleteData[0]]; - - const wrapper = mount( - - - - ); - - const wrapperAutocompleteField = wrapper.find(AutocompleteField); - wrapperAutocompleteField.setState({ areSuggestionsVisible: true }); - const wrapperFixedEuiFieldSearch = wrapper.find('input'); - wrapperFixedEuiFieldSearch.simulate('keydown', { key: 'Tab', preventDefault: noop }); - expect(onChange).toHaveBeenCalled(); - }); - - test('OnChange should NOT be called if tab is pressed when 0 items are suggested', () => { - const onChange = jest.fn((value: string) => value); - - const wrapper = mount( - - ); - - const wrapperFixedEuiFieldSearch = wrapper.find('input'); - wrapperFixedEuiFieldSearch.simulate('keydown', { key: 'Tab', preventDefault: noop }); - expect(onChange).not.toHaveBeenCalled(); - }); - - test('Load more suggestions when arrowdown on the search bar', () => { - const loadSuggestions = jest.fn(noop); - - const wrapper = mount( - - ); - const wrapperFixedEuiFieldSearch = wrapper.find('input'); - wrapperFixedEuiFieldSearch.simulate('keydown', { key: 'ArrowDown', preventDefault: noop }); - expect(loadSuggestions).toHaveBeenCalled(); - }); -}); diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete_field/index.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete_field/index.tsx deleted file mode 100644 index f1b7da522fbd0..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete_field/index.tsx +++ /dev/null @@ -1,335 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { - EuiFieldSearch, - EuiFieldSearchProps, - EuiOutsideClickDetector, - EuiPanel, -} from '@elastic/eui'; -import React from 'react'; -import { QuerySuggestion } from '../../../../../../../src/plugins/data/public'; - -import euiStyled from '../../../../../../legacy/common/eui_styled_components'; - -import { SuggestionItem } from './suggestion_item'; - -interface AutocompleteFieldProps { - 'data-test-subj'?: string; - isLoadingSuggestions: boolean; - isValid: boolean; - loadSuggestions: (value: string, cursorPosition: number, maxCount?: number) => void; - onSubmit?: (value: string) => void; - onChange?: (value: string) => void; - placeholder?: string; - suggestions: QuerySuggestion[]; - value: string; -} - -interface AutocompleteFieldState { - areSuggestionsVisible: boolean; - isFocused: boolean; - selectedIndex: number | null; -} - -export class AutocompleteField extends React.PureComponent< - AutocompleteFieldProps, - AutocompleteFieldState -> { - public readonly state: AutocompleteFieldState = { - areSuggestionsVisible: false, - isFocused: false, - selectedIndex: null, - }; - - private inputElement: HTMLInputElement | null = null; - - public render() { - const { - 'data-test-subj': dataTestSubj, - suggestions, - isLoadingSuggestions, - isValid, - placeholder, - value, - } = this.props; - const { areSuggestionsVisible, selectedIndex } = this.state; - return ( - - - - {areSuggestionsVisible && !isLoadingSuggestions && suggestions.length > 0 ? ( - - {suggestions.map((suggestion, suggestionIndex) => ( - - ))} - - ) : null} - - - ); - } - - public componentDidUpdate(prevProps: AutocompleteFieldProps, prevState: AutocompleteFieldState) { - const hasNewValue = prevProps.value !== this.props.value; - const hasNewSuggestions = prevProps.suggestions !== this.props.suggestions; - - if (hasNewValue) { - this.updateSuggestions(); - } - - if (hasNewSuggestions && this.state.isFocused) { - this.showSuggestions(); - } - } - - private handleChangeInputRef = (element: HTMLInputElement | null) => { - this.inputElement = element; - }; - - private handleChange = (evt: React.ChangeEvent) => { - this.changeValue(evt.currentTarget.value); - }; - - private handleKeyDown = (evt: React.KeyboardEvent) => { - const { suggestions } = this.props; - switch (evt.key) { - case 'ArrowUp': - evt.preventDefault(); - if (suggestions.length > 0) { - this.setState( - composeStateUpdaters(withSuggestionsVisible, withPreviousSuggestionSelected) - ); - } - break; - case 'ArrowDown': - evt.preventDefault(); - if (suggestions.length > 0) { - this.setState(composeStateUpdaters(withSuggestionsVisible, withNextSuggestionSelected)); - } else { - this.updateSuggestions(); - } - break; - case 'Enter': - evt.preventDefault(); - if (this.state.selectedIndex !== null) { - this.applySelectedSuggestion(); - } else { - this.submit(); - } - break; - case 'Tab': - evt.preventDefault(); - if (this.state.areSuggestionsVisible && this.props.suggestions.length === 1) { - this.applySuggestionAt(0)(); - } else if (this.state.selectedIndex !== null) { - this.applySelectedSuggestion(); - } - break; - case 'Escape': - evt.preventDefault(); - evt.stopPropagation(); - this.setState(withSuggestionsHidden); - break; - } - }; - - private handleKeyUp = (evt: React.KeyboardEvent) => { - switch (evt.key) { - case 'ArrowLeft': - case 'ArrowRight': - case 'Home': - case 'End': - this.updateSuggestions(); - break; - } - }; - - private handleFocus = () => { - this.setState(composeStateUpdaters(withSuggestionsVisible, withFocused)); - }; - - private handleBlur = () => { - this.setState(composeStateUpdaters(withSuggestionsHidden, withUnfocused)); - }; - - private selectSuggestionAt = (index: number) => () => { - this.setState(withSuggestionAtIndexSelected(index)); - }; - - private applySelectedSuggestion = () => { - if (this.state.selectedIndex !== null) { - this.applySuggestionAt(this.state.selectedIndex)(); - } - }; - - private applySuggestionAt = (index: number) => () => { - const { value, suggestions } = this.props; - const selectedSuggestion = suggestions[index]; - - if (!selectedSuggestion) { - return; - } - - const newValue = - value.substr(0, selectedSuggestion.start) + - selectedSuggestion.text + - value.substr(selectedSuggestion.end); - - this.setState(withSuggestionsHidden); - this.changeValue(newValue); - this.focusInputElement(); - }; - - private changeValue = (value: string) => { - const { onChange } = this.props; - - if (onChange) { - onChange(value); - } - }; - - private focusInputElement = () => { - if (this.inputElement) { - this.inputElement.focus(); - } - }; - - private showSuggestions = () => { - this.setState(withSuggestionsVisible); - }; - - private submit = () => { - const { isValid, onSubmit, value } = this.props; - - if (isValid && onSubmit) { - onSubmit(value); - } - - this.setState(withSuggestionsHidden); - }; - - private updateSuggestions = () => { - const inputCursorPosition = this.inputElement ? this.inputElement.selectionStart || 0 : 0; - this.props.loadSuggestions(this.props.value, inputCursorPosition, 10); - }; -} - -type StateUpdater = ( - prevState: Readonly, - prevProps: Readonly -) => State | null; - -function composeStateUpdaters(...updaters: Array>) { - return (state: State, props: Props) => - updaters.reduce((currentState, updater) => updater(currentState, props) || currentState, state); -} - -const withPreviousSuggestionSelected = ( - state: AutocompleteFieldState, - props: AutocompleteFieldProps -): AutocompleteFieldState => ({ - ...state, - selectedIndex: - props.suggestions.length === 0 - ? null - : state.selectedIndex !== null - ? (state.selectedIndex + props.suggestions.length - 1) % props.suggestions.length - : Math.max(props.suggestions.length - 1, 0), -}); - -const withNextSuggestionSelected = ( - state: AutocompleteFieldState, - props: AutocompleteFieldProps -): AutocompleteFieldState => ({ - ...state, - selectedIndex: - props.suggestions.length === 0 - ? null - : state.selectedIndex !== null - ? (state.selectedIndex + 1) % props.suggestions.length - : 0, -}); - -const withSuggestionAtIndexSelected = (suggestionIndex: number) => ( - state: AutocompleteFieldState, - props: AutocompleteFieldProps -): AutocompleteFieldState => ({ - ...state, - selectedIndex: - props.suggestions.length === 0 - ? null - : suggestionIndex >= 0 && suggestionIndex < props.suggestions.length - ? suggestionIndex - : 0, -}); - -const withSuggestionsVisible = (state: AutocompleteFieldState) => ({ - ...state, - areSuggestionsVisible: true, -}); - -const withSuggestionsHidden = (state: AutocompleteFieldState) => ({ - ...state, - areSuggestionsVisible: false, - selectedIndex: null, -}); - -const withFocused = (state: AutocompleteFieldState) => ({ - ...state, - isFocused: true, -}); - -const withUnfocused = (state: AutocompleteFieldState) => ({ - ...state, - isFocused: false, -}); - -export const FixedEuiFieldSearch: React.FC< - React.InputHTMLAttributes & - EuiFieldSearchProps & { - inputRef?: (element: HTMLInputElement | null) => void; - onSearch: (value: string) => void; - } -> = EuiFieldSearch as any; // eslint-disable-line @typescript-eslint/no-explicit-any - -const AutocompleteContainer = euiStyled.div` - position: relative; -`; - -AutocompleteContainer.displayName = 'AutocompleteContainer'; - -const SuggestionsPanel = euiStyled(EuiPanel).attrs(() => ({ - paddingSize: 'none', - hasShadow: true, -}))` - position: absolute; - width: 100%; - margin-top: 2px; - overflow: hidden; - z-index: ${(props) => props.theme.eui.euiZLevel1}; -`; - -SuggestionsPanel.displayName = 'SuggestionsPanel'; diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete_field/suggestion_item.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete_field/suggestion_item.tsx deleted file mode 100644 index 56d25cbdda024..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete_field/suggestion_item.tsx +++ /dev/null @@ -1,131 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { EuiIcon } from '@elastic/eui'; -import { transparentize } from 'polished'; -import React from 'react'; -import styled from 'styled-components'; -import euiStyled from '../../../../../../legacy/common/eui_styled_components'; -import { QuerySuggestion } from '../../../../../../../src/plugins/data/public'; - -interface SuggestionItemProps { - isSelected?: boolean; - onClick?: React.MouseEventHandler; - onMouseEnter?: React.MouseEventHandler; - suggestion: QuerySuggestion; -} - -export const SuggestionItem = React.memo( - ({ isSelected = false, onClick, onMouseEnter, suggestion }) => { - return ( - - - - - {suggestion.text} - {suggestion.description} - - ); - } -); - -SuggestionItem.displayName = 'SuggestionItem'; - -const SuggestionItemContainer = euiStyled.div<{ - isSelected?: boolean; -}>` - display: flex; - flex-direction: row; - font-size: ${(props) => props.theme.eui.euiFontSizeS}; - height: ${(props) => props.theme.eui.euiSizeXL}; - white-space: nowrap; - background-color: ${(props) => - props.isSelected ? props.theme.eui.euiColorLightestShade : 'transparent'}; -`; - -SuggestionItemContainer.displayName = 'SuggestionItemContainer'; - -const SuggestionItemField = euiStyled.div` - align-items: center; - cursor: pointer; - display: flex; - flex-direction: row; - height: ${(props) => props.theme.eui.euiSizeXL}; - padding: ${(props) => props.theme.eui.euiSizeXS}; -`; - -SuggestionItemField.displayName = 'SuggestionItemField'; - -const SuggestionItemIconField = styled(SuggestionItemField)<{ suggestionType: string }>` - background-color: ${(props) => - transparentize(0.9, getEuiIconColor(props.theme, props.suggestionType))}; - color: ${(props) => getEuiIconColor(props.theme, props.suggestionType)}; - flex: 0 0 auto; - justify-content: center; - width: ${(props) => props.theme.eui.euiSizeXL}; -`; - -SuggestionItemIconField.displayName = 'SuggestionItemIconField'; - -const SuggestionItemTextField = styled(SuggestionItemField)` - flex: 2 0 0; - font-family: ${(props) => props.theme.eui.euiCodeFontFamily}; -`; - -SuggestionItemTextField.displayName = 'SuggestionItemTextField'; - -const SuggestionItemDescriptionField = styled(SuggestionItemField)` - flex: 3 0 0; - - p { - display: inline; - - span { - font-family: ${(props) => props.theme.eui.euiCodeFontFamily}; - } - } -`; - -SuggestionItemDescriptionField.displayName = 'SuggestionItemDescriptionField'; - -const getEuiIconType = (suggestionType: string) => { - switch (suggestionType) { - case 'field': - return 'kqlField'; - case 'value': - return 'kqlValue'; - case 'recentSearch': - return 'search'; - case 'conjunction': - return 'kqlSelector'; - case 'operator': - return 'kqlOperand'; - default: - return 'empty'; - } -}; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const getEuiIconColor = (theme: any, suggestionType: string): string => { - switch (suggestionType) { - case 'field': - return theme.eui.euiColorVis7; - case 'value': - return theme.eui.euiColorVis0; - case 'operator': - return theme.eui.euiColorVis1; - case 'conjunction': - return theme.eui.euiColorVis2; - case 'recentSearch': - default: - return theme.eui.euiColorMediumShade; - } -}; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.stories.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.stories.tsx new file mode 100644 index 0000000000000..7e4cbe34f9a64 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.stories.tsx @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { storiesOf, addDecorator } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import React from 'react'; +import { ThemeProvider } from 'styled-components'; +import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; + +import { BuilderButtonOptions } from './builder_button_options'; + +addDecorator((storyFn) => ( + ({ eui: euiLightVars, darkMode: false })}>{storyFn()} +)); + +storiesOf('Components|Exceptions|BuilderButtonOptions', module) + .add('init button', () => { + return ( + + ); + }) + .add('and/or buttons', () => { + return ( + + ); + }) + .add('nested button', () => { + return ( + + ); + }) + .add('and disabled', () => { + return ( + + ); + }) + .add('or disabled', () => { + return ( + + ); + }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.test.tsx new file mode 100644 index 0000000000000..59306b5343743 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.test.tsx @@ -0,0 +1,167 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { mount } from 'enzyme'; +import React from 'react'; + +import { BuilderButtonOptions } from './builder_button_options'; + +describe('BuilderButtonOptions', () => { + test('it renders "and" and "or" buttons', () => { + const wrapper = mount( + + ); + + expect(wrapper.find('[data-test-subj="exceptionsAndButton"] button')).toHaveLength(1); + expect(wrapper.find('[data-test-subj="exceptionsOrButton"] button')).toHaveLength(1); + expect(wrapper.find('[data-test-subj="exceptionsAddNewExceptionButton"] button')).toHaveLength( + 0 + ); + expect(wrapper.find('[data-test-subj="exceptionsNestedButton"] button')).toHaveLength(0); + }); + + test('it renders "add exception" button if "displayInitButton" is true', () => { + const wrapper = mount( + + ); + + expect(wrapper.find('[data-test-subj="exceptionsAddNewExceptionButton"] button')).toHaveLength( + 1 + ); + }); + + test('it invokes "onAddExceptionClicked" when "add exception" button is clicked', () => { + const onOrClicked = jest.fn(); + + const wrapper = mount( + + ); + + wrapper.find('[data-test-subj="exceptionsAddNewExceptionButton"] button').simulate('click'); + + expect(onOrClicked).toHaveBeenCalledTimes(1); + }); + + test('it invokes "onOrClicked" when "or" button is clicked', () => { + const onOrClicked = jest.fn(); + + const wrapper = mount( + + ); + + wrapper.find('[data-test-subj="exceptionsOrButton"] button').simulate('click'); + + expect(onOrClicked).toHaveBeenCalledTimes(1); + }); + + test('it invokes "onAndClicked" when "and" button is clicked', () => { + const onAndClicked = jest.fn(); + + const wrapper = mount( + + ); + + wrapper.find('[data-test-subj="exceptionsAndButton"] button').simulate('click'); + + expect(onAndClicked).toHaveBeenCalledTimes(1); + }); + + test('it disables "and" button if "isAndDisabled" is true', () => { + const wrapper = mount( + + ); + + const andButton = wrapper.find('[data-test-subj="exceptionsAndButton"] button').at(0); + + expect(andButton.prop('disabled')).toBeTruthy(); + }); + + test('it disables "or" button if "isOrDisabled" is true', () => { + const wrapper = mount( + + ); + + const orButton = wrapper.find('[data-test-subj="exceptionsOrButton"] button').at(0); + + expect(orButton.prop('disabled')).toBeTruthy(); + }); + + test('it invokes "onNestedClicked" when "and" button is clicked', () => { + const onNestedClicked = jest.fn(); + + const wrapper = mount( + + ); + + wrapper.find('[data-test-subj="exceptionsNestedButton"] button').simulate('click'); + + expect(onNestedClicked).toHaveBeenCalledTimes(1); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.tsx new file mode 100644 index 0000000000000..ff1556bcc4d25 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.tsx @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiButton } from '@elastic/eui'; +import styled from 'styled-components'; + +import * as i18n from '../translations'; + +const MyEuiButton = styled(EuiButton)` + min-width: 95px; +`; + +interface BuilderButtonOptionsProps { + isOrDisabled: boolean; + isAndDisabled: boolean; + displayInitButton: boolean; + showNestedButton: boolean; + onAndClicked: () => void; + onOrClicked: () => void; + onNestedClicked: () => void; +} + +export const BuilderButtonOptions: React.FC = ({ + isOrDisabled = false, + isAndDisabled = false, + displayInitButton, + showNestedButton = false, + onAndClicked, + onOrClicked, + onNestedClicked, +}) => ( + + {displayInitButton ? ( + + + {i18n.ADD_EXCEPTION_TITLE} + + + ) : ( + <> + + + {i18n.AND} + + + + + {i18n.OR} + + + {showNestedButton && ( + + + {i18n.ADD_NESTED_DESCRIPTION} + + + )} + + )} + +); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_item.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_item.tsx new file mode 100644 index 0000000000000..39a1e1bdbad5a --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_item.tsx @@ -0,0 +1,243 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useCallback } from 'react'; +import { EuiFormRow, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +import { IFieldType, IIndexPattern } from '../../../../../../../../src/plugins/data/common'; +import { FieldComponent } from '../../autocomplete/field'; +import { OperatorComponent } from '../../autocomplete/operator'; +import { isOperator } from '../../autocomplete/operators'; +import { OperatorOption } from '../../autocomplete/types'; +import { AutocompleteFieldMatchComponent } from '../../autocomplete/field_value_match'; +import { AutocompleteFieldMatchAnyComponent } from '../../autocomplete/field_value_match_any'; +import { AutocompleteFieldExistsComponent } from '../../autocomplete/field_value_exists'; +import { FormattedBuilderEntry, BuilderEntry } from '../types'; +import { AutocompleteFieldListsComponent } from '../../autocomplete/field_value_lists'; +import { ListSchema, OperatorTypeEnum } from '../../../../lists_plugin_deps'; +import { getValueFromOperator } from '../helpers'; +import { getEmptyValue } from '../../empty_value'; +import * as i18n from '../translations'; + +interface EntryItemProps { + entry: FormattedBuilderEntry; + entryIndex: number; + indexPattern: IIndexPattern; + isLoading: boolean; + showLabel: boolean; + onChange: (arg: BuilderEntry, i: number) => void; +} + +export const EntryItemComponent: React.FC = ({ + entry, + entryIndex, + indexPattern, + isLoading, + showLabel, + onChange, +}): JSX.Element => { + const handleFieldChange = useCallback( + ([newField]: IFieldType[]): void => { + onChange( + { + field: newField.name, + type: OperatorTypeEnum.MATCH, + operator: isOperator.operator, + value: undefined, + }, + entryIndex + ); + }, + [onChange, entryIndex] + ); + + const handleOperatorChange = useCallback( + ([newOperator]: OperatorOption[]): void => { + const newEntry = getValueFromOperator(entry.field, newOperator); + onChange(newEntry, entryIndex); + }, + [onChange, entryIndex, entry.field] + ); + + const handleFieldMatchValueChange = useCallback( + (newField: string): void => { + onChange( + { + field: entry.field != null ? entry.field.name : undefined, + type: OperatorTypeEnum.MATCH, + operator: isOperator.operator, + value: newField, + }, + entryIndex + ); + }, + [onChange, entryIndex, entry.field] + ); + + const handleFieldMatchAnyValueChange = useCallback( + (newField: string[]): void => { + onChange( + { + field: entry.field != null ? entry.field.name : undefined, + type: OperatorTypeEnum.MATCH_ANY, + operator: isOperator.operator, + value: newField, + }, + entryIndex + ); + }, + [onChange, entryIndex, entry.field] + ); + + const handleFieldListValueChange = useCallback( + (newField: ListSchema): void => { + onChange( + { + field: entry.field != null ? entry.field.name : undefined, + type: OperatorTypeEnum.LIST, + operator: isOperator.operator, + list: { id: newField.id, type: newField.type }, + }, + entryIndex + ); + }, + [onChange, entryIndex, entry.field] + ); + + const renderFieldInput = (isFirst: boolean): JSX.Element => { + const comboBox = ( + + ); + + if (isFirst) { + return ( + + {comboBox} + + ); + } else { + return comboBox; + } + }; + + const renderOperatorInput = (isFirst: boolean): JSX.Element => { + const comboBox = ( + + ); + + if (isFirst) { + return ( + + {comboBox} + + ); + } else { + return comboBox; + } + }; + + const getFieldValueComboBox = (type: OperatorTypeEnum): JSX.Element => { + switch (type) { + case OperatorTypeEnum.MATCH: + const value = typeof entry.value === 'string' ? entry.value : undefined; + return ( + + ); + case OperatorTypeEnum.MATCH_ANY: + const values: string[] = Array.isArray(entry.value) ? entry.value : []; + return ( + + ); + case OperatorTypeEnum.LIST: + const id = typeof entry.value === 'string' ? entry.value : undefined; + return ( + + ); + case OperatorTypeEnum.EXISTS: + return ( + + ); + default: + return <>; + } + }; + + const renderFieldValueInput = (isFirst: boolean, entryType: OperatorTypeEnum): JSX.Element => { + if (isFirst) { + return ( + + {getFieldValueComboBox(entryType)} + + ); + } else { + return getFieldValueComboBox(entryType); + } + }; + + return ( + + {renderFieldInput(showLabel)} + {renderOperatorInput(showLabel)} + {renderFieldValueInput(showLabel, entry.operator.type)} + + ); +}; + +EntryItemComponent.displayName = 'EntryItem'; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/exception_item.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/exception_item.tsx new file mode 100644 index 0000000000000..3afdf43ec7dfa --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/exception_item.tsx @@ -0,0 +1,137 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useMemo } from 'react'; +import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import styled from 'styled-components'; + +import { IIndexPattern } from '../../../../../../../../src/plugins/data/common'; +import { AndOrBadge } from '../../and_or_badge'; +import { EntryItemComponent } from './entry_item'; +import { getFormattedBuilderEntries } from '../helpers'; +import { FormattedBuilderEntry, ExceptionsBuilderExceptionItem, BuilderEntry } from '../types'; + +const MyInvisibleAndBadge = styled(EuiFlexItem)` + visibility: hidden; +`; + +const MyFirstRowContainer = styled(EuiFlexItem)` + padding-top: 20px; +`; + +interface ExceptionListItemProps { + exceptionItem: ExceptionsBuilderExceptionItem; + exceptionId: string; + exceptionItemIndex: number; + isLoading: boolean; + indexPattern: IIndexPattern; + andLogicIncluded: boolean; + onDeleteExceptionItem: (item: ExceptionsBuilderExceptionItem, index: number) => void; + onExceptionItemChange: (item: ExceptionsBuilderExceptionItem, index: number) => void; +} + +export const ExceptionListItemComponent = React.memo( + ({ + exceptionItem, + exceptionId, + exceptionItemIndex, + indexPattern, + isLoading, + andLogicIncluded, + onDeleteExceptionItem, + onExceptionItemChange, + }) => { + const handleEntryChange = (entry: BuilderEntry, entryIndex: number): void => { + const updatedEntries: BuilderEntry[] = [ + ...exceptionItem.entries.slice(0, entryIndex), + { ...entry }, + ...exceptionItem.entries.slice(entryIndex + 1), + ]; + const updatedExceptionItem: ExceptionsBuilderExceptionItem = { + ...exceptionItem, + entries: updatedEntries, + }; + onExceptionItemChange(updatedExceptionItem, exceptionItemIndex); + }; + + const handleDeleteEntry = (entryIndex: number): void => { + const updatedEntries: BuilderEntry[] = [ + ...exceptionItem.entries.slice(0, entryIndex), + ...exceptionItem.entries.slice(entryIndex + 1), + ]; + const updatedExceptionItem: ExceptionsBuilderExceptionItem = { + ...exceptionItem, + entries: updatedEntries, + }; + + onDeleteExceptionItem(updatedExceptionItem, exceptionItemIndex); + }; + + const entries = useMemo( + (): FormattedBuilderEntry[] => + indexPattern != null ? getFormattedBuilderEntries(indexPattern, exceptionItem.entries) : [], + [indexPattern, exceptionItem.entries] + ); + + const andBadge = useMemo((): JSX.Element => { + const badge = ; + if (entries.length > 1 && exceptionItemIndex === 0) { + return {badge}; + } else if (entries.length > 1) { + return {badge}; + } else { + return {badge}; + } + }, [entries.length, exceptionItemIndex]); + + const getDeleteButton = (index: number): JSX.Element => { + const button = ( + handleDeleteEntry(index)} + aria-label="entryDeleteButton" + className="exceptionItemEntryDeleteButton" + data-test-subj="exceptionItemEntryDeleteButton" + /> + ); + if (index === 0 && exceptionItemIndex === 0) { + return {button}; + } else { + return {button}; + } + }; + + return ( + + {andLogicIncluded && andBadge} + + + {entries.map((item, index) => ( + + + + + + {getDeleteButton(index)} + + + ))} + + + + ); + } +); + +ExceptionListItemComponent.displayName = 'ExceptionListItem'; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.tsx new file mode 100644 index 0000000000000..d7e438f49af36 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.tsx @@ -0,0 +1,248 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useMemo, useCallback, useEffect, useState } from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import styled from 'styled-components'; + +import { ExceptionListItemComponent } from './exception_item'; +import { useFetchIndexPatterns } from '../../../../alerts/containers/detection_engine/rules/fetch_index_patterns'; +import { + ExceptionListItemSchema, + NamespaceType, + exceptionListItemSchema, + OperatorTypeEnum, + OperatorEnum, + CreateExceptionListItemSchema, +} from '../../../../../public/lists_plugin_deps'; +import { AndOrBadge } from '../../and_or_badge'; +import { BuilderButtonOptions } from './builder_button_options'; +import { getNewExceptionItem, filterExceptionItems } from '../helpers'; +import { ExceptionsBuilderExceptionItem, CreateExceptionListItemBuilderSchema } from '../types'; +import { Loader } from '../../loader'; + +const MyInvisibleAndBadge = styled(EuiFlexItem)` + visibility: hidden; +`; + +const MyAndBadge = styled(AndOrBadge)` + & > .euiFlexItem { + margin: 0; + } +`; + +const MyButtonsContainer = styled(EuiFlexItem)` + margin: 16px 0; +`; + +interface OnChangeProps { + exceptionItems: Array; + exceptionsToDelete: ExceptionListItemSchema[]; +} + +interface ExceptionBuilderProps { + exceptionListItems: ExceptionListItemSchema[]; + listType: 'detection' | 'endpoint'; + listId: string; + listNamespaceType: NamespaceType; + ruleName: string; + indexPatternConfig: string[]; + isLoading: boolean; + isOrDisabled: boolean; + isAndDisabled: boolean; + onChange: (arg: OnChangeProps) => void; +} + +export const ExceptionBuilder = ({ + exceptionListItems, + listType, + listId, + listNamespaceType, + ruleName, + indexPatternConfig, + isLoading, + isOrDisabled, + isAndDisabled, + onChange, +}: ExceptionBuilderProps) => { + const [andLogicIncluded, setAndLogicIncluded] = useState(false); + const [exceptions, setExceptions] = useState( + exceptionListItems + ); + const [exceptionsToDelete, setExceptionsToDelete] = useState([]); + const [{ isLoading: indexPatternLoading, indexPatterns }] = useFetchIndexPatterns( + indexPatternConfig ?? [] + ); + + // Bubble up changes to parent + useEffect(() => { + onChange({ exceptionItems: filterExceptionItems(exceptions), exceptionsToDelete }); + }, [onChange, exceptionsToDelete, exceptions]); + + const checkAndLogic = (items: ExceptionsBuilderExceptionItem[]): void => { + setAndLogicIncluded(items.filter(({ entries }) => entries.length > 1).length > 0); + }; + + const handleDeleteExceptionItem = ( + item: ExceptionsBuilderExceptionItem, + itemIndex: number + ): void => { + if (item.entries.length === 0) { + if (exceptionListItemSchema.is(item)) { + setExceptionsToDelete((items) => [...items, item]); + } + + setExceptions((existingExceptions) => { + const updatedExceptions = [ + ...existingExceptions.slice(0, itemIndex), + ...existingExceptions.slice(itemIndex + 1), + ]; + checkAndLogic(updatedExceptions); + + return updatedExceptions; + }); + } else { + handleExceptionItemChange(item, itemIndex); + } + }; + + const handleExceptionItemChange = (item: ExceptionsBuilderExceptionItem, index: number): void => { + const updatedExceptions = [ + ...exceptions.slice(0, index), + { + ...item, + }, + ...exceptions.slice(index + 1), + ]; + + checkAndLogic(updatedExceptions); + setExceptions(updatedExceptions); + }; + + const handleAddNewExceptionItemEntry = useCallback((): void => { + setExceptions((existingExceptions): ExceptionsBuilderExceptionItem[] => { + const lastException = existingExceptions[existingExceptions.length - 1]; + const { entries } = lastException; + const updatedException: ExceptionsBuilderExceptionItem = { + ...lastException, + entries: [ + ...entries, + { field: '', type: OperatorTypeEnum.MATCH, operator: OperatorEnum.INCLUDED, value: '' }, + ], + }; + + setAndLogicIncluded(updatedException.entries.length > 1); + + return [ + ...existingExceptions.slice(0, existingExceptions.length - 1), + { ...updatedException }, + ]; + }); + }, [setExceptions, setAndLogicIncluded]); + + const handleAddNewExceptionItem = useCallback((): void => { + // There is a case where there are numerous exception list items, all with + // empty `entries` array. Thought about appending an entry item to one, but that + // would then be arbitrary, decided to just create a new exception list item + const newException = getNewExceptionItem({ + listType, + listId, + namespaceType: listNamespaceType, + ruleName, + }); + setExceptions((existingExceptions) => [...existingExceptions, { ...newException }]); + }, [setExceptions, listType, listId, listNamespaceType, ruleName]); + + // An exception item can have an empty array for `entries` + const displayInitialAddExceptionButton = useMemo((): boolean => { + return ( + exceptions.length === 0 || + (exceptions.length === 1 && + exceptions[0].entries != null && + exceptions[0].entries.length === 0) + ); + }, [exceptions]); + + // The builder can have existing exception items, or new exception items that have yet + // to be created (and thus lack an id), this was creating some React bugs with relying + // on the index, as a result, created a temporary id when new exception items are first + // instantiated that is stored in `meta` that gets stripped on it's way out + const getExceptionListItemId = (item: ExceptionsBuilderExceptionItem, index: number): string => { + if ((item as ExceptionListItemSchema).id != null) { + return (item as ExceptionListItemSchema).id; + } else if ((item as CreateExceptionListItemBuilderSchema).meta.temporaryUuid != null) { + return (item as CreateExceptionListItemBuilderSchema).meta.temporaryUuid; + } else { + return `${index}`; + } + }; + + return ( + + {(isLoading || indexPatternLoading) && ( + + )} + {exceptions.map((exceptionListItem, index) => ( + + + {index !== 0 && + (andLogicIncluded ? ( + + + + + + + + + + + ) : ( + + + + ))} + + + + + + ))} + + + + {andLogicIncluded && ( + + + + )} + + {}} + /> + + + + + ); +}; + +ExceptionBuilder.displayName = 'ExceptionBuilder'; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx index b936aea047690..3e3b86cc60585 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx @@ -16,8 +16,10 @@ import { getTagsInclude, getDescriptionListContent, getFormattedComments, + filterExceptionItems, + getNewExceptionItem, } from './helpers'; -import { FormattedEntry, DescriptionListItem } from './types'; +import { FormattedEntry, DescriptionListItem, EmptyEntry } from './types'; import { isOperator, isNotOperator, @@ -27,8 +29,8 @@ import { isNotInListOperator, existsOperator, doesNotExistOperator, -} from './operators'; -import { OperatorTypeEnum } from '../../../lists_plugin_deps'; +} from '../autocomplete/operators'; +import { OperatorTypeEnum, OperatorEnum } from '../../../lists_plugin_deps'; import { getExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; import { getEntryExistsMock, @@ -169,7 +171,7 @@ describe('Exception helpers', () => { fieldName: 'host.name', isNested: false, operator: 'exists', - value: null, + value: undefined, }, ]; expect(result).toEqual(expected); @@ -221,13 +223,13 @@ describe('Exception helpers', () => { fieldName: 'host.name', isNested: false, operator: 'exists', - value: null, + value: undefined, }, { fieldName: 'host.name', isNested: false, - operator: null, - value: null, + operator: undefined, + value: undefined, }, { fieldName: 'host.name.host.name', @@ -407,4 +409,36 @@ describe('Exception helpers', () => { expect(wrapper.text()).toEqual('some old comment'); }); }); + + describe('#filterExceptionItems', () => { + test('it removes empty entry items', () => { + const { entries, ...rest } = getExceptionListItemSchemaMock(); + const mockEmptyException: EmptyEntry = { + field: 'host.name', + type: OperatorTypeEnum.MATCH, + operator: OperatorEnum.INCLUDED, + value: undefined, + }; + const exceptions = filterExceptionItems([ + { + ...rest, + entries: [...entries, mockEmptyException], + }, + ]); + + expect(exceptions).toEqual([getExceptionListItemSchemaMock()]); + }); + + test('it removes `temporaryId` from items', () => { + const { meta, ...rest } = getNewExceptionItem({ + listType: 'detection', + listId: '123', + namespaceType: 'single', + ruleName: 'rule name', + }); + const exceptions = filterExceptionItems([{ ...rest, meta }]); + + expect(exceptions).toEqual([{ ...rest, meta: undefined }]); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx index ae4131f9f62c2..c8b3d3f527270 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx @@ -8,28 +8,44 @@ import React from 'react'; import { EuiText, EuiCommentProps, EuiAvatar } from '@elastic/eui'; import { capitalize } from 'lodash'; import moment from 'moment'; +import uuid from 'uuid'; import * as i18n from './translations'; -import { FormattedEntry, OperatorOption, DescriptionListItem } from './types'; -import { EXCEPTION_OPERATORS, isOperator } from './operators'; +import { + FormattedEntry, + BuilderEntry, + EmptyListEntry, + DescriptionListItem, + FormattedBuilderEntry, + CreateExceptionListItemBuilderSchema, + ExceptionsBuilderExceptionItem, +} from './types'; +import { EXCEPTION_OPERATORS, isOperator } from '../autocomplete/operators'; +import { OperatorOption } from '../autocomplete/types'; import { CommentsArray, Entry, - EntriesArray, ExceptionListItemSchema, + NamespaceType, OperatorTypeEnum, + CreateExceptionListItemSchema, + entry, entriesNested, - entriesExists, - entriesList, + createExceptionListItemSchema, + exceptionListItemSchema, } from '../../../lists_plugin_deps'; +import { IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/common'; + +export const isListType = (item: BuilderEntry): item is EmptyListEntry => + item.type === OperatorTypeEnum.LIST; /** * Returns the operator type, may not need this if using io-ts types * - * @param entry a single ExceptionItem entry + * @param item a single ExceptionItem entry */ -export const getOperatorType = (entry: Entry): OperatorTypeEnum => { - switch (entry.type) { +export const getOperatorType = (item: BuilderEntry): OperatorTypeEnum => { + switch (item.type) { case 'match': return OperatorTypeEnum.MATCH; case 'match_any': @@ -45,36 +61,46 @@ export const getOperatorType = (entry: Entry): OperatorTypeEnum => { * Determines operator selection (is/is not/is one of, etc.) * Default operator is "is" * - * @param entry a single ExceptionItem entry + * @param item a single ExceptionItem entry */ -export const getExceptionOperatorSelect = (entry: Entry): OperatorOption => { - if (entriesNested.is(entry)) { +export const getExceptionOperatorSelect = (item: BuilderEntry): OperatorOption => { + if (entriesNested.is(item)) { return isOperator; } else { - const operatorType = getOperatorType(entry); + const operatorType = getOperatorType(item); const foundOperator = EXCEPTION_OPERATORS.find((operatorOption) => { - return entry.operator === operatorOption.operator && operatorType === operatorOption.type; + return item.operator === operatorOption.operator && operatorType === operatorOption.type; }); return foundOperator ?? isOperator; } }; +export const getExceptionOperatorFromSelect = (value: string): OperatorOption => { + const operator = EXCEPTION_OPERATORS.filter(({ message }) => message === value); + return operator[0] ?? isOperator; +}; + /** * Formats ExceptionItem entries into simple field, operator, value * for use in rendering items in table * * @param entries an ExceptionItem's entries */ -export const getFormattedEntries = (entries: EntriesArray): FormattedEntry[] => { - const formattedEntries = entries.map((entry) => { - if (entriesNested.is(entry)) { - const parent = { fieldName: entry.field, operator: null, value: null, isNested: false }; - return entry.entries.reduce( +export const getFormattedEntries = (entries: BuilderEntry[]): FormattedEntry[] => { + const formattedEntries = entries.map((item) => { + if (entriesNested.is(item)) { + const parent = { + fieldName: item.field, + operator: undefined, + value: undefined, + isNested: false, + }; + return item.entries.reduce( (acc, nestedEntry) => { const formattedEntry = formatEntry({ isNested: true, - parent: entry.field, + parent: item.field, item: nestedEntry, }); return [...acc, { ...formattedEntry }]; @@ -82,20 +108,24 @@ export const getFormattedEntries = (entries: EntriesArray): FormattedEntry[] => [parent] ); } else { - return formatEntry({ isNested: false, item: entry }); + return formatEntry({ isNested: false, item }); } }); return formattedEntries.flat(); }; -export const getEntryValue = (entry: Entry): string | string[] | null => { - if (entriesList.is(entry)) { - return entry.list.id; - } else if (entriesExists.is(entry)) { - return null; - } else { - return entry.value; +export const getEntryValue = (item: BuilderEntry): string | string[] | undefined => { + switch (item.type) { + case OperatorTypeEnum.MATCH: + case OperatorTypeEnum.MATCH_ANY: + return item.value; + case OperatorTypeEnum.EXISTS: + return undefined; + case OperatorTypeEnum.LIST: + return item.list.id; + default: + return undefined; } }; @@ -109,13 +139,13 @@ export const formatEntry = ({ }: { isNested: boolean; parent?: string; - item: Entry; + item: BuilderEntry; }): FormattedEntry => { const operator = getExceptionOperatorSelect(item); const value = getEntryValue(item); return { - fieldName: isNested ? `${parent}.${item.field}` : item.field, + fieldName: isNested ? `${parent}.${item.field}` : item.field ?? '', operator: operator.message, value, isNested, @@ -192,3 +222,122 @@ export const getFormattedComments = (comments: CommentsArray): EuiCommentProps[] timelineIcon: , children: {comment.comment}, })); + +export const getFormattedBuilderEntries = ( + indexPattern: IIndexPattern, + entries: BuilderEntry[] +): FormattedBuilderEntry[] => { + const { fields } = indexPattern; + return entries.map((item) => { + if (entriesNested.is(item)) { + return { + parent: item.field, + operator: isOperator, + nested: getFormattedBuilderEntries(indexPattern, item.entries), + field: undefined, + value: undefined, + }; + } else { + const [selectedField] = fields.filter( + ({ name }) => item.field != null && item.field === name + ); + return { + field: selectedField, + operator: getExceptionOperatorSelect(item), + value: getEntryValue(item), + }; + } + }); +}; + +export const getValueFromOperator = ( + field: IFieldType | undefined, + selectedOperator: OperatorOption +): Entry => { + const fieldValue = field != null ? field.name : ''; + switch (selectedOperator.type) { + case 'match': + return { + field: fieldValue, + type: OperatorTypeEnum.MATCH, + operator: selectedOperator.operator, + value: '', + }; + case 'match_any': + return { + field: fieldValue, + type: OperatorTypeEnum.MATCH_ANY, + operator: selectedOperator.operator, + value: [], + }; + case 'list': + return { + field: fieldValue, + type: OperatorTypeEnum.LIST, + operator: selectedOperator.operator, + list: { id: '', type: 'ip' }, + }; + default: + return { + field: fieldValue, + type: OperatorTypeEnum.EXISTS, + operator: selectedOperator.operator, + }; + } +}; + +export const getNewExceptionItem = ({ + listType, + listId, + namespaceType, + ruleName, +}: { + listType: 'detection' | 'endpoint'; + listId: string; + namespaceType: NamespaceType; + ruleName: string; +}): CreateExceptionListItemBuilderSchema => { + return { + _tags: [listType], + comments: [], + description: `${ruleName} - exception list item`, + entries: [ + { + field: '', + operator: 'included', + type: 'match', + value: '', + }, + ], + item_id: undefined, + list_id: listId, + meta: { + temporaryUuid: uuid.v4(), + }, + name: `${ruleName} - exception list item`, + namespace_type: namespaceType, + tags: [], + type: 'simple', + }; +}; + +export const filterExceptionItems = ( + exceptions: ExceptionsBuilderExceptionItem[] +): Array => { + return exceptions.reduce>( + (acc, exception) => { + const entries = exception.entries.filter((t) => entry.is(t) || entriesNested.is(t)); + const item = { ...exception, entries }; + if (exceptionListItemSchema.is(item)) { + return [...acc, item]; + } else if (createExceptionListItemSchema.is(item) && item.meta != null) { + const { meta, ...rest } = item; + const itemSansMetaId: CreateExceptionListItemSchema = { ...rest, meta: undefined }; + return [...acc, itemSansMetaId]; + } else { + return acc; + } + }, + [] + ); +}; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts index 27dab7cf9db29..093842f5e6c24 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts @@ -3,6 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ + import { i18n } from '@kbn/i18n'; export const DETECTION_LIST = i18n.translate( @@ -137,3 +138,65 @@ export const SHOWING_EXCEPTIONS = (items: number) => values: { items }, defaultMessage: 'Showing {items} {items, plural, =1 {exception} other {exceptions}}', }); + +export const FIELD = i18n.translate('xpack.securitySolution.exceptions.fieldDescription', { + defaultMessage: 'Field', +}); + +export const OPERATOR = i18n.translate('xpack.securitySolution.exceptions.operatorDescription', { + defaultMessage: 'Operator', +}); + +export const VALUE = i18n.translate('xpack.securitySolution.exceptions.valueDescription', { + defaultMessage: 'Value', +}); + +export const EXCEPTION_FIELD_PLACEHOLDER = i18n.translate( + 'xpack.securitySolution.exceptions.exceptionFieldPlaceholderDescription', + { + defaultMessage: 'Search', + } +); + +export const EXCEPTION_OPERATOR_PLACEHOLDER = i18n.translate( + 'xpack.securitySolution.exceptions.exceptionOperatorPlaceholderDescription', + { + defaultMessage: 'Operator', + } +); + +export const EXCEPTION_FIELD_VALUE_PLACEHOLDER = i18n.translate( + 'xpack.securitySolution.exceptions.exceptionFieldValuePlaceholderDescription', + { + defaultMessage: 'Search field value...', + } +); + +export const EXCEPTION_FIELD_LISTS_PLACEHOLDER = i18n.translate( + 'xpack.securitySolution.exceptions.exceptionListsPlaceholderDescription', + { + defaultMessage: 'Search for list...', + } +); + +export const ADD_EXCEPTION_TITLE = i18n.translate( + 'xpack.securitySolution.exceptions.addExceptionTitle', + { + defaultMessage: 'Add exception', + } +); + +export const AND = i18n.translate('xpack.securitySolution.exceptions.andDescription', { + defaultMessage: 'AND', +}); + +export const OR = i18n.translate('xpack.securitySolution.exceptions.orDescription', { + defaultMessage: 'OR', +}); + +export const ADD_NESTED_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.exceptions.addNestedDescription', + { + defaultMessage: 'Add nested condition', + } +); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts index ed2be64b4430f..d5a0afe47c48e 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts @@ -4,20 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ import { ReactNode } from 'react'; - -import { Operator, OperatorType } from '../../../lists_plugin_deps'; - -export interface OperatorOption { - message: string; - value: string; - operator: Operator; - type: OperatorType; -} +import { IFieldType } from '../../../../../../../src/plugins/data/common'; +import { OperatorOption } from '../autocomplete/types'; +import { + EntryNested, + Entry, + ExceptionListItemSchema, + CreateExceptionListItemSchema, + OperatorTypeEnum, + OperatorEnum, +} from '../../../lists_plugin_deps'; export interface FormattedEntry { fieldName: string; - operator: string | null; - value: string | string[] | null; + operator: string | undefined; + value: string | string[] | undefined; isNested: boolean; } @@ -49,3 +50,46 @@ export interface ExceptionsPagination { totalItemCount: number; pageSizeOptions: number[]; } + +export interface FormattedBuilderEntryBase { + field: IFieldType | undefined; + operator: OperatorOption; + value: string | string[] | undefined; +} + +export interface FormattedBuilderEntry extends FormattedBuilderEntryBase { + parent?: string; + nested?: FormattedBuilderEntryBase[]; +} + +export interface EmptyEntry { + field: string | undefined; + operator: OperatorEnum; + type: OperatorTypeEnum.MATCH | OperatorTypeEnum.MATCH_ANY; + value: string | string[] | undefined; +} + +export interface EmptyListEntry { + field: string | undefined; + operator: OperatorEnum; + type: OperatorTypeEnum.LIST; + list: { id: string | undefined; type: string | undefined }; +} + +export type BuilderEntry = Entry | EmptyListEntry | EmptyEntry | EntryNested; + +export type ExceptionListItemBuilderSchema = Omit & { + entries: BuilderEntry[]; +}; + +export type CreateExceptionListItemBuilderSchema = Omit< + CreateExceptionListItemSchema, + 'meta' | 'entries' +> & { + meta: { temporaryUuid: string }; + entries: BuilderEntry[]; +}; + +export type ExceptionsBuilderExceptionItem = + | ExceptionListItemBuilderSchema + | CreateExceptionListItemBuilderSchema; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_entries.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_entries.test.tsx index c6a779845b190..dedf7f2b22380 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_entries.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_entries.test.tsx @@ -115,8 +115,8 @@ describe('ExceptionEntries', () => { test('it renders nested entry', () => { const parentEntry = getFormattedEntryMock(); - parentEntry.operator = null; - parentEntry.value = null; + parentEntry.operator = undefined; + parentEntry.value = undefined; const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> diff --git a/x-pack/plugins/security_solution/public/lists_plugin_deps.ts b/x-pack/plugins/security_solution/public/lists_plugin_deps.ts index f3a724a755a48..f1482029b82c9 100644 --- a/x-pack/plugins/security_solution/public/lists_plugin_deps.ts +++ b/x-pack/plugins/security_solution/public/lists_plugin_deps.ts @@ -9,23 +9,32 @@ export { useExceptionList, usePersistExceptionItem, usePersistExceptionList, + useFindLists, ExceptionIdentifiers, ExceptionList, Pagination, UseExceptionListSuccess, } from '../../lists/public'; export { + ListSchema, CommentsArray, ExceptionListSchema, ExceptionListItemSchema, + CreateExceptionListItemSchema, Entry, EntryExists, EntryNested, + EntryList, EntriesArray, NamespaceType, Operator, + OperatorEnum, OperatorType, OperatorTypeEnum, + exceptionListItemSchema, + createExceptionListItemSchema, + listSchema, + entry, entriesNested, entriesExists, entriesList,